From f03b44d2e563d781f669f962f2ae7b986c0d6bff Mon Sep 17 00:00:00 2001 From: 0lcm Date: Sun, 24 May 2026 15:28:33 -0400 Subject: [PATCH] Submission commit --- codingTracker.0lcm/README.md | 75 ++++++ .../SqliteRepositoryTests.cs | 97 +++++++ ...codingTracker.0lcm.IntegrationTests.csproj | 29 ++ .../TimeValidationServiceTests.cs | 52 ++++ .../codingTracker.0lcm.UnitTests.csproj | 28 ++ codingTracker.0lcm/codingTracker.0lcm.slnx | 6 + .../codingTracker.0lcm/Data/AppDbContext.cs | 20 ++ .../codingTracker.0lcm/Data/DbConfig.cs | 11 + .../20260524162017_InitialCommit.Designer.cs | 48 ++++ .../20260524162017_InitialCommit.cs | 38 +++ .../Migrations/AppDbContextModelSnapshot.cs | 45 ++++ .../Extensions/EnumExtensions.cs | 15 ++ .../Interfaces/IDateTimeFormats.cs | 9 + .../Interfaces/ISessionService.cs | 13 + .../Interfaces/ISqliteRepository.cs | 11 + .../Interfaces/ITimeValidationService.cs | 9 + .../codingTracker.0lcm/Logging/Logging.cs | 73 ++++++ .../Models/CodingSession.cs | 22 ++ .../Models/DateTimeFormats.cs | 21 ++ .../codingTracker.0lcm/Models/Enums.cs | 33 +++ .../codingTracker.0lcm/Models/Timer.cs | 19 ++ .../codingTracker.0lcm/Program.cs | 82 ++++++ .../Properties/launchSettings.json | 8 + .../Repositories/SqliteRepository.cs | 33 +++ .../Services/SessionService.cs | 69 +++++ .../Services/TimeValidationService.cs | 74 ++++++ .../User Input/UserInputHelper.cs | 81 ++++++ .../User Interface/ConsoleUi.cs | 248 ++++++++++++++++++ .../User Interface/DisplayHelper.cs | 134 ++++++++++ .../codingTracker.0lcm/appSettings.json | 19 ++ .../codingTracker.0lcm.csproj | 45 ++++ .../codingTracker.0lcm/codingTracker.db | Bin 0 -> 16384 bytes 32 files changed, 1467 insertions(+) create mode 100644 codingTracker.0lcm/README.md create mode 100644 codingTracker.0lcm/codingTracker.0lcm.IntegrationTests/SqliteRepositoryTests.cs create mode 100644 codingTracker.0lcm/codingTracker.0lcm.IntegrationTests/codingTracker.0lcm.IntegrationTests.csproj create mode 100644 codingTracker.0lcm/codingTracker.0lcm.UnitTests/TimeValidationServiceTests.cs create mode 100644 codingTracker.0lcm/codingTracker.0lcm.UnitTests/codingTracker.0lcm.UnitTests.csproj create mode 100644 codingTracker.0lcm/codingTracker.0lcm.slnx create mode 100644 codingTracker.0lcm/codingTracker.0lcm/Data/AppDbContext.cs create mode 100644 codingTracker.0lcm/codingTracker.0lcm/Data/DbConfig.cs create mode 100644 codingTracker.0lcm/codingTracker.0lcm/Data/Migrations/20260524162017_InitialCommit.Designer.cs create mode 100644 codingTracker.0lcm/codingTracker.0lcm/Data/Migrations/20260524162017_InitialCommit.cs create mode 100644 codingTracker.0lcm/codingTracker.0lcm/Data/Migrations/AppDbContextModelSnapshot.cs create mode 100644 codingTracker.0lcm/codingTracker.0lcm/Extensions/EnumExtensions.cs create mode 100644 codingTracker.0lcm/codingTracker.0lcm/Interfaces/IDateTimeFormats.cs create mode 100644 codingTracker.0lcm/codingTracker.0lcm/Interfaces/ISessionService.cs create mode 100644 codingTracker.0lcm/codingTracker.0lcm/Interfaces/ISqliteRepository.cs create mode 100644 codingTracker.0lcm/codingTracker.0lcm/Interfaces/ITimeValidationService.cs create mode 100644 codingTracker.0lcm/codingTracker.0lcm/Logging/Logging.cs create mode 100644 codingTracker.0lcm/codingTracker.0lcm/Models/CodingSession.cs create mode 100644 codingTracker.0lcm/codingTracker.0lcm/Models/DateTimeFormats.cs create mode 100644 codingTracker.0lcm/codingTracker.0lcm/Models/Enums.cs create mode 100644 codingTracker.0lcm/codingTracker.0lcm/Models/Timer.cs create mode 100644 codingTracker.0lcm/codingTracker.0lcm/Program.cs create mode 100644 codingTracker.0lcm/codingTracker.0lcm/Properties/launchSettings.json create mode 100644 codingTracker.0lcm/codingTracker.0lcm/Repositories/SqliteRepository.cs create mode 100644 codingTracker.0lcm/codingTracker.0lcm/Services/SessionService.cs create mode 100644 codingTracker.0lcm/codingTracker.0lcm/Services/TimeValidationService.cs create mode 100644 codingTracker.0lcm/codingTracker.0lcm/User Input/UserInputHelper.cs create mode 100644 codingTracker.0lcm/codingTracker.0lcm/User Interface/ConsoleUi.cs create mode 100644 codingTracker.0lcm/codingTracker.0lcm/User Interface/DisplayHelper.cs create mode 100644 codingTracker.0lcm/codingTracker.0lcm/appSettings.json create mode 100644 codingTracker.0lcm/codingTracker.0lcm/codingTracker.0lcm.csproj create mode 100644 codingTracker.0lcm/codingTracker.0lcm/codingTracker.db diff --git a/codingTracker.0lcm/README.md b/codingTracker.0lcm/README.md new file mode 100644 index 0000000..6516a52 --- /dev/null +++ b/codingTracker.0lcm/README.md @@ -0,0 +1,75 @@ +# About Project +This is project was created as a learning project from [CSharpAcademy's Coding Tracker project](https://thecsharpacademy.com/project/13/coding-tracker) +and follows the requirments set there. +The main functionality of the project lies in creating and tracking the user's coding sessions, allowing them to create, view, update, and delete their sessions. +This is a basic console project and uses the Spectre.Console library for Ui related issues, and Sqlite and Dapper ORM for the database. + +# Features - Overview +* Most menus and prompts use basic up, down, and enter keys, requiring little user typing, leading to less possibilities for input error, and an easier life for the user. +* Menus use Spectre.Console for a nice look, and easy accesibility. + ![Image displaying the main menu, with several options for input.](https://i.imgur.com/rF9FrGu.png) +* Viewing sessions shows a brief overview of each session, allowing the user to select any session to see more details. + ![Image displaying a menu along with various sessions](https://i.imgur.com/asyxmGM.png) +* The user can easily filter for specific dates, or filter sessions to show in ascending/descending order. + ![Image displaying a menu along with different options, including a 'filter sessions' and 'clear filters' option, along with various sessions arranged by ascending duration.](https://i.imgur.com/3a4DF6j.png) +* Each session can be indivdually selected to see more details, as well as take certain actions like deleting or updating a session. + ![Image displaying a menu that displays a session's details, and provides a 'Delete', 'Update' and 'Return' option.](https://i.imgur.com/xao218V.png) +* Confirmation screens are displayed for destructive actions, and common user mistakes. + ![Image displaying a deletion confirmation screen](https://i.imgur.com/pxlxj5O.png) +* Features a timer to that records a coding session in real time. + ![Image displaying a menu labeled "Press 'Q' To Stop Timer"](https://i.imgur.com/i9et7w9.png) + +# Features - Detailed +## Menus and Ui +For menus with constant values such as main menus, or certain action menus, they are displayed using the DisplayHelper's DisplayMenu method, which uses the AnsiConsole.Prompt +method. This method uses a generic type, and allows Enums to be passed in. The menu is created from the Enum's items, and uses an enum extension to make items more presentable, +for example; the main menu's enum has an item called "NewSession", but using the enum extension, this is displayed as "New Session", leading to much more readable menus. +For menus that use display flexible values instead of constant ones, DisplayHelper's DisplayPrompt method is called. This method is functionally identical to DisplayMenu, +except it takes in a `List` parameter instead of using Enums. +The DisplayHelper.cs file takes care of almost all the calls to Spectre.Console's Ui methods, and uses constant strings of hex codes for different colors, allowing the asthetics +of the application to stay focused on consistency, since almost all needs for displaying Ui is passed through these helper methods. Furthermore, the constant strings use an +`internal` access modifier, meaning any Ui needs outside of DisplayHelper.cs can be displayed with the same consistent colors. Using internal constant strings also means that +if a color needs to be changed within the application, you can simply swap out out the hex code string value for that color, or you can even remove colors or add new colors all +by switching one section of code. + +## Viewing and Filtering Sessions +The main method for viewing sessions is the ViewSessions method within the ConsoleUi.cs class. This method displays a functionally empty loading screen to the user just to +present the user with the idea of loading, although this only shows once per trip to the method, since it gets annoying quickly if you have to wait everytime, especially when +filtering sessions. After the method has finished loading, it displays a menu containing a "Return to Main Menu", "Filter Sessions", and a conditional "Clear Filters" option. +It also displays each current session recorded in the database, along with it's ID, Date of creation, and duration. From there the user can either select the "Filter Sessions" +option to go to the filtering menu, or they can select any individual session to see more details and actions. Going to the filter menu allows the user to select a date to filter +by, either 'Today' for today's date, 'Other' to enter a specific date, or 'Default' to not filter by date. Afterwards theres also an 'Ascending' and 'Descending' option, as well +as another 'Default' option for not filtering by ascending/descending. Loading an invidual session allows the user to see more details of a session, such as the full: date, +start time, end time, and duration. There are also options to delete the specific session, update the session, or return to the previous menu. + +## Timer and Real Time Logging +Using the timer creates an asynchronous task, and then passes the task to DisplayHelper's DisplayAsyncSpinner method, which displays a spinner until the timer is cancelled or +finished. A title is passed to the spinner to let the user know to press 'Q' in order to stop the timer, and the application waits unitl the Q key is pressed before cancelling +the task and ending the spinner. Afterwards, it displays a spinner while saving the session to the database, and returns to the main menu. + +# Resources Used +[.NET (10.0)](https://learn.microsoft.com/en-us/dotnet/) +[Spectre.Console (0.54.0)](https://spectreconsole.net) - Ui +[Dapper (2.1.66)](https://www.learndapper.com) - ORM +[Microsoft.Data.Sqlite (10.0.2)](https://learn.microsoft.com/en-us/dotnet/standard/data/sqlite/?tabs=net-cli) - Database +[Microsoft.Extensions.Configuration (10.0.2)](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.configuration?view=net-10.0-pp) - Configuration +[Microsoft.Extensions.Configuration.EnviromentVariables (10.0.2)](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.configuration.environmentvariablesextensions?view=net-10.0-pp) +[Microsoft.Extensions.Configuration.Json (10.0.2)](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.configuration.json?view=net-8.0-pp) +[Microsoft.Extensions.Logging (10.0.2)](https://learn.microsoft.com/en-us/dotnet/core/extensions/logging/overview?tabs=command-line) - Logging +[Microsoft.Extensions.Logging.Abstractions (10.0.2)](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.abstractions?view=net-10.0-pp) +[Microsoft.Extensions.Logging.Console (10.0.2)](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.console?view=net-8.0-pp) + +# Personal Thoughts +When I first started this project I was a little confused on how I should start, but i was able to get an idea after a while. In fact, in comparison to past projects, this one +felt a lot easier, Maybe because I in the [last project I had](https://www.thecsharpacademy.com/project/12/habit-logger), I had to figure out how to use Sqlite's database from +scratch, whereas for this one I already had a little idea of how to use it. +By far, my favorite part of this project was learning and using Spectre.Console. It made it so easy to customize my application, and it amazed me how things that I would have no +idea how to do otherwise, could be done with just a single line of code. This also gave me the ability to turn the basic console of black and white text into real menus with +options, selections, colors, and tons of other things to make my life easier and make the app nicer. +Overall, this project was very fun and I enjoyed it, although I'm a little nervous for the next project since I heard it gets a lot more difficult, but I'm still excited to start. +One thing I'd like to learn more of would be proper error handling, since right now my application doesn't do much other than logging it to the console and keeping it from +crashing. I think I could also learn more about Seperation of Concerns and how to keep everything clean. I tried to seperate the code into a Ui layer for actually Displaying things +to the user, a Service layer for taking care of the real logic, and a Database layer for dealing with the Sqlite commands and operations, but I think I could still improve on +these topics. +I definitly enjoyed this project, and the fact that the gap of knowledge wasn't as big as it felt in previous projects let me relax a little more and not worry about having to +learn a ton of new things at once. I'm excited to start the next project, and hopefully it wont take long before I have to write a second ReadMe. diff --git a/codingTracker.0lcm/codingTracker.0lcm.IntegrationTests/SqliteRepositoryTests.cs b/codingTracker.0lcm/codingTracker.0lcm.IntegrationTests/SqliteRepositoryTests.cs new file mode 100644 index 0000000..1bc4349 --- /dev/null +++ b/codingTracker.0lcm/codingTracker.0lcm.IntegrationTests/SqliteRepositoryTests.cs @@ -0,0 +1,97 @@ +using codingTracker._0lcm.Data; +using codingTracker._0lcm.Interfaces; +using codingTracker._0lcm.Models; +using codingTracker._0lcm.Repositories; +using Microsoft.EntityFrameworkCore; + +namespace codingTracker._0lcm.IntegrationTests; + +public class SqliteRepositoryTests +{ + private AppDbContext _dbContext; + private ISqliteRepository _sqliteRepo; + + [SetUp] + public void Setup() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + _dbContext = new AppDbContext(options); + _sqliteRepo = new SqliteRepository(_dbContext); + } + + [TearDown] + public void TearDown() + { + _dbContext.Database.EnsureDeleted(); + _dbContext.Dispose(); + } + + [Test] + public async Task InsertCodingSessionAsync_ShouldCreateNewCodingSession() + { + var codingSession = new CodingSession + { + StartTime = DateTime.Now, + EndTime = DateTime.Now, + Duration = TimeSpan.FromSeconds(1) + }; + + await _sqliteRepo.InsertCodingSessionAsync(codingSession); + + var sessions = await _sqliteRepo.GetSessionsAsync(); + Assert.That(sessions, Is.Not.Empty); + Assert.That(sessions[0].StartTime, Is.EqualTo(codingSession.StartTime)); + } + + [Test] + public async Task DeleteCodingSessionAsync_ShouldDeleteSession() + { + var codingSession = new CodingSession + { + StartTime = DateTime.Now, + EndTime = DateTime.Now, + Duration = TimeSpan.FromSeconds(1) + }; + + await _sqliteRepo.InsertCodingSessionAsync(codingSession); + await _sqliteRepo.DeleteCodingSessionAsync(codingSession); + + var sessions = await _sqliteRepo.GetSessionsAsync(); + Assert.That(sessions, Is.Empty); + } + + [Test] + public async Task UpdateCodingSessionAsync_ShouldUpdateSession() + { + var originalDuration = TimeSpan.FromSeconds(1); + + var codingSession = new CodingSession + { + StartTime = DateTime.Now, + EndTime = DateTime.Now, + Duration = originalDuration + }; + + await _sqliteRepo.InsertCodingSessionAsync(codingSession); + + codingSession.Duration = TimeSpan.FromSeconds(2); + + await _sqliteRepo.UpdateCodingSessionAsync(codingSession); + + var sessions = await _sqliteRepo.GetSessionsAsync(); + + Assert.That(sessions[0].Duration, Is.EqualTo(codingSession.Duration)); + Assert.That(sessions[0].Duration, Is.Not.EqualTo(originalDuration)); + } + + [Test] + public async Task GetSessionsAsync_WhenDatabaseIsEmpty_ShouldReturnEmptyList() + { + var sessions = await _sqliteRepo.GetSessionsAsync(); + + Assert.That(sessions, Is.Empty); + } +} \ No newline at end of file diff --git a/codingTracker.0lcm/codingTracker.0lcm.IntegrationTests/codingTracker.0lcm.IntegrationTests.csproj b/codingTracker.0lcm/codingTracker.0lcm.IntegrationTests/codingTracker.0lcm.IntegrationTests.csproj new file mode 100644 index 0000000..6291bd5 --- /dev/null +++ b/codingTracker.0lcm/codingTracker.0lcm.IntegrationTests/codingTracker.0lcm.IntegrationTests.csproj @@ -0,0 +1,29 @@ + + + + net10.0 + codingTracker._0lcm.IntegrationTests + latest + enable + enable + false + + + + + + + + + + + + + + + + + + + + diff --git a/codingTracker.0lcm/codingTracker.0lcm.UnitTests/TimeValidationServiceTests.cs b/codingTracker.0lcm/codingTracker.0lcm.UnitTests/TimeValidationServiceTests.cs new file mode 100644 index 0000000..465d491 --- /dev/null +++ b/codingTracker.0lcm/codingTracker.0lcm.UnitTests/TimeValidationServiceTests.cs @@ -0,0 +1,52 @@ +using codingTracker._0lcm.Interfaces; +using codingTracker._0lcm.Services; +using Moq; + +namespace codingTracker._0lcm.NUnitTesting; + +public class TimeValidationTests +{ + private Mock _dateTimeFormats; + private ITimeValidationService _timeValidationService; + + [SetUp] + public void SetUp() + { + _dateTimeFormats = new Mock(); + + _dateTimeFormats.Setup(d => d.HourFormats).Returns(["H:mm", "HH:mm"]); + _dateTimeFormats.Setup(d => d.DateFormats).Returns(["yyyy-MM-dd", "yyyy-MM-d", "yyyy-M-dd", "yyyy-M-d"]); + + _timeValidationService = new TimeValidationService(_dateTimeFormats.Object); + } + + [TestCase("2026-1-1", true)] + [TestCase("2026-01-01", true)] + [TestCase("2026-01-1", true)] + [TestCase("2026-1-01", true)] + [TestCase("2026-31-1", false)] + [TestCase("26-1-1", false)] + [TestCase("2026/1/1", false)] + public void TryValidateDateTime_GivenValidInput_ReturnCorrectBool(string input, bool expectedResult) + { + var result = _timeValidationService.TryValidateDateTime(input, out _, out _); + + Assert.That(result, Is.EqualTo(expectedResult)); + } + + [TestCase("10:10", "12:20", true)] + [TestCase("10:10", "08:20", true)] + [TestCase("10:10", "12:20", true)] + [TestCase("1:10", "02:20", true)] + [TestCase("01:10", "2:20", true)] + [TestCase("10:01", "12:00", true)] + [TestCase("10:1", "12:20", false)] + public void TryValidateStartAndEndTimes_GivenValidInputs_ReturnCorrectBool(string startTimeInput, + string endTimeInput, bool expectedResult) + { + var result = + _timeValidationService.TryValidateStartAndEndTimePair(startTimeInput, endTimeInput, out _, out _, out _); + + Assert.That(result, Is.EqualTo(expectedResult)); + } +} \ No newline at end of file diff --git a/codingTracker.0lcm/codingTracker.0lcm.UnitTests/codingTracker.0lcm.UnitTests.csproj b/codingTracker.0lcm/codingTracker.0lcm.UnitTests/codingTracker.0lcm.UnitTests.csproj new file mode 100644 index 0000000..4bad259 --- /dev/null +++ b/codingTracker.0lcm/codingTracker.0lcm.UnitTests/codingTracker.0lcm.UnitTests.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + codingTracker._0lcm.NUnitTesting + latest + enable + enable + false + + + + + + + + + + + + + + + + + + + diff --git a/codingTracker.0lcm/codingTracker.0lcm.slnx b/codingTracker.0lcm/codingTracker.0lcm.slnx new file mode 100644 index 0000000..e639d0a --- /dev/null +++ b/codingTracker.0lcm/codingTracker.0lcm.slnx @@ -0,0 +1,6 @@ + + + + + diff --git a/codingTracker.0lcm/codingTracker.0lcm/Data/AppDbContext.cs b/codingTracker.0lcm/codingTracker.0lcm/Data/AppDbContext.cs new file mode 100644 index 0000000..6565555 --- /dev/null +++ b/codingTracker.0lcm/codingTracker.0lcm/Data/AppDbContext.cs @@ -0,0 +1,20 @@ +using codingTracker._0lcm.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace codingTracker._0lcm.Data; + +public class AppDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet CodingSessions { get; set; } +} + +public class AppDbContextFactory : IDesignTimeDbContextFactory +{ + public AppDbContext CreateDbContext(string[] args) + { + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder.UseSqlite(DbConfig.GetConnectionString()); + return new AppDbContext(optionsBuilder.Options); + } +} \ No newline at end of file diff --git a/codingTracker.0lcm/codingTracker.0lcm/Data/DbConfig.cs b/codingTracker.0lcm/codingTracker.0lcm/Data/DbConfig.cs new file mode 100644 index 0000000..9277b80 --- /dev/null +++ b/codingTracker.0lcm/codingTracker.0lcm/Data/DbConfig.cs @@ -0,0 +1,11 @@ +namespace codingTracker._0lcm.Data; + +public static class DbConfig +{ + public static string GetConnectionString() + { + return $"Data Source={Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "CodingTracker", + "app.db")}"; + } +} \ No newline at end of file diff --git a/codingTracker.0lcm/codingTracker.0lcm/Data/Migrations/20260524162017_InitialCommit.Designer.cs b/codingTracker.0lcm/codingTracker.0lcm/Data/Migrations/20260524162017_InitialCommit.Designer.cs new file mode 100644 index 0000000..c2936b0 --- /dev/null +++ b/codingTracker.0lcm/codingTracker.0lcm/Data/Migrations/20260524162017_InitialCommit.Designer.cs @@ -0,0 +1,48 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using codingTracker._0lcm.Data; + +#nullable disable + +namespace codingTracker._0lcm.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260524162017_InitialCommit")] + partial class InitialCommit + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.2"); + + modelBuilder.Entity("codingTracker._0lcm.Models.CodingSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("Duration") + .HasColumnType("TEXT"); + + b.Property("EndTime") + .HasColumnType("TEXT"); + + b.Property("StartTime") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("CodingSessions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/codingTracker.0lcm/codingTracker.0lcm/Data/Migrations/20260524162017_InitialCommit.cs b/codingTracker.0lcm/codingTracker.0lcm/Data/Migrations/20260524162017_InitialCommit.cs new file mode 100644 index 0000000..987b311 --- /dev/null +++ b/codingTracker.0lcm/codingTracker.0lcm/Data/Migrations/20260524162017_InitialCommit.cs @@ -0,0 +1,38 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace codingTracker._0lcm.Data.Migrations +{ + /// + public partial class InitialCommit : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "CodingSessions", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Date = table.Column(type: "TEXT", nullable: false), + StartTime = table.Column(type: "TEXT", nullable: false), + EndTime = table.Column(type: "TEXT", nullable: false), + Duration = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CodingSessions", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "CodingSessions"); + } + } +} diff --git a/codingTracker.0lcm/codingTracker.0lcm/Data/Migrations/AppDbContextModelSnapshot.cs b/codingTracker.0lcm/codingTracker.0lcm/Data/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..8fe958b --- /dev/null +++ b/codingTracker.0lcm/codingTracker.0lcm/Data/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,45 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using codingTracker._0lcm.Data; + +#nullable disable + +namespace codingTracker._0lcm.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + partial class AppDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.2"); + + modelBuilder.Entity("codingTracker._0lcm.Models.CodingSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("Duration") + .HasColumnType("TEXT"); + + b.Property("EndTime") + .HasColumnType("TEXT"); + + b.Property("StartTime") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("CodingSessions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/codingTracker.0lcm/codingTracker.0lcm/Extensions/EnumExtensions.cs b/codingTracker.0lcm/codingTracker.0lcm/Extensions/EnumExtensions.cs new file mode 100644 index 0000000..5748841 --- /dev/null +++ b/codingTracker.0lcm/codingTracker.0lcm/Extensions/EnumExtensions.cs @@ -0,0 +1,15 @@ +using System.Text.RegularExpressions; + +namespace codingTracker._0lcm.Extensions; + +internal static class EnumExtensions +{ + internal static string ToDisplayString(this Enum value) + { + return Regex.Replace( + value.ToString(), + "([a-z])([A-Z])", + "$1 $2" + ); + } +} \ No newline at end of file diff --git a/codingTracker.0lcm/codingTracker.0lcm/Interfaces/IDateTimeFormats.cs b/codingTracker.0lcm/codingTracker.0lcm/Interfaces/IDateTimeFormats.cs new file mode 100644 index 0000000..61cd9a3 --- /dev/null +++ b/codingTracker.0lcm/codingTracker.0lcm/Interfaces/IDateTimeFormats.cs @@ -0,0 +1,9 @@ +namespace codingTracker._0lcm.Interfaces; + +public interface IDateTimeFormats +{ + public string[] HourFormats { get; } + public string[] DateFormats { get; } + public string DateIso { get; } + public string FullDateWithTimeFormat { get; } +} \ No newline at end of file diff --git a/codingTracker.0lcm/codingTracker.0lcm/Interfaces/ISessionService.cs b/codingTracker.0lcm/codingTracker.0lcm/Interfaces/ISessionService.cs new file mode 100644 index 0000000..8bbe4e0 --- /dev/null +++ b/codingTracker.0lcm/codingTracker.0lcm/Interfaces/ISessionService.cs @@ -0,0 +1,13 @@ +using codingTracker._0lcm.Models; +using Timer = codingTracker._0lcm.Models.Timer; + +namespace codingTracker._0lcm.Interfaces; + +public interface ISessionService +{ + public Task CreateSession(DateTime startTime, DateTime endTime, TimeSpan duration); + public Task CreateTimerTask(Timer timer, CancellationToken cancellationToken); + public Task SaveTimer(Timer timer); + public Task> GetFilteredSessions(DateOnly? filterDate = null, bool? ascending = true); + public Task CheckForExistingSessionDate(DateOnly date); +} \ No newline at end of file diff --git a/codingTracker.0lcm/codingTracker.0lcm/Interfaces/ISqliteRepository.cs b/codingTracker.0lcm/codingTracker.0lcm/Interfaces/ISqliteRepository.cs new file mode 100644 index 0000000..9adeca6 --- /dev/null +++ b/codingTracker.0lcm/codingTracker.0lcm/Interfaces/ISqliteRepository.cs @@ -0,0 +1,11 @@ +using codingTracker._0lcm.Models; + +namespace codingTracker._0lcm.Interfaces; + +public interface ISqliteRepository +{ + public Task InsertCodingSessionAsync(CodingSession session); + public Task DeleteCodingSessionAsync(CodingSession session); + public Task UpdateCodingSessionAsync(CodingSession session); + public Task> GetSessionsAsync(); +} \ No newline at end of file diff --git a/codingTracker.0lcm/codingTracker.0lcm/Interfaces/ITimeValidationService.cs b/codingTracker.0lcm/codingTracker.0lcm/Interfaces/ITimeValidationService.cs new file mode 100644 index 0000000..8f05c85 --- /dev/null +++ b/codingTracker.0lcm/codingTracker.0lcm/Interfaces/ITimeValidationService.cs @@ -0,0 +1,9 @@ +namespace codingTracker._0lcm.Interfaces; + +public interface ITimeValidationService +{ + public bool TryValidateStartAndEndTimePair(string startTimeInput, string endTimeInput, out DateTime startTime, + out DateTime endTime, out string? errorMessage); + + public bool TryValidateDateTime(string dateInput, out DateOnly date, out string? errorMessage); +} \ No newline at end of file diff --git a/codingTracker.0lcm/codingTracker.0lcm/Logging/Logging.cs b/codingTracker.0lcm/codingTracker.0lcm/Logging/Logging.cs new file mode 100644 index 0000000..5dcd1d8 --- /dev/null +++ b/codingTracker.0lcm/codingTracker.0lcm/Logging/Logging.cs @@ -0,0 +1,73 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging.Console; + +namespace codingTracker._0lcm.Logging; + +internal class CustomFormatter : ConsoleFormatter +{ + public CustomFormatter() : base("customFormatter") + { + } + + public override void Write( + in LogEntry logEntry, + IExternalScopeProvider? scopeProvider, + TextWriter textWriter + ) + { + var message = logEntry.Formatter(logEntry.State, logEntry.Exception); + if (string.IsNullOrEmpty(message)) return; + + var originalColor = Console.ForegroundColor; + try + { + Console.ForegroundColor = GetLogLevelColor(logEntry.LogLevel); + + textWriter.Write($"[{DateTimeOffset.Now:HH:mm:ss}]"); + + textWriter.Write($"[{logEntry.LogLevel,-12}]"); + + textWriter.Write($"[{logEntry.Category}]"); + + textWriter.Write(message); + + if (logEntry.Exception != null) textWriter.Write(logEntry.Exception.ToString()); + } + finally + { + Console.ForegroundColor = originalColor; + } + } + + private static ConsoleColor GetLogLevelColor(LogLevel logLevel) + { + return logLevel switch + { + LogLevel.Trace => ConsoleColor.Gray, + LogLevel.Debug => ConsoleColor.Gray, + LogLevel.Information => ConsoleColor.Green, + LogLevel.Warning => ConsoleColor.Yellow, + LogLevel.Error => ConsoleColor.Red, + LogLevel.Critical => ConsoleColor.Magenta, + _ => ConsoleColor.White + }; + } +} + +internal class AppLogger +{ + private static readonly ILoggerFactory AppLoggerFactory = + LoggerFactory.Create(builder => + { + builder + .SetMinimumLevel(LogLevel.Debug) + .AddConsole(options => { options.FormatterName = "customFormatter"; }) + .AddConsoleFormatter(); + }); + + internal static ILogger CreateLogger() + { + return AppLoggerFactory.CreateLogger(); + } +} \ No newline at end of file diff --git a/codingTracker.0lcm/codingTracker.0lcm/Models/CodingSession.cs b/codingTracker.0lcm/codingTracker.0lcm/Models/CodingSession.cs new file mode 100644 index 0000000..5e89871 --- /dev/null +++ b/codingTracker.0lcm/codingTracker.0lcm/Models/CodingSession.cs @@ -0,0 +1,22 @@ +namespace codingTracker._0lcm.Models; + +public class CodingSession +{ + public CodingSession() + { + } + + public CodingSession(DateTime startTime, DateTime endTime, TimeSpan duration) + { + StartTime = startTime; + EndTime = endTime; + Duration = TimeSpan.FromSeconds(Math.Floor(duration.TotalSeconds)); + Date = DateOnly.FromDateTime(startTime); + } + + public int Id { get; set; } + public DateOnly Date { get; set; } + public DateTime StartTime { get; set; } + public DateTime EndTime { get; set; } + public TimeSpan Duration { get; set; } +} \ No newline at end of file diff --git a/codingTracker.0lcm/codingTracker.0lcm/Models/DateTimeFormats.cs b/codingTracker.0lcm/codingTracker.0lcm/Models/DateTimeFormats.cs new file mode 100644 index 0000000..8bc30ff --- /dev/null +++ b/codingTracker.0lcm/codingTracker.0lcm/Models/DateTimeFormats.cs @@ -0,0 +1,21 @@ +using codingTracker._0lcm.Interfaces; +using Microsoft.Extensions.Configuration; + +namespace codingTracker._0lcm.Models; + +public class DateTimeFormats(IConfiguration configuration) : IDateTimeFormats +{ + public string[] HourFormats { get; } = + configuration.GetSection("TimeFormats:HourFormat").Get() + ?? ["H:mm", "HH:mm"]; + + public string[] DateFormats { get; } = + configuration.GetSection("TimeFormats:DateFormats").Get() + ?? ["yyyy-MM-dd", "yyyy-MM-d", "yyyy-M-dd", "yyyy-M-d"]; + + public string DateIso { get; } = + configuration["TimeFormats:DateIso"] ?? "yyyy-MM-dd"; + + public string FullDateWithTimeFormat { get; } = + configuration["TimeFormats:FullDateWithTimeFormat"] ?? "yyyy-MM-dd HH:mm:ss"; +} \ No newline at end of file diff --git a/codingTracker.0lcm/codingTracker.0lcm/Models/Enums.cs b/codingTracker.0lcm/codingTracker.0lcm/Models/Enums.cs new file mode 100644 index 0000000..a2c1bf2 --- /dev/null +++ b/codingTracker.0lcm/codingTracker.0lcm/Models/Enums.cs @@ -0,0 +1,33 @@ +namespace codingTracker._0lcm.Models; + +internal class Enums +{ + internal enum MainMenuOption + { + NewSession, + ViewSessions, + StartTimer, + Exit + } + + internal enum LoadSessionOption + { + Delete, + Update, + Return + } + + internal enum FilterSessionDateOption + { + Today, + Other, + Default + } + + internal enum FilterSessionAscendingOption + { + Ascending, + Descending, + Default + } +} \ No newline at end of file diff --git a/codingTracker.0lcm/codingTracker.0lcm/Models/Timer.cs b/codingTracker.0lcm/codingTracker.0lcm/Models/Timer.cs new file mode 100644 index 0000000..9b00302 --- /dev/null +++ b/codingTracker.0lcm/codingTracker.0lcm/Models/Timer.cs @@ -0,0 +1,19 @@ +namespace codingTracker._0lcm.Models; + +public class Timer +{ + public Timer() + { + StartTime = DateTime.Now; + } + + public DateTime StartTime { get; private set; } + public DateTime EndTime { get; private set; } + public bool IsStopped { get; private set; } + + public void Stop() + { + EndTime = DateTime.Now; + IsStopped = true; + } +} \ No newline at end of file diff --git a/codingTracker.0lcm/codingTracker.0lcm/Program.cs b/codingTracker.0lcm/codingTracker.0lcm/Program.cs new file mode 100644 index 0000000..bbda04d --- /dev/null +++ b/codingTracker.0lcm/codingTracker.0lcm/Program.cs @@ -0,0 +1,82 @@ +using codingTracker._0lcm.Data; +using codingTracker._0lcm.Interfaces; +using codingTracker._0lcm.Models; +using codingTracker._0lcm.Repositories; +using codingTracker._0lcm.Services; +using codingTracker._0lcm.User_Input; +using codingTracker._0lcm.User_Interface; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +var builder = Host.CreateApplicationBuilder(args); + +builder.Configuration + .SetBasePath(AppContext.BaseDirectory) + .AddJsonFile("appsettings.json", false, true); + +builder.Services.AddDbContext(options => + options + .UseSqlite(DbConfig.GetConnectionString())); + +builder.Services.AddTransient(); +builder.Services.AddTransient(); +builder.Services.AddTransient(); + +builder.Services.AddTransient(); +builder.Services.AddTransient(); + +builder.Services.AddTransient(); + +builder.Logging.SetMinimumLevel(LogLevel.Warning); + +builder.Services.AddHostedService(); + +var app = builder.Build(); + +using (var scope = app.Services.CreateScope()) +{ + var db = scope.ServiceProvider.GetRequiredService(); + + var dbDirectory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "CodingTracker"); + Directory.CreateDirectory(dbDirectory); + + await db.Database.MigrateAsync(); +} + +await app.RunAsync(); + +internal class Worker : BackgroundService +{ + private readonly ConsoleUi _consoleUi; + + public Worker(ConsoleUi consoleUi) + { + _consoleUi = consoleUi; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await _consoleUi.MainMenu(); + } +} + +// { +// internal class Program +// { +// internal static readonly IConfiguration configuration = new ConfigurationBuilder() +// .SetBasePath(AppContext.BaseDirectory) +// .AddJsonFile("appSettings.json", optional: false, reloadOnChange: true) +// .Build(); +// +// static async Task Main(string[] args) +// { +// var repo = new SqliteController(); +// repo.CreateDatabase(); +// await ConsoleUi.MainMenu(); +// } +// } +// } \ No newline at end of file diff --git a/codingTracker.0lcm/codingTracker.0lcm/Properties/launchSettings.json b/codingTracker.0lcm/codingTracker.0lcm/Properties/launchSettings.json new file mode 100644 index 0000000..3ef287b --- /dev/null +++ b/codingTracker.0lcm/codingTracker.0lcm/Properties/launchSettings.json @@ -0,0 +1,8 @@ +{ + "profiles": { + "codingTracker.0lcm": { + "commandName": "Project", + "workingDirectory": "$(ProjectDir)" + } + } +} \ No newline at end of file diff --git a/codingTracker.0lcm/codingTracker.0lcm/Repositories/SqliteRepository.cs b/codingTracker.0lcm/codingTracker.0lcm/Repositories/SqliteRepository.cs new file mode 100644 index 0000000..02367b1 --- /dev/null +++ b/codingTracker.0lcm/codingTracker.0lcm/Repositories/SqliteRepository.cs @@ -0,0 +1,33 @@ +using codingTracker._0lcm.Data; +using codingTracker._0lcm.Interfaces; +using codingTracker._0lcm.Models; +using Microsoft.EntityFrameworkCore; + +namespace codingTracker._0lcm.Repositories; + +public class SqliteRepository(AppDbContext db) : ISqliteRepository +{ + public async Task InsertCodingSessionAsync(CodingSession session) + { + await db.CodingSessions.AddAsync(session); + await db.SaveChangesAsync(); + } + + public async Task DeleteCodingSessionAsync(CodingSession session) + { + db.CodingSessions.Remove(session); + await db.SaveChangesAsync(); + } + + public async Task UpdateCodingSessionAsync(CodingSession session) + { + db.CodingSessions.Update(session); + await db.SaveChangesAsync(); + } + + //------- Queries ------- + public Task> GetSessionsAsync() + { + return db.CodingSessions.ToListAsync(); + } +} \ No newline at end of file diff --git a/codingTracker.0lcm/codingTracker.0lcm/Services/SessionService.cs b/codingTracker.0lcm/codingTracker.0lcm/Services/SessionService.cs new file mode 100644 index 0000000..974372f --- /dev/null +++ b/codingTracker.0lcm/codingTracker.0lcm/Services/SessionService.cs @@ -0,0 +1,69 @@ +using codingTracker._0lcm.Interfaces; +using codingTracker._0lcm.Models; +using Timer = codingTracker._0lcm.Models.Timer; + +namespace codingTracker._0lcm.Services; + +public class SessionService(ISqliteRepository sqliteRepository) : ISessionService +{ + public async Task CreateSession(DateTime startTime, DateTime endTime, TimeSpan duration) + { + CodingSession session = new( + startTime, + endTime, + duration + ); + + await sqliteRepository.InsertCodingSessionAsync(session); + } + + public Task CreateTimerTask(Timer timer, CancellationToken cancellationToken) + { + var runTimer = Task.Run(async () => + { + try + { + await Task.Delay(Timeout.Infinite, cancellationToken); + } + catch (TaskCanceledException) + { + timer.Stop(); + } + }); + return runTimer; + } + + public async Task SaveTimer(Timer timer) + { + CodingSession session = new( + timer.StartTime, + timer.EndTime, + timer.EndTime - timer.StartTime + ); + + await sqliteRepository.InsertCodingSessionAsync(session); + } + + public async Task> GetFilteredSessions(DateOnly? filterDate = null, bool? ascending = true) + { + var sessions = await sqliteRepository.GetSessionsAsync(); + + if (filterDate.HasValue) sessions = sessions.Where(s => s.Date == filterDate.Value).ToList(); + + if (ascending != null) + sessions = (bool)ascending + ? sessions.OrderBy(s => s.Duration).ToList() + : sessions.OrderByDescending(s => s.Duration).ToList(); + + return sessions; + } + + public async Task CheckForExistingSessionDate(DateOnly date) + { + var sessions = await sqliteRepository.GetSessionsAsync(); + foreach (var session in sessions) + if (date == session.Date) + return true; + return false; + } +} \ No newline at end of file diff --git a/codingTracker.0lcm/codingTracker.0lcm/Services/TimeValidationService.cs b/codingTracker.0lcm/codingTracker.0lcm/Services/TimeValidationService.cs new file mode 100644 index 0000000..552f73d --- /dev/null +++ b/codingTracker.0lcm/codingTracker.0lcm/Services/TimeValidationService.cs @@ -0,0 +1,74 @@ +using System.Globalization; +using codingTracker._0lcm.Interfaces; + +namespace codingTracker._0lcm.Services; + +public class TimeValidationService(IDateTimeFormats dateTimeFormats) : ITimeValidationService +{ + private readonly string[] _dateFormats = dateTimeFormats.DateFormats; + private readonly string[] _hourFormats = dateTimeFormats.HourFormats; + + /// + /// Takes a startTime string and endTime string and attempts to parse them by 'H:mm' and 'HH:mm'. Also checks that + /// endTime + /// cannot be before starTime. outputs the validated startTime and endTime, as well as an error message if any + /// validation + /// check fails. + /// + public bool TryValidateStartAndEndTimePair( + string startTimeInput, + string endTimeInput, + out DateTime startTime, + out DateTime endTime, + out string? errorMessage) + { + startTime = default; + endTime = default; + errorMessage = null; + + var isValidStartTime = DateTime.TryParseExact(startTimeInput.Trim(), + _hourFormats, + CultureInfo.InvariantCulture, + DateTimeStyles.None, out var parsedStartTime); + var isValidEndTime = DateTime.TryParseExact(endTimeInput.Trim(), + _hourFormats, + CultureInfo.InvariantCulture, + DateTimeStyles.None, out var parsedEndTime); + + if (!isValidStartTime || !isValidEndTime) + { + errorMessage = "Invalid Time Format. Please Use HH:mm (e.g 09:30 or 9:30)"; + return false; + } + + startTime = DateTime.Today + parsedStartTime.TimeOfDay; + endTime = DateTime.Today + parsedEndTime.TimeOfDay; + + if (endTime <= startTime) endTime = endTime.AddDays(1); + + return true; + } + + /// + /// Trys to Parse a string to a DateTime following Iso yyyy-MM-dd to be converted to DateOnly and outputted. + /// + public bool TryValidateDateTime(string dateInput, out DateOnly date, out string? errorMessage) + { + date = default; + errorMessage = null; + + var isValidDate = DateTime.TryParseExact + (dateInput.Trim(), _dateFormats, + CultureInfo.InvariantCulture, + DateTimeStyles.None, out var parsedDate); + + if (!isValidDate) + { + errorMessage = "Invalid Time Format. Please Use yyyy-MM-dd"; + return false; + } + + date = DateOnly.FromDateTime(parsedDate); + return true; + } +} \ No newline at end of file diff --git a/codingTracker.0lcm/codingTracker.0lcm/User Input/UserInputHelper.cs b/codingTracker.0lcm/codingTracker.0lcm/User Input/UserInputHelper.cs new file mode 100644 index 0000000..fa308cb --- /dev/null +++ b/codingTracker.0lcm/codingTracker.0lcm/User Input/UserInputHelper.cs @@ -0,0 +1,81 @@ +using codingTracker._0lcm.Interfaces; +using codingTracker._0lcm.Models; +using codingTracker._0lcm.User_Interface; +using Spectre.Console; + +namespace codingTracker._0lcm.User_Input; + +internal class UserInputHelper(ITimeValidationService timeValidationService, ISessionService sessionService) +{ + /// + /// Asks user to manually input a valid start and end time and returns a new CodingSession + /// or updates and returns a pre-existing CodingSession if one is passed through. + /// + internal CodingSession GetSessionFromInput(CodingSession? session = null) + { + while (true) + { + Console.Clear(); + + DisplayHelper.DisplayInfo( + "Please Follow a 24hr HH:mm Format (00:00-23:59). Press to Submit Input."); + DisplayHelper.DisplayInfo( + "Please Note That If Your End Time Is Before Your Start Time It Will Automatically Be Counted as a Cross-Midnight Session.\n"); + + var startTimeInput = DisplayHelper.DisplayQuestion("Please Enter a Start Time:"); + var endTimeInput = DisplayHelper.DisplayQuestion("Please Enter an End Time:"); + + if (timeValidationService.TryValidateStartAndEndTimePair(startTimeInput, endTimeInput, + out var startTime, out var endTime, out var errorMessage)) + { + var duration = endTime - startTime; + if (duration.TotalHours >= 10) + if (!AnsiConsole.Confirm( + $"[{DisplayHelper.Red}]This Session Is {duration.TotalHours:F1} Hours Long. Is That Right?[/]")) + continue; + + if (session != null) + { + session.StartTime = startTime; + session.EndTime = endTime; + session.Duration = duration; + + return session; + } + + return new CodingSession( + startTime, + endTime, + endTime - startTime + ); + } + + DisplayHelper.DisplayUrgent(errorMessage ?? "Invalid Input."); + DisplayHelper.DisplayInfo("Press To Re-enter Time Selections."); + Console.ReadLine(); + } + } + + internal async Task GetDateInput() + { + while (true) + { + Console.Clear(); + + DisplayHelper.DisplayInfo("Please Follow YYYY-MM-dd Format"); + + var dateInput = DisplayHelper.DisplayQuestion("Please enter a date:"); + + if (timeValidationService.TryValidateDateTime(dateInput, out var date, out var errorMessage) + && await sessionService.CheckForExistingSessionDate(date)) + return date; + + if (errorMessage == null && !await sessionService.CheckForExistingSessionDate(date)) + errorMessage = "Date Is Not In Recorded Sessions. Please Choose A New Date."; + + DisplayHelper.DisplayUrgent(errorMessage ?? "Invalid Input."); + DisplayHelper.DisplayInfo("Press To Re-Enter Date Selection."); + Console.ReadLine(); + } + } +} \ No newline at end of file diff --git a/codingTracker.0lcm/codingTracker.0lcm/User Interface/ConsoleUi.cs b/codingTracker.0lcm/codingTracker.0lcm/User Interface/ConsoleUi.cs new file mode 100644 index 0000000..f4c22db --- /dev/null +++ b/codingTracker.0lcm/codingTracker.0lcm/User Interface/ConsoleUi.cs @@ -0,0 +1,248 @@ +using codingTracker._0lcm.Interfaces; +using codingTracker._0lcm.Logging; +using codingTracker._0lcm.Models; +using codingTracker._0lcm.User_Input; +using Microsoft.Extensions.Logging; +using Spectre.Console; +using Spectre.Console.Rendering; +using Timer = codingTracker._0lcm.Models.Timer; + +namespace codingTracker._0lcm.User_Interface; + +internal class ConsoleUi( + ISqliteRepository sqliteRepository, + ISessionService sessionService, + IDateTimeFormats dateTimeFormats, + UserInputHelper userInputHelper) +{ + private const string ReturnOption = "Return to Menu"; + private const string FilterOption = "Filter Sessions"; + private const string ClearFilterOption = "Clear Filters"; + private static readonly ILogger Logger = AppLogger.CreateLogger(); + private readonly string _dateIso = dateTimeFormats.DateIso; + + //------- Main Menu ------- + internal async Task MainMenu() + { + while (true) + try + { + Console.Clear(); + await HandleMainMenuChoice(); + } + catch (Exception ex) + { + HandleException(ex); + } + } + + private async Task HandleMainMenuChoice() + { + var mainMenuOption = DisplayHelper.DisplayMenu(); + switch (mainMenuOption) + { + case Enums.MainMenuOption.NewSession: + CreateNewSession(); + break; + + case Enums.MainMenuOption.ViewSessions: + ViewSessions(); + break; + + case Enums.MainMenuOption.StartTimer: + await StartTimer(); + break; + + case Enums.MainMenuOption.Exit: + ExitApplication(); + break; + } + } + + private void HandleException(Exception ex) + { + DisplayHelper.DisplayError("So Sorry, An Error Has Occured Somewhere:"); + Logger.LogError(ex, "An Error Ocurred In Main Menu, Or Subsequent Methods: "); + + DisplayHelper.DisplayWarning("Please Press Enter to Return to The Main Menu"); + Console.ReadLine(); + } + + private void ExitApplication() + { + Console.Clear(); + DisplayHelper.DisplaySpinner("Closing Application...", 1500); + Environment.Exit(0); + } + + //------- CRUD Operations ------- + private void CreateNewSession() + { + sqliteRepository.InsertCodingSessionAsync(userInputHelper.GetSessionFromInput()); + + DisplayHelper.DisplaySuccess("Succesfully Created Session!"); + DisplayHelper.DisplayInfo("Press To Continue."); + Console.ReadLine(); + } + + private void UpdateSession(CodingSession session) + { + sqliteRepository.UpdateCodingSessionAsync(userInputHelper.GetSessionFromInput(session)); + + DisplayHelper.DisplaySuccess("Succesfully Updated Session!"); + DisplayHelper.DisplayInfo("Press To Continue"); + Console.ReadLine(); + } + + private void DeleteSession(CodingSession session) + { + if (AnsiConsole.Confirm($"[{DisplayHelper.Red}]Are You Sure You Want to Delete This Session?[/]")) + { + sqliteRepository.DeleteCodingSessionAsync(session); + + DisplayHelper.DisplaySuccess("Succesfully Deleted Session."); + DisplayHelper.DisplayInfo("Press To Continue."); + Console.ReadLine(); + } + } + + //------- View Sessions ------- + private async Task ViewSessions() + { + DisplayHelper.DisplayInfo("Scroll With And . Press To Choose An Option."); + + List? filteredSessions = null; + var hasLoadedOnce = false; + + while (true) + { + Console.Clear(); + + if (!hasLoadedOnce) + { + DisplayHelper.DisplaySpinner("Loading Sessions...", 2000); + hasLoadedOnce = true; + } + + var sessionMap = await BuildSessionMap(filteredSessions); + + var choice = DisplayHelper.DisplayPrompt( + sessionMap.Keys.ToList(), + "| ID \t| Date \t | Duration |"); + + if (choice == ReturnOption) return; + if (choice == FilterOption) + { + filteredSessions = await FilterSessions(); + continue; + } + + if (choice == ClearFilterOption) + { + filteredSessions = null; + continue; + } + + LoadSpecificSession(sessionMap[choice]!); + } + } + + private async Task> BuildSessionMap(List? filteredSessions) + { + var sessions = filteredSessions ?? await sqliteRepository.GetSessionsAsync(); + var sessionMap = new Dictionary(); + + sessionMap[ReturnOption] = null; + sessionMap[FilterOption] = null; + if (filteredSessions != null) sessionMap[ClearFilterOption] = null; + + foreach (var session in sessions) + { + var date = session.Date.ToString(_dateIso); + var display = $"ID: {session.Id}. {date} - {session.Duration}"; + sessionMap[display] = session; + } + + return sessionMap; + } + + private void LoadSpecificSession(CodingSession session) + { + DisplayHelper.DisplaySpinner("Loading Session...", 1000); + DisplayHelper.DisplaySuccess("Succesfully Loaded Session:\n"); + + var date = session.Date.ToString(_dateIso); + var properties = new List + { + new Markup($"[{DisplayHelper.White}]Date: [{DisplayHelper.Yellow}]{date}[/][/]"), + new Markup($"[{DisplayHelper.White}]Start Time: [{DisplayHelper.Green}]{session.StartTime}[/][/]"), + new Markup($"[{DisplayHelper.White}]End Time: [{DisplayHelper.Red}]{session.EndTime}[/][/]"), + new Markup($"[{DisplayHelper.White}]Duration: [{DisplayHelper.Yellow}]{session.Duration}[/][/]") + }; + DisplayHelper.DisplayRows(properties); + DisplayHelper.DisplayMessage("\n"); + var choice = DisplayHelper.DisplayMenu(); + + switch (choice) + { + case Enums.LoadSessionOption.Delete: + DeleteSession(session); + break; + case Enums.LoadSessionOption.Update: + UpdateSession(session); + break; + case Enums.LoadSessionOption.Return: + return; + } + } + + //------- Filter Operations ------- + private async Task> FilterSessions() + { + var filterDateChoice = DisplayHelper.DisplayMenu + ("Select 'Today' For Today's Date, 'Other' To Manually Insert a Date, and 'All' To See All Sessions."); + + var filterChoice = DisplayHelper.DisplayMenu + ("Select 'Ascending' or 'Descending', or 'Default' For the Default Filtering."); + + DateOnly? filterDate = filterDateChoice switch + { + Enums.FilterSessionDateOption.Today => DateOnly.FromDateTime(DateTime.Today), + Enums.FilterSessionDateOption.Other => await userInputHelper.GetDateInput(), + Enums.FilterSessionDateOption.Default => null, + _ => throw new NotImplementedException() + }; + + bool? ascending = filterChoice switch + { + Enums.FilterSessionAscendingOption.Ascending => true, + Enums.FilterSessionAscendingOption.Descending => false, + Enums.FilterSessionAscendingOption.Default => null, + _ => throw new NotImplementedException() + }; + + return await sessionService.GetFilteredSessions(filterDate, ascending); + } + + //------- Timer ------- + private async Task StartTimer() + { + var timer = new Timer(); + + var cts = new CancellationTokenSource(); + var task = sessionService.CreateTimerTask(timer, cts.Token); + + var spinnerTask = DisplayHelper.DisplayAsyncSpinner("Press 'Q' To Stop Timer", task); + + while (Console.ReadKey(true).Key != ConsoleKey.Q) + { + } + + cts.Cancel(); + + await spinnerTask; + + sessionService.SaveTimer(timer); + DisplayHelper.DisplaySpinner("Saving Coding Session...", 3500); + } +} \ No newline at end of file diff --git a/codingTracker.0lcm/codingTracker.0lcm/User Interface/DisplayHelper.cs b/codingTracker.0lcm/codingTracker.0lcm/User Interface/DisplayHelper.cs new file mode 100644 index 0000000..5f1cea1 --- /dev/null +++ b/codingTracker.0lcm/codingTracker.0lcm/User Interface/DisplayHelper.cs @@ -0,0 +1,134 @@ +using codingTracker._0lcm.Extensions; +using Spectre.Console; +using Spectre.Console.Rendering; + +namespace codingTracker._0lcm.User_Interface; + +internal static class DisplayHelper +{ + //------- Colors ------- + internal const string White = "#f1f1f1"; + internal const string Grey = "#8c8e8f"; + internal const string Green = "#32aa3b"; + internal const string Red = "#cd2d2d"; + internal const string Yellow = "#e2b929"; + internal const string Error = "#870c00"; + + //------- Basic Outputs ------- + internal static void DisplayMessage(string message, bool writeLine = true) + { + if (writeLine) + AnsiConsole.MarkupLine($"[{White}]{message}[/]"); + else + AnsiConsole.Markup($"[{White}]{message}[/]"); + } + + internal static void DisplayRows(List rows, bool writeLine = true) + { + var rowsLayout = new Rows(rows); + AnsiConsole.Write(rowsLayout); + } + + internal static void DisplayInfo(string info, bool writeLine = true) + { + if (writeLine) + AnsiConsole.MarkupLine($"[{Grey}]{info}[/]"); + else + AnsiConsole.Markup($"[{Grey}]{info}[/]"); + } + + internal static void DisplaySuccess(string message, bool writeLine = true) + { + if (writeLine) + AnsiConsole.MarkupLine($"[{Green}]{message}[/]"); + else + AnsiConsole.Markup($"[{Green}]{message}[/]"); + } + + internal static void DisplayUrgent(string message, bool writeLine = true) + { + if (writeLine) + AnsiConsole.MarkupLine($"[{Red}]{message}[/]"); + else + AnsiConsole.Markup($"[{Red}]{message}[/]"); + } + + internal static void DisplayWarning(string message, bool writeLine = true) + { + if (writeLine) + AnsiConsole.MarkupLine($"[{Yellow}]{message}[/]"); + else + AnsiConsole.Markup($"[{Yellow}]{message}[/]"); + } + + internal static void DisplayError(string message, bool writeLine = true) + { + if (writeLine) + AnsiConsole.MarkupLine($"[{Error}]{message}[/]"); + else + AnsiConsole.Markup($"[{Error}]{message}[/]"); + } + + //------- Menus & Prompts ------- + internal static T DisplayMenu(string? title = null) where T : Enum + { + var menuChoice = AnsiConsole.Prompt( + new SelectionPrompt() + .Title(title ?? "Please Select An Option:") + .HighlightStyle(Style.Parse("darkviolet")) + .AddChoices(Enum.GetValues(typeof(T)).Cast()) + .UseConverter(e => e.ToDisplayString()) + ); + + + return menuChoice; + } + + internal static string DisplayPrompt(List choiceList, string? title = null) + { + var choice = AnsiConsole.Prompt( + new SelectionPrompt() + .Title(title ?? "Please Select An Option:") + .HighlightStyle(Style.Parse("darkviolet")) + .AddChoices(choiceList)); + + return choice; + } + + internal static List DisplayMultiPrompt(List choiceList, string? title = null, + bool requireChoice = true) + { + var prompt = new MultiSelectionPrompt() + .Title(title ?? "Please Select An Option:") + .HighlightStyle(Style.Parse("darkviolet")) + .InstructionsText($"[{Grey}]Press[/] [{White}][/] to Toggle, and [{White}][/] to Confirm") + .AddChoices(choiceList); + + if (requireChoice) + prompt.Required(); + else + prompt.NotRequired(); + + return AnsiConsole.Prompt(prompt); + } + + internal static string DisplayQuestion(string question) + { + var response = AnsiConsole.Ask($"[{White}]{question}[/]"); + return response; + } + + internal static void DisplaySpinner(string waitMessage, int waitTimeInMs = 3000) + { + AnsiConsole.Status() + .Spinner(Spinner.Known.Star) + .Start($"[{White}]{waitMessage}[/]", ctx => { Thread.Sleep(waitTimeInMs); }); + } + + internal static async Task DisplayAsyncSpinner(string waitMessage, Task task) + { + await AnsiConsole.Status() + .Spinner(Spinner.Known.Star) + .StartAsync($"[{White}]{waitMessage}[/]", async ctx => { await task; }); + } +} \ No newline at end of file diff --git a/codingTracker.0lcm/codingTracker.0lcm/appSettings.json b/codingTracker.0lcm/codingTracker.0lcm/appSettings.json new file mode 100644 index 0000000..bfadf52 --- /dev/null +++ b/codingTracker.0lcm/codingTracker.0lcm/appSettings.json @@ -0,0 +1,19 @@ +{ + "ConnectionStrings": { + "DefaultConnection": "Data Source=codingTracker.db" + }, + "TimeFormats": { + "DateIso": "yyyy-MM-dd", + "DateFormats": [ + "yyyy-MM-dd", + "yyyy-MM-d", + "yyyy-M-dd", + "yyyy-M-d" + ], + "HourFormats": [ + "H:mm", + "HH:mm" + ], + "FullDateWithTimeFormat": "yyyy-MM-dd HH:mm:ss" + } +} \ No newline at end of file diff --git a/codingTracker.0lcm/codingTracker.0lcm/codingTracker.0lcm.csproj b/codingTracker.0lcm/codingTracker.0lcm/codingTracker.0lcm.csproj new file mode 100644 index 0000000..fccfa74 --- /dev/null +++ b/codingTracker.0lcm/codingTracker.0lcm/codingTracker.0lcm.csproj @@ -0,0 +1,45 @@ + + + + Exe + net10.0 + codingTracker._0lcm + enable + enable + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + PreserveNewest + + + + + + + + + diff --git a/codingTracker.0lcm/codingTracker.0lcm/codingTracker.db b/codingTracker.0lcm/codingTracker.0lcm/codingTracker.db new file mode 100644 index 0000000000000000000000000000000000000000..5f9a4129bdbf6a4de738a7155282b75076a37c9b GIT binary patch literal 16384 zcmeI(J#W)M7zgk>zm%#{dWa>y@@ z_y~L%J_28a0WmQkCZ0=EQM;h7y!=mRpYQQKcjsTSSW!-n`&pUF7qj^^E~UqAGtSu~ zDH&tybQpA87ETxS{^h;jwd?HuJF6kSF@5tM+k7QHP=f*i2tWV=5P$##AOHafKmY=N zsKAlF*4*0S<1&7JmKNt{^ujnP((^@{pVqs^<6+Q^0vUA=`hl#CZRPPaZBwT8=78U7 zS{8r1znt@QmSp+Mle8$ZSzc6oT4l!SJ=tn($XdygpMhQ&1y6#Z3-1Gq@As@hhauWbd=&3R zOcy8+fB*y_009U<00Izz00bZaf&W~azHL*jg&Yd0xN1G4 zp5#3A-Hva2SCUqto#kXINv|i-N?vUx$MszmvUmKBYCY{rl1n?3j@kg#ijs0uX=z1Rwwb2tWV=5P$##An*?e+~K;#tN#Nqe*hNjz_tJY literal 0 HcmV?d00001