diff --git a/README.md b/README.md new file mode 100644 index 000000000..c4dae85d9 --- /dev/null +++ b/README.md @@ -0,0 +1,146 @@ +# Coding Tracker + + +## Overview +This is a basic console app meant for creating a log of coding sessions performed by a user. It is build on the requirements outlined in The C# Academy's Coding Tracker project (https://www.thecsharpacademy.com/project/13/coding-tracker). +This project was very similar to the previous project within the C# Academy roadmap, the Habit Logger. Due to the simmilarity, I attempted to use this as an opportunity to work with some design patterns. Admittedly, this caused the application to become much more complicated than was neccessary. However, it proved to be a very usefuly learning opportunity. + + +## Requirements: + +Base Requirements +* This is an application where you’ll log your daily coding time. +* Users need to be able to input the date and time of the coding session. +* The application should store and retrieve data from a real database. +* When the application starts, it should create a sqlite database, if one isn’t present. +* It should also create a table in the database, where the codingn sessions will be logged. +* The users should be able to insert, delete, update and view their logged sessions. +* You should handle all possible errors so that the application never crashes. +* Your project needs to contain a Read Me file where you'll explain how your app works. Here's a nice example: +* To show the data on the console, you should use the "Spectre.Console" library. +* You're required to have separate classes in different files (ex. UserInput.cs, Validation.cs, CodingController.cs) +* You should tell the user the specific format you want the date and time to be logged and not allow any other format. +* You'll need to create a configuration file that you'll contain your database path and connection strings. +* You'll need to create a "CodingSession" class in a separate file. It will contain the properties of your coding session: Id, StartTime, EndTime, Duration +* The user shouldn't input the duration of the session. It should be calculated based on the Start and End times, in a separate "CalculateDuration" method. +* The user should be able to input the start and end times manually. +* You need to use Dapper ORM for the data access instead of ADO.NET. (This requirement was included in Feb/2024) +* When reading from the database, you can't use an anonymous object, you have to read your table into a List of Coding Sessions. +* Follow the DRY Principle, and avoid code repetition. + +Challenge Requirements + +* Add the possibility of tracking the coding time via a stopwatch so the user can track the session as it happens. +* Let the users filter their coding records per period (weeks, days, years) and/or order ascending or descending. +* Create reports where the users can see their total and average coding session per period. +* Create the ability to set coding goals and show how far the users are from reaching their goal, along with how many hours a day they would have to code to reach their goal. You can do it via SQL queries or with C#. + + +## Technologies + +C# +SQLite Database Conenction +Dapper ORM +Spectre.Console + + +## Operation + +### Main Menu +The application opens into the main menu. Behind the scenes, it checks for the existence of a SQLite DB. If none exists, it will create one and seed it with some basic starter data. +Once initialized, the user can select between the menu options: + +Track Session --> Allows the user to insert new records into the DB +View/Manage Entries --> Allows the user to view records in the DB and either update or delete them +View Reports --> Provides some basic analytics for the user to see their progress over a specified time period +Manage Goals --> Evaluate, view, create, and delete goals +Exit --> Closes application + +### Track Session +This is the portion where the user can insert records into the database. There is a submenu that allows to select an input method. + +The option "Enter Start and End Times" will prompt the user for a start time, then and end time and calculate the duration. It will then print the data to the user and ask for confirmation before adding it to the database. + +The option "Start Time" will take a timestamp of the current time. The display will now wait for the user to press any key in order to stop the timer. Once the timer is stopped, it uses the start time and stop time to calculate a duration. This is followed by a confirmation prior to inserting into the DB. + +### View/Manage Entries +This option first requires the user to select a time period. Once selected, a list of coding sessions is printed. The user can then update or delete individual records within the list. + +#### Selecting the Time Period +The options for time period are abbreviated below: + +* All --> Retrieves all coding sessions in the database. +* Past Year --> Retrieves all coding sessions from now until (now - 12 months) (day exclusive). +* YTD --> Retrieves all coding sessions from the current calendar year (based on current date/time). +* Custom Week/Month/Year --> Retrieves all coding sessions with a the specified period. User is prompted for the start date and then the end date is calculated based on the period selected. + +All retrieved data is ordered by the start time in ascending format (built into the SQL query). + +#### Printing the Records +Once the time period is selected, the list of coding sessions is printed to the screen with an index. The user is then given an additional menu to update or delete any record in that list. + +#### Updating a Record +A record is selected to update based on the printed index. The user will be asked to enter a new start time. They can either enter a time or submit a blank entry to keep the current start time. +They are then asked to enter an end time. They can either enter a time or submit a blank entry to keep the current end time. +Once both times are provided, a confirmation will appear with the new start time, end time, and duration. Confirming will update the record in the database. + +#### Deleting a Record +A record is selected to delete based on the printed index. Once a record is selected, a confirmation will appear with the data for the record. Confirming will delete the record from the database. + +### View Reports +Reports give the user the ability to view the total session count, the total time spent coding, and the average time spent coding for each day in the period. This section begins by prompting the user for a time period. It then prints the seesions from that period, followed by the report data. + +#### Selecting the Time Period +The options for time period are abbreviated below: + +* All --> Retrieves all coding sessions in the database. +* Past Year --> Retrieves all coding sessions from now until (now - 12 months) (day exclusive). +* YTD --> Retrieves all coding sessions from the current calendar year (based on current date/time). +* Custom Week/Month/Year --> Retrieves all coding sessions with a the specified period. User is prompted for the start date and then the end date is calculated based on the period selected. + +All retrieved data is ordered by the start time in ascending format (built into the SQL query). + +#### Printing the Data +Once the time period is selected, the list of coding sessions is printed to the screen with an index. This is followed by the report data. + +### Manage Goals +Goals are a way for the user to set targets for their coding sessions. There are three types of goals: Total Time, Average Daily Time, and Days Per Period. Upon selecting this option, all active goals are evaluated. Any goals that are completed or failed are individually reported to the user. +Once any/all newly completed or failed goals are reported, a list of these goals and all active goals will print. From here the user can then choose to Add a Goal, Delete a Goal, or View Completed goals. Note that navigating away from this screen and returning will remove the newly completed/failed goals from the list. + +#### Add Goals +User selects the start time, end time, goal type (Total Time, Average Time, or Days Per Period), and then the Targe Value. Data is validated and then added to the goals table in the DB. + +Note: Goals cannot have an end time that has already expired. It MUST be a future time. The logic is that there is no real accomplishment in creating a goal for events that have already occurred. + +#### Delete Goal +Allows the user to select and deleted a goal based on the list. The list printed here is all In Progress, Completed, and Failed goals. + +#### View Complete Goals +This prints a list of all Completed goals so the user can see their shining accomplishments (Hooray!) + + +## Challenges + +My goal was to attempt to work with some design patterns for the creation of this application. Based on some of the reading in the requirements, I decided to use a version of MVC. +This utilized a set of models for storing and moving data, a view for printing data, and a controller for navigating between these. I also ended up adding in a service layer. The overall structure I went with is outlined below: + +image + +For the data access, I created a Sqlite Connection Factory. This was injected into a repository for Coding Sessions and one for Goals. This allowed me to inject my connection string in a single place, and use that connection for all CRUD operations. + +My coding session and goals repositories inherited from a Generic Repository that contained generic methods for Dapper query and execute commands. (Note that the overloaded methods were needed to fullfill a project requirement that disallowed the use of anonymous objects). + +I utilized custom type handlers for Dapper in order to more easily work with DateTime values and my own enums. + +For validation, I created my own generic validation results class (this was inspired by how Spectre.Console validated input). This was a really neat way to handle validation and reporting between layers. + +UI was done using Spectre.Console. Given additional time, I would love to clean it up and make it more showy, but see that as less important in my learning journey. + +## Future Enhancements + +* UI should be redesigned to be more user friendly + +* The separation of the "View/Manage Entries" and "View Reports" sections has a lot of redundencies. They should be combined in the future. + +* There is no protection in the program for a corrupted DB file. This will need to be fixed. + diff --git a/codingTracker.jzhartman/CodingTracker.Controller/CodingTracker.Controller.csproj b/codingTracker.jzhartman/CodingTracker.Controller/CodingTracker.Controller.csproj new file mode 100644 index 000000000..c14579d77 --- /dev/null +++ b/codingTracker.jzhartman/CodingTracker.Controller/CodingTracker.Controller.csproj @@ -0,0 +1,15 @@ + + + + net8.0 + enable + enable + + + + + + + + + diff --git a/codingTracker.jzhartman/CodingTracker.Controller/EntryListController.cs b/codingTracker.jzhartman/CodingTracker.Controller/EntryListController.cs new file mode 100644 index 000000000..358d70324 --- /dev/null +++ b/codingTracker.jzhartman/CodingTracker.Controller/EntryListController.cs @@ -0,0 +1,203 @@ +using CodingTracker.Models.Entities; +using CodingTracker.Services.Interfaces; +using CodingTracker.Views.Interfaces; + +namespace CodingTracker.Controller.Interfaces; + +public class EntryListController : IEntryListController +{ + private readonly ICodingSessionDataService _service; + private readonly IMenuView _menuView; + private readonly IUserInputView _inputView; + private readonly IConsoleOutputView _outputView; + + public EntryListController(ICodingSessionDataService service, + IMenuView menuView, IUserInputView inputView, IConsoleOutputView outputView) + { + _service = service; + _menuView = menuView; + _inputView = inputView; + _outputView = outputView; + } + + public void Run() + { + bool returnToPreviousMenu = false; + + while (!returnToPreviousMenu) + { + _outputView.WelcomeMessage(); + + var dateRangeSelection = GetDateRangeSelectionFromUser(); + + if (dateRangeSelection == "Return to Previous Menu") { returnToPreviousMenu = true; continue; } + + (DateTime startTime, DateTime endTime) = GetDatesBasedOnUserSelection(dateRangeSelection); + var sessions = _service.GetSessionListByDateRange(startTime, endTime); + + bool returnToDateSelection = false; + + while (!returnToDateSelection) + { + _outputView.PrintCodingSessionListAsTable(sessions); + + var selection = _menuView.PrintUpdateOrDeleteOptionsAndGetSelection(); + + switch (selection) + { + case "Change Record": + ManageSessionUpdate(sessions); + break; + case "Delete Record": + ManageSessionDelete(sessions); + break; + case "Return to Previous Menu": + returnToDateSelection = true; + break; + } + sessions = _service.GetSessionListByDateRange(startTime, endTime); + _outputView.WelcomeMessage(); + + } + } + } + + private void ManageSessionDelete(List sessions) + { + var recordId = _inputView.GetRecordIdFromUser("delete", sessions.Count()) - 1; + + if (ConfirmDelete(sessions[recordId])) + DeleteSession(sessions[recordId]); + else + _outputView.ActionCancelledMessage("deletion"); + + } + private void DeleteSession(CodingSessionDataRecord session) + { + _service.DeleteSessionById((int)session.Id); + } + private bool ConfirmDelete(CodingSessionDataRecord session) + { + return _inputView.GetDeleteSessionConfirmationFromUser(session); + } + private void ManageSessionUpdate(List sessions) + { + var recordId = _inputView.GetRecordIdFromUser("update", sessions.Count()) - 1; + _outputView.PrintCodingSessionToUpdateById(sessions[recordId], recordId+1); + + var newStartTime = GetUpdatedStartTime(sessions[recordId]); + var newEndTime = GetUpdatedEndTime(sessions[recordId], newStartTime); + + var updatedSession = new CodingSession(newStartTime, newEndTime); + + if (ConfirmUpdate(sessions[recordId], updatedSession)) + UpdateSession(updatedSession, sessions[recordId].Id); + else + _outputView.ActionCancelledMessage("update"); + } + private void UpdateSession(CodingSession session, long id) + { + var sessionDTO = new CodingSessionDataRecord {Id = id, StartTime = session.StartTime, EndTime = session.EndTime, Duration = (int)session.Duration }; + _service.UpdateSession(sessionDTO); + _outputView.ActionCompleteMessage(true, "Success", "Coding session successfully added!"); + } + private bool ConfirmUpdate(CodingSessionDataRecord session, CodingSession updatedSession) + { + return _inputView.GetUpdateSessionConfirmationFromUser(session, updatedSession); + } + private DateTime GetUpdatedStartTime(CodingSessionDataRecord session) + { + var output = new DateTime(); + bool startTimeValid = false; + + while (startTimeValid == false) + { + var newStartTime = _inputView.GetTimeFromUser("new [green]Start Time[/]", true); + var result = _service.ValidateUpdatedStartTime(session, newStartTime); + + if (result.IsValid) + { + startTimeValid = true; + output = result.Value; + _outputView.ConfirmationMessage(result.Value.ToString("yyyy-MM-dd HH:mm:ss")); + } + else + { + _outputView.ErrorMessage(result.Parameter, result.Message); + } + } + return output; + } + private DateTime GetUpdatedEndTime(CodingSessionDataRecord session, DateTime newStartTime) + { + var output = new DateTime(); + bool startTimeValid = false; + + while (startTimeValid == false) + { + var newEndTime = _inputView.GetTimeFromUser("new [red]End Time[/]", true); + var result = _service.ValidateUpdatedEndTime(session, newStartTime, newEndTime); + + if (result.IsValid) + { + startTimeValid = true; + output = result.Value; + _outputView.ConfirmationMessage(result.Value.ToString("yyyy-MM-dd HH:mm:ss")); + } + else + { + _outputView.ErrorMessage(result.Parameter, result.Message); + } + } + return output; + } + private string GetDateRangeSelectionFromUser() + { + return _menuView.PrintEntryViewOptionsAndGetSelection(); + } + private (DateTime, DateTime) GetDatesBasedOnUserSelection(string selection) + { + DateTime startTime = new DateTime(); + DateTime endTime = new DateTime(); + + switch (selection) + { + case "All": + case "Past Year": + case "Year to Date": + (startTime, endTime) = _service.GetBasicDateRange(selection); + break; + case "Custom Week": + case "Custom Month": + case "Custom Year": + startTime = GetRangeStartTime(); + endTime = _service.GetEndTimeForAdvancedDateRange(selection, startTime); + break; + } + + return (startTime, endTime); + } + private DateTime GetRangeStartTime() + { + var output = new DateTime(); + bool startTimeValid = false; + + while (startTimeValid == false) + { + var startTime = _inputView.GetTimeFromUser("start time"); + var result = _service.ValidateDateRangeStartTime(startTime); + + if (result.IsValid) + { + startTimeValid = true; + output = result.Value; + _outputView.ConfirmationMessage(result.Value.ToString("yyyy-MM-dd HH:mm:ss")); + } + else + { + _outputView.ErrorMessage(result.Parameter, result.Message); + } + } + return output; + } +} diff --git a/codingTracker.jzhartman/CodingTracker.Controller/GoalsController.cs b/codingTracker.jzhartman/CodingTracker.Controller/GoalsController.cs new file mode 100644 index 000000000..2d3b362c2 --- /dev/null +++ b/codingTracker.jzhartman/CodingTracker.Controller/GoalsController.cs @@ -0,0 +1,294 @@ +using CodingTracker.Controller.Interfaces; +using CodingTracker.Models.Entities; +using CodingTracker.Services.Interfaces; +using CodingTracker.Views.Interfaces; + +namespace CodingTracker.Controller; +public class GoalsController : IGoalsController +{ + private readonly IGoalDataService _goalService; + private readonly ICodingSessionDataService _codingSessionService; + private readonly IMenuView _menuView; + private readonly IConsoleOutputView _outputView; + private readonly IUserInputView _inputView; + + public GoalsController( IGoalDataService goalService, ICodingSessionDataService codingSessionService, + IMenuView menuView, IConsoleOutputView outputView, IUserInputView inputView) + { + _goalService = goalService; + _codingSessionService = codingSessionService; + _menuView = menuView; + _outputView = outputView; + _inputView = inputView; + } + + public void Run() + { + bool returnToMainMenu = false; + + while (!returnToMainMenu) + { + _outputView.WelcomeMessage(); + + var goalsInProgress = _goalService.GetAllGoalsByStatus(GoalStatus.InProgress); + EvaluateGoals(goalsInProgress); + + var selection = _menuView.PrintGoalOptionsAndGetSelection(); + + switch (selection) + { + case "Add Goal": + var goal = GetGoalDataFromUser(); + ConfirmAddGoal(goal); + break; + case "Delete Goal": + ManageGoalDelete(); + break; + case "View Completed Goals": + ViewCompletedGoals(); + break; + case "Return to Previous Menu": + returnToMainMenu = true; + break; + } + } + } + + private void ViewCompletedGoals() + { + var goals = _goalService.GetAllGoalsByStatus(GoalStatus.Complete); + + _outputView.WelcomeMessage(); + PrintGoalsList(goals); + _inputView.PressAnyKeyToContinue(); + } + private GoalModel GetGoalDataFromUser() + { + var startTime = GetStartTimeFromUser(); + var endTime = GetEndTimeFromUser(startTime); + var goalType = GetGoalTypeFromUser(); + var goalValue = GetGoalValue(startTime, endTime, goalType); + + return new GoalModel + { + StartTime = startTime, + EndTime = endTime, + Type = goalType, + GoalValue = goalValue, + Status = GoalStatus.InProgress, + CurrentValue = 0, + Progress = 0 + }; + } + private DateTime GetStartTimeFromUser() + { + var output = new DateTime(); + bool startTimeValid = false; + + while (startTimeValid == false) + { + output = _inputView.GetTimeFromUser("Goal start time"); + + var result = _codingSessionService.ValidateGoalStartTime(output); + + if (result.IsValid) + { + startTimeValid = true; + output = result.Value; + _outputView.ConfirmationMessage(result.Value.ToString("yyyy-MM-dd HH:mm:ss")); + } + else + { + _outputView.ErrorMessage(result.Parameter, result.Message); + } + } + return output; + } + private DateTime GetEndTimeFromUser(DateTime startTime) + { + var output = new DateTime(); + bool endTimeValid = false; + + while (endTimeValid == false) + { + output = _inputView.GetTimeFromUser("Goal end time"); + + var result = _codingSessionService.ValidateGoalEndTime(output, startTime); + + if (result.IsValid) + { + endTimeValid = true; + output = result.Value; + _outputView.ConfirmationMessage(result.Value.ToString("yyyy-MM-dd HH:mm:ss")); + } + else + { + _outputView.ErrorMessage(result.Parameter, result.Message); + } + } + return output; + } + private GoalType GetGoalTypeFromUser() + { + var selection = _menuView.PrintGoalTypesAndGetSelection(); + + switch (selection) + { + case "Total Time": + return GoalType.TotalTime; + case "Average Time": + return GoalType.AverageTime; + case "Days Per Period": + return GoalType.DaysPerPeriod; + default: + return new GoalType(); + } + } + private long GetGoalValue(DateTime startTime, DateTime endTime, GoalType goalType) + { + long output = -1; + bool valueIsValid = false; + + var maxValue = GetMaxGoalValueByType(goalType, startTime, endTime); + + while (valueIsValid == false) + { + var input = GetGoalValueFromUser(goalType); + var result = _goalService.ValidateGoalValueInput(goalType, input, maxValue); + + if (result.IsValid) + { + valueIsValid = true; + output = result.Value; + _outputView.ConfirmationMessage(result.Value.ToString()); + } + else + { + _outputView.ErrorMessage(result.Parameter, result.Message); + } + } + + return output; + } + private long GetGoalValueFromUser(GoalType goalType) + { + switch (goalType) + { + case GoalType.AverageTime: + case GoalType.TotalTime: + return _inputView.GetGoalValueTime(goalType); + + case GoalType.DaysPerPeriod: + return _inputView.GetGoalValueForDaysPerPeriod(); + + default: + return -1; + } + } + private long GetMaxGoalValueByType(GoalType goalType, DateTime startTime, DateTime endTime) + { + TimeSpan maxTime = new TimeSpan(); + + switch (goalType) + { + case GoalType.AverageTime: + maxTime = new TimeSpan(23, 59, 59); + return (long)maxTime.TotalSeconds; + + case GoalType.TotalTime: + maxTime = endTime - startTime; + return (long)maxTime.TotalSeconds; + + case GoalType.DaysPerPeriod: + maxTime = endTime - startTime; + return (long)maxTime.TotalDays; + + default: + return -1; + } + } + private void ConfirmAddGoal(GoalModel goal) + { + var goalConfirmed = _inputView.GetAddGoalConfirmationFromUser(goal); + + if (goalConfirmed) + { + _goalService.AddGoal(goal); + } + else + { + _outputView.GoalCancelledMessage("addition"); + _inputView.PressAnyKeyToContinue(); + } + } + + + private void ManageGoalDelete() + { + _outputView.WelcomeMessage(); + var goals = _goalService.GetAllGoals(); + + PrintGoalsList(goals); + + var recordId = _inputView.GetRecordIdFromUser("delete", goals.Count()) - 1; + + if (_inputView.GetDeleteGoalConfirmationFromUser(goals[recordId])) + { + _goalService.DeleteGoalById(recordId); + } + else + { + _outputView.GoalCancelledMessage("deletion"); + _inputView.PressAnyKeyToContinue(); + } + } + + private void PrintGoalsList(List goals) + { + _outputView.WelcomeMessage(); + + if (goals.Count <= 0) + _outputView.NoRecordsMessage("goals"); + else + _outputView.PrintGoalListAsTable(goals); + } + + private void EvaluateGoals(List goals) + { + UpdateGoalStatusAndProgress(goals); + PrintGoalsList(goals); + + } + private void UpdateGoalStatusAndProgress(List goals) + { + foreach (var goal in goals) + { + var codingSessions = _codingSessionService.GetSessionListByDateRange(goal.StartTime, goal.EndTime); + + switch (goal.Type) + { + case GoalType.TotalTime: + _goalService.EvaluateTotalTimeGoal(goal, codingSessions); + break; + case GoalType.AverageTime: + _goalService.EvaluateAverageTimeGoal(goal, codingSessions); + break; + case GoalType.DaysPerPeriod: + _goalService.EvaluateDaysPerPeriodGoal(goal, codingSessions); + break; + default: + _outputView.ErrorMessage("Goal Type", "Invalid Goal Type detected!"); + break; + } + + _goalService.EvaluateGoal(goal); + + if (goal.Status == GoalStatus.Complete || goal.Status == GoalStatus.Failed) + { + _outputView.WelcomeMessage(); + _outputView.GoalEvaluationMessage(goal); + _inputView.PressAnyKeyToContinue(); + } + } + } +} diff --git a/codingTracker.jzhartman/CodingTracker.Controller/Interfaces/IEntryListController.cs b/codingTracker.jzhartman/CodingTracker.Controller/Interfaces/IEntryListController.cs new file mode 100644 index 000000000..0b683fcdc --- /dev/null +++ b/codingTracker.jzhartman/CodingTracker.Controller/Interfaces/IEntryListController.cs @@ -0,0 +1,5 @@ +namespace CodingTracker.Controller.Interfaces; +public interface IEntryListController +{ + void Run(); +} \ No newline at end of file diff --git a/codingTracker.jzhartman/CodingTracker.Controller/Interfaces/IGoalsController.cs b/codingTracker.jzhartman/CodingTracker.Controller/Interfaces/IGoalsController.cs new file mode 100644 index 000000000..7ad682c66 --- /dev/null +++ b/codingTracker.jzhartman/CodingTracker.Controller/Interfaces/IGoalsController.cs @@ -0,0 +1,5 @@ +namespace CodingTracker.Controller.Interfaces; +public interface IGoalsController +{ + void Run(); +} \ No newline at end of file diff --git a/codingTracker.jzhartman/CodingTracker.Controller/Interfaces/IMainMenuController.cs b/codingTracker.jzhartman/CodingTracker.Controller/Interfaces/IMainMenuController.cs new file mode 100644 index 000000000..ec0de5201 --- /dev/null +++ b/codingTracker.jzhartman/CodingTracker.Controller/Interfaces/IMainMenuController.cs @@ -0,0 +1,5 @@ +namespace CodingTracker.Controller.Interfaces; +public interface IMainMenuController +{ + void Run(); +} \ No newline at end of file diff --git a/codingTracker.jzhartman/CodingTracker.Controller/Interfaces/IReportsController.cs b/codingTracker.jzhartman/CodingTracker.Controller/Interfaces/IReportsController.cs new file mode 100644 index 000000000..c177cd555 --- /dev/null +++ b/codingTracker.jzhartman/CodingTracker.Controller/Interfaces/IReportsController.cs @@ -0,0 +1,5 @@ +namespace CodingTracker.Controller.Interfaces; +public interface IReportsController +{ + void Run(); +} \ No newline at end of file diff --git a/codingTracker.jzhartman/CodingTracker.Controller/Interfaces/ITrackSessionController.cs b/codingTracker.jzhartman/CodingTracker.Controller/Interfaces/ITrackSessionController.cs new file mode 100644 index 000000000..586384d9c --- /dev/null +++ b/codingTracker.jzhartman/CodingTracker.Controller/Interfaces/ITrackSessionController.cs @@ -0,0 +1,5 @@ +namespace CodingTracker.Controller.Interfaces; +public interface ITrackSessionController +{ + void Run(); +} \ No newline at end of file diff --git a/codingTracker.jzhartman/CodingTracker.Controller/MainMenuController.cs b/codingTracker.jzhartman/CodingTracker.Controller/MainMenuController.cs new file mode 100644 index 000000000..4fbeeb666 --- /dev/null +++ b/codingTracker.jzhartman/CodingTracker.Controller/MainMenuController.cs @@ -0,0 +1,60 @@ +using CodingTracker.Controller.Interfaces; +using CodingTracker.Services.Interfaces; +using CodingTracker.Views.Interfaces; + +namespace CodingTracker.Controller; +public class MainMenuController : IMainMenuController +{ + private readonly ICodingSessionDataService _service; + private readonly ITrackSessionController _trackController; + private readonly IEntryListController _entryListController; + private readonly IReportsController _reportsController; + private readonly IGoalsController _goalsController; + private readonly IMenuView _menuView; + private readonly IConsoleOutputView _outputView; + + public MainMenuController(ICodingSessionDataService service, + ITrackSessionController trackController, IEntryListController entryListController, IReportsController reportsController, IGoalsController goalsController, + IMenuView menuView, IConsoleOutputView outputView) + { + _service = service; + _trackController = trackController; + _entryListController = entryListController; + _reportsController = reportsController; + _goalsController = goalsController; + _menuView = menuView; + _outputView = outputView; + } + + public void Run() + { + bool exitApp = false; + + while (!exitApp) + { + _outputView.WelcomeMessage(); + var selection = _menuView.PrintMainMenuAndGetSelection(); + + switch (selection) + { + case "Track Session": + _trackController.Run(); + break; + case "View/Manage Entries": + _entryListController.Run(); + break; + case "View Reports": + _reportsController.Run(); + break; + case "Manage Goal": + _goalsController.Run(); + break; + case "Exit": + exitApp = true; + break; + default: + break; + } + } + } +} diff --git a/codingTracker.jzhartman/CodingTracker.Controller/ReportsController.cs b/codingTracker.jzhartman/CodingTracker.Controller/ReportsController.cs new file mode 100644 index 000000000..638576bb0 --- /dev/null +++ b/codingTracker.jzhartman/CodingTracker.Controller/ReportsController.cs @@ -0,0 +1,89 @@ +using CodingTracker.Controller.Interfaces; +using CodingTracker.Models.Entities; +using CodingTracker.Services.Interfaces; +using CodingTracker.Views.Interfaces; + +namespace CodingTracker.Controller; +public class ReportsController : IReportsController +{ + private readonly ICodingSessionDataService _service; + private readonly IMenuView _menuView; + private readonly IUserInputView _inputView; + private readonly IConsoleOutputView _outputView; + public ReportsController(ICodingSessionDataService service, + IMenuView menuView, IUserInputView inputView, IConsoleOutputView outputView) + { + _service = service; + _menuView = menuView; + _inputView = inputView; + _outputView = outputView; + } + + public void Run() + { + bool returnToMainMenu = false; + + while (!returnToMainMenu) + { + _outputView.WelcomeMessage(); + + var dateRangeSelection = _menuView.PrintEntryViewOptionsAndGetSelection(); + + if (dateRangeSelection == "Return to Previous Menu") { returnToMainMenu = true; continue; } + + (DateTime startTime, DateTime endTime) = GetDatesBasedOnUserSelection(dateRangeSelection); + + var sessions = _service.GetSessionListByDateRange(startTime, endTime); + var report = new ReportModel(sessions); + + _outputView.PrintCodingSessionListAsTable(sessions); + _outputView.PrintReportDataAsTable(report); + } + } + + private (DateTime, DateTime) GetDatesBasedOnUserSelection(string selection) + { + DateTime startTime = new DateTime(); + DateTime endTime = new DateTime(); + + switch (selection) + { + case "All": + case "Past Year": + case "Year to Date": + (startTime, endTime) = _service.GetBasicDateRange(selection); + break; + case "Custom Week": + case "Custom Month": + case "Custom Year": + startTime = GetRangeStartTime(); + endTime = _service.GetEndTimeForAdvancedDateRange(selection, startTime); + break; + } + + return (startTime, endTime); + } + private DateTime GetRangeStartTime() + { + var output = new DateTime(); + bool startTimeValid = false; + + while (startTimeValid == false) + { + var startTime = _inputView.GetTimeFromUser("start time"); + var result = _service.ValidateDateRangeStartTime(startTime); + + if (result.IsValid) + { + startTimeValid = true; + output = result.Value; + _outputView.ConfirmationMessage(result.Value.ToString("yyyy-MM-dd HH:mm:ss")); + } + else + { + _outputView.ErrorMessage(result.Parameter, result.Message); + } + } + return output; + } +} diff --git a/codingTracker.jzhartman/CodingTracker.Controller/TrackSessionController.cs b/codingTracker.jzhartman/CodingTracker.Controller/TrackSessionController.cs new file mode 100644 index 000000000..874fc1458 --- /dev/null +++ b/codingTracker.jzhartman/CodingTracker.Controller/TrackSessionController.cs @@ -0,0 +1,146 @@ +using CodingTracker.Controller.Interfaces; +using CodingTracker.Models.Entities; +using CodingTracker.Services.Interfaces; +using CodingTracker.Views.Interfaces; + +namespace CodingTracker.Controller; +public class TrackSessionController : ITrackSessionController +{ + private readonly ICodingSessionDataService _service; + private readonly IMenuView _menuView; + private readonly IUserInputView _inputView; + private readonly IConsoleOutputView _outputView; + + public TrackSessionController(ICodingSessionDataService service, IMenuView menuView, IUserInputView inputView, IConsoleOutputView outputView) + { + _service = service; + _menuView = menuView; + _inputView = inputView; + _outputView = outputView; + } + + public void Run() + { + bool returnToMainMenu = false; + + while (!returnToMainMenu) + { + _outputView.WelcomeMessage(); + var selection = _menuView.PrintTrackingMenuAndGetSelection(); + + switch (selection) + { + case "Enter Start and End Times": + var session = GetNewSessionFromUserInput(); + ConfirmAndAddSession(session); + break; + case "Begin Timer": + var stopwatchSession = GetNewSessionFromStopwatch(); + ConfirmAndAddSession(stopwatchSession); + break; + case "Return to Main Menu": + returnToMainMenu = true; + break; + default: + break; + } + } + } + + private CodingSession GetNewSessionFromUserInput() + { + var startTime = GetStartTimeFromUser(); + var endTime = GetEndTimeFromUser(startTime); + + return new CodingSession(startTime, endTime); + } + private DateTime GetStartTimeFromUser() + { + var output = new DateTime(); + bool startTimeValid = false; + + while (startTimeValid == false) + { + output = _inputView.GetTimeFromUser("start time"); + + var result = _service.ValidateStartTime(output); + + if (result.IsValid) + { + startTimeValid = true; + output = result.Value; + _outputView.ConfirmationMessage(result.Value.ToString("yyyy-MM-dd HH:mm:ss")); + } + else + { + _outputView.ErrorMessage(result.Parameter, result.Message); + } + } + return output; + } + private DateTime GetEndTimeFromUser(DateTime startTime) + { + var output = new DateTime(); + bool endTimeValid = false; + + while (endTimeValid == false) + { + output = _inputView.GetTimeFromUser("end time"); + + var result = _service.ValidateEndTime(output, startTime); + + if (result.IsValid) + { + endTimeValid = true; + output = result.Value; + _outputView.ConfirmationMessage(result.Value.ToString("yyyy-MM-dd HH:mm:ss")); + } + else + { + _outputView.ErrorMessage(result.Parameter, result.Message); + } + } + return output; + } + private void ConfirmAndAddSession(CodingSession session) + { + var sessionConfirmed = _inputView.GetAddSessionConfirmationFromUser(session); + + if (sessionConfirmed) + { + AddSession(session); + } + else + { + _outputView.ActionCancelledMessage("addition"); + } + } + private void AddSession(CodingSession session) + { + _service.AddSession(session); + _outputView.ActionCompleteMessage(true, "Success", "Coding session successfully added!"); + } + + private CodingSession GetNewSessionFromStopwatch() + { + var startTime = GetStartTimeWithStopwatch(); + _outputView.ConfirmationMessage(startTime.ToString("yyyy-MM-dd HH:mm:ss")); + + var endTime = GetStopTimeWithStopwatch(); + _outputView.ConfirmationMessage(endTime.ToString("yyyy-MM-dd HH:mm:ss")); + + return new CodingSession(startTime, endTime); + } + + private DateTime GetStartTimeWithStopwatch() + { + _inputView.StartStopwatch(); + return DateTime.Now; + } + + private DateTime GetStopTimeWithStopwatch() + { + _inputView.StopStopwatch(); + return DateTime.Now; + } +} diff --git a/codingTracker.jzhartman/CodingTracker.Data/CodingTracker.Data.csproj b/codingTracker.jzhartman/CodingTracker.Data/CodingTracker.Data.csproj new file mode 100644 index 000000000..7b45ce86a --- /dev/null +++ b/codingTracker.jzhartman/CodingTracker.Data/CodingTracker.Data.csproj @@ -0,0 +1,18 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + diff --git a/codingTracker.jzhartman/CodingTracker.Data/DatabaseInitializer.cs b/codingTracker.jzhartman/CodingTracker.Data/DatabaseInitializer.cs new file mode 100644 index 000000000..1e80dec32 --- /dev/null +++ b/codingTracker.jzhartman/CodingTracker.Data/DatabaseInitializer.cs @@ -0,0 +1,123 @@ +using CodingTracker.Data.Interfaces; +using Dapper; +using Microsoft.Data.Sqlite; + +namespace CodingTracker.Data; +public class DatabaseInitializer : IDatabaseInitializer +{ + private readonly ISqliteConnectionFactory _connectionFactory; + private readonly int _seedRecordCount = 100; + + + public DatabaseInitializer(ISqliteConnectionFactory connectionfactory) + { + _connectionFactory = connectionfactory; + } + + public void Run() + { + InitializeTable("CodingSessions"); + InitializeTable("Goals"); + } + + private void InitializeTable(string tableName) + { + if (TableExists(tableName) == false) + { + CreateTable(tableName); + SeedData(tableName); + } + } + private bool TableExists(string tableName) + { + using var connection = _connectionFactory.CreateConnection(); + int count = connection.ExecuteScalar($"select count(*) from sqlite_master where type='table' and name='{tableName}'"); + return count == 1; + } + private void CreateTable(string tableName) + { + string parameters = string.Empty; + + if (tableName == "CodingSessions") + parameters = @"Id integer primary key not null, + StartTime text not null, + EndTime text not null, + Duration integer not null"; + + if (tableName == "Goals") + parameters = @"Id integer primary key not null, + Type text not null, + StartTime text not null, + EndTime text not null, + Status text not null, + GoalValue integer not null, + CurrentValue integer not null, + Progress real not null"; + + using var connection = _connectionFactory.CreateConnection(); + + var command = connection.CreateCommand(); + command.CommandText = $"create table if not exists {tableName}({parameters})"; + command.ExecuteNonQuery(); + } + private void SeedData(string tableName) + { + using var connection = _connectionFactory.CreateConnection(); + + var command = connection.CreateCommand(); + + string sql = string.Empty; + + if (tableName == "CodingSessions") + sql = CreateCodingSessionsSqlString(); + + if (tableName == "Goals") + sql = CreateGoalsSqlString(); + + command.CommandText = sql; + command.ExecuteNonQuery(); + } + + private string CreateCodingSessionsSqlString() + { + string sql = "insert into CodingSessions(StartTime, EndTime, Duration)\nValues\n"; + Random rand = new Random(); + + DateOnly date = DateOnly.FromDateTime(DateTime.Now.AddDays(-5 * (_seedRecordCount+1))); + TimeOnly time = new TimeOnly(21,0,0); + DateTime startDate = date.ToDateTime(time); + + DateTime endDate = startDate.AddHours(2); + TimeSpan duration = endDate - startDate; + + for (int i = 0; i < _seedRecordCount; i++) + { + if (i != 0) sql += ",\n"; + + sql += $"('{startDate.ToString("yyyy-MM-dd HH:mm:ss")}', '{endDate.ToString("yyyy-MM-dd HH:mm:ss")}', {duration.TotalSeconds})"; + startDate = startDate.AddDays(rand.Next(1, 6)); + endDate = startDate.AddHours(rand.Next(1, 3)).AddMinutes(rand.Next(0, 60)).AddSeconds(rand.Next(0, 60)); + duration = endDate - startDate; + } + sql += ";"; + + return sql; + } + private string CreateGoalsSqlString() + { + + var startTime = DateTime.Now.AddDays(-4 * (_seedRecordCount + 1)); + var endTime = startTime.AddDays(30); + + string sql = "insert into Goals(Type, StartTime, EndTime, Status, GoalValue, CurrentValue, Progress) "; + + sql += "Values "; + sql += $"(2, '{startTime.ToString("yyyy-MM-dd HH:mm:ss")}', '{endTime.ToString("yyyy-MM-dd HH:mm:ss")}', 0, 15, 0, 0),"; + sql += $"(0, '{startTime.ToString("yyyy-MM-dd HH:mm:ss")}', '{endTime.ToString("yyyy-MM-dd HH:mm:ss")}', 0, 108000, 0, 0),"; + sql += $"(1, '{startTime.ToString("yyyy-MM-dd HH:mm:ss")}', '{endTime.ToString("yyyy-MM-dd HH:mm:ss")}', 0, 1800, 0, 0)"; + + sql += ";"; + + return sql; + } +} diff --git a/codingTracker.jzhartman/CodingTracker.Data/Interfaces/ICodingSessionRepository.cs b/codingTracker.jzhartman/CodingTracker.Data/Interfaces/ICodingSessionRepository.cs new file mode 100644 index 000000000..0a86ac9d1 --- /dev/null +++ b/codingTracker.jzhartman/CodingTracker.Data/Interfaces/ICodingSessionRepository.cs @@ -0,0 +1,17 @@ +using CodingTracker.Models.Entities; + +namespace CodingTracker.Data.Interfaces; +public interface ICodingSessionRepository +{ + void AddSession(CodingSession session); + List GetAll(); + List GetByDateRange(DateTime startTime, DateTime endTime); + CodingSessionDataRecord GetById(int id); + void UpdateSession(CodingSessionDataRecord session); + void DeleteById(int id); + bool ExistsWithinTimeFrame(DateTime time); + bool ExistsWithinTimeFrameExcludingSessionById(CodingSessionDataRecord session, DateTime newTime); + DateTime GetStartTimeOfNextRecord(DateTime time); + DateTime GetStartTimeOfNextRecordExcludingCurrentSession(DateTime time, long id); + DateTime GetStartTimeOfLastRecord(); +} \ No newline at end of file diff --git a/codingTracker.jzhartman/CodingTracker.Data/Interfaces/IDatabaseInitializer.cs b/codingTracker.jzhartman/CodingTracker.Data/Interfaces/IDatabaseInitializer.cs new file mode 100644 index 000000000..4bcf7ad6f --- /dev/null +++ b/codingTracker.jzhartman/CodingTracker.Data/Interfaces/IDatabaseInitializer.cs @@ -0,0 +1,5 @@ +namespace CodingTracker.Data.Interfaces; +public interface IDatabaseInitializer +{ + void Run(); +} \ No newline at end of file diff --git a/codingTracker.jzhartman/CodingTracker.Data/Interfaces/IGoalRepository.cs b/codingTracker.jzhartman/CodingTracker.Data/Interfaces/IGoalRepository.cs new file mode 100644 index 000000000..7aa53e853 --- /dev/null +++ b/codingTracker.jzhartman/CodingTracker.Data/Interfaces/IGoalRepository.cs @@ -0,0 +1,13 @@ +using CodingTracker.Models.Entities; + +namespace CodingTracker.Data.Interfaces; +public interface IGoalRepository +{ + void AddGoal(GoalModel goal); + void DeleteById(int id); + void EvaluateGoal(GoalDTO goal); + List GetAllGoals(); + List GetAllGoalsByStatus(GoalStatus status); + int GetGoalCount(); + void UpdateGoal(GoalDTO goal); +} \ No newline at end of file diff --git a/codingTracker.jzhartman/CodingTracker.Data/Interfaces/ISqliteConnectionFactory.cs b/codingTracker.jzhartman/CodingTracker.Data/Interfaces/ISqliteConnectionFactory.cs new file mode 100644 index 000000000..6d28597f4 --- /dev/null +++ b/codingTracker.jzhartman/CodingTracker.Data/Interfaces/ISqliteConnectionFactory.cs @@ -0,0 +1,7 @@ +using System.Data; + +namespace CodingTracker.Data.Interfaces; +public interface ISqliteConnectionFactory +{ + IDbConnection CreateConnection(); +} \ No newline at end of file diff --git a/codingTracker.jzhartman/CodingTracker.Data/Parameters/DateRangeQuery.cs b/codingTracker.jzhartman/CodingTracker.Data/Parameters/DateRangeQuery.cs new file mode 100644 index 000000000..888d1f415 --- /dev/null +++ b/codingTracker.jzhartman/CodingTracker.Data/Parameters/DateRangeQuery.cs @@ -0,0 +1,6 @@ +namespace CodingTracker.Data.Parameters; +public class DateRangeQuery +{ + public DateTime StartTime { get; set; } + public DateTime EndTime { get; set; } +} diff --git a/codingTracker.jzhartman/CodingTracker.Data/Parameters/DateValue.cs b/codingTracker.jzhartman/CodingTracker.Data/Parameters/DateValue.cs new file mode 100644 index 000000000..3c0e9b5ed --- /dev/null +++ b/codingTracker.jzhartman/CodingTracker.Data/Parameters/DateValue.cs @@ -0,0 +1,5 @@ +namespace CodingTracker.Data.Parameters; +public class DateValue +{ + public DateTime Time { get; set; } +} diff --git a/codingTracker.jzhartman/CodingTracker.Data/Parameters/EndTimeUpdate.cs b/codingTracker.jzhartman/CodingTracker.Data/Parameters/EndTimeUpdate.cs new file mode 100644 index 000000000..924cf91ec --- /dev/null +++ b/codingTracker.jzhartman/CodingTracker.Data/Parameters/EndTimeUpdate.cs @@ -0,0 +1,6 @@ +namespace CodingTracker.Data.Parameters; +public class EndTimeUpdate +{ + public int Id { get; set; } + public DateTime EndTime { get; set; } +} diff --git a/codingTracker.jzhartman/CodingTracker.Data/Parameters/GoalStatusQuery.cs b/codingTracker.jzhartman/CodingTracker.Data/Parameters/GoalStatusQuery.cs new file mode 100644 index 000000000..ac342f4af --- /dev/null +++ b/codingTracker.jzhartman/CodingTracker.Data/Parameters/GoalStatusQuery.cs @@ -0,0 +1,7 @@ +using CodingTracker.Models.Entities; + +namespace CodingTracker.Data.Parameters; +public class GoalStatusQuery +{ + public GoalStatus Status { get; set; } +} diff --git a/codingTracker.jzhartman/CodingTracker.Data/Parameters/TimeUpdate.cs b/codingTracker.jzhartman/CodingTracker.Data/Parameters/TimeUpdate.cs new file mode 100644 index 000000000..4fe56b78c --- /dev/null +++ b/codingTracker.jzhartman/CodingTracker.Data/Parameters/TimeUpdate.cs @@ -0,0 +1,6 @@ +namespace CodingTracker.Data.Parameters; +public class TimeUpdate +{ + public int Id { get; set; } + public DateTime Time { get; set; } +} diff --git a/codingTracker.jzhartman/CodingTracker.Data/Repositories/CodingSessionRepository.cs b/codingTracker.jzhartman/CodingTracker.Data/Repositories/CodingSessionRepository.cs new file mode 100644 index 000000000..acea1f67b --- /dev/null +++ b/codingTracker.jzhartman/CodingTracker.Data/Repositories/CodingSessionRepository.cs @@ -0,0 +1,110 @@ +using CodingTracker.Data.Interfaces; +using CodingTracker.Data.Parameters; +using CodingTracker.Models.Entities; +using Dapper; + +namespace CodingTracker.Data.Repositories; +public class CodingSessionRepository : RepositoryGenerics, ICodingSessionRepository +{ + public CodingSessionRepository(ISqliteConnectionFactory connectionFactory) : base(connectionFactory) {} + public List GetAll() + { + string sql = "Select * from CodingSessions order by StartTime"; + return LoadData(sql); + } + public List GetByDateRange(DateTime begin, DateTime finish) + { + var dateRange = new DateRangeQuery { StartTime = begin, EndTime = finish }; + string sql = @"Select * from CodingSessions where (StartTime >= @StartTime) AND (endTime <= @EndTime) order by StartTime"; + return LoadData(sql, dateRange); + } + public CodingSessionDataRecord GetById(int id) + { + string sql = $"select * from CodingSessions where Id = {id}"; + return LoadData(sql).FirstOrDefault(); + } + public int GetRecordCount() + { + string sql = "select count(*) from CodingSessions"; + return LoadData(sql).First(); + } + public DateTime GetStartTimeOfNextRecord(DateTime time) + { + var parameter = new DateValue { Time = time }; + using var connection = _connectionFactory.CreateConnection(); + + string sql = @"select StartTime from CodingSessions + where StartTime > @Time + order by StartTime + limit 1"; + + return LoadData(sql, parameter).FirstOrDefault(); + } + public DateTime GetStartTimeOfNextRecordExcludingCurrentSession(DateTime time, long id) + { + var parameter = new TimeUpdate { Time = time, Id = (int)id }; + using var connection = _connectionFactory.CreateConnection(); + + string sql = @"select StartTime from CodingSessions + where StartTime > @Time + and Id != @Id + order by StartTime + limit 1"; + + return LoadData(sql, parameter).FirstOrDefault(); + } + public DateTime GetStartTimeOfLastRecord() + { + using var connection = _connectionFactory.CreateConnection(); + + string sql = @"select StartTime from CodingSessions + order by StartTime DESC + limit 1"; + + return LoadData(sql).FirstOrDefault(); + } + public bool ExistsWithinTimeFrame(DateTime time) + { + var parameter = new DateValue { Time = time}; + using var connection = _connectionFactory.CreateConnection(); + + string sql = @"select count(1) from CodingSessions + where StartTime <= @Time + and EndTime >= @Time"; + + int count = connection.ExecuteScalar(sql, parameter); + + return (count > 0); + } + public bool ExistsWithinTimeFrameExcludingSessionById(CodingSessionDataRecord session, DateTime newTime) + { + var parameter = new TimeUpdate { Id = (int)session.Id, Time = newTime }; + using var connection = _connectionFactory.CreateConnection(); + + string sql = @"select count(1) from CodingSessions + where StartTime <= @Time + and EndTime >= @Time + and Id != @Id"; + + int count = connection.ExecuteScalar(sql, parameter); + + return (count > 0); + } + + + public void AddSession(CodingSession session) + { + string sql = "insert into CodingSessions (StartTime, EndTime, Duration) values (@StartTime, @EndTime, @Duration)"; + SaveData(sql, session); + } + public void UpdateSession(CodingSessionDataRecord session) + { + string sql = "update CodingSessions Set StartTime = @StartTime, EndTime = @EndTime, Duration = @Duration where Id = @Id"; + SaveData(sql, session); + } + public void DeleteById(int id) + { + string sql = $"delete from CodingSessions where Id = {id}"; + SaveData(sql); + } +} \ No newline at end of file diff --git a/codingTracker.jzhartman/CodingTracker.Data/Repositories/GoalRepository.cs b/codingTracker.jzhartman/CodingTracker.Data/Repositories/GoalRepository.cs new file mode 100644 index 000000000..32c1d92df --- /dev/null +++ b/codingTracker.jzhartman/CodingTracker.Data/Repositories/GoalRepository.cs @@ -0,0 +1,52 @@ +using CodingTracker.Data.Interfaces; +using CodingTracker.Data.Parameters; +using CodingTracker.Models.Entities; + +namespace CodingTracker.Data.Repositories; +public class GoalRepository : RepositoryGenerics, IGoalRepository +{ + public GoalRepository(ISqliteConnectionFactory connectionFactory) : base(connectionFactory) { } + + public List GetAllGoalsByStatus(GoalStatus status) + { + var goalStatus = new GoalStatusQuery {Status = status }; + string sql = $"select * from Goals where Status = @Status"; + return LoadData(sql, goalStatus); + } + + public List GetAllGoals() + { + string sql = $"select * from Goals"; + return LoadData(sql); + } + + public void AddGoal(GoalModel goal) + { + string sql = "insert into Goals (StartTime, EndTime, Type, Status, GoalValue, CurrentValue, Progress) values (@StartTime, @EndTime, @Type, @Status, @GoalValue, @CurrentValue, @Progress)"; + SaveData(sql, goal); + } + + public void UpdateGoal(GoalDTO goal) + { + string sql = "update Goals set StartTime = @StartTime, EndTime = @EndTime, Type = @Type, Status = @Status where Id = @Id"; + SaveData(sql, goal); + } + public void DeleteById(int id) + { + string sql = $"delete from Goals where Id = {id}"; + SaveData(sql); + } + + public void EvaluateGoal(GoalDTO goal) + { + string sql = "update Goals set Status = @Status, GoalValue = @GoalValue, CurrentValue = @CurrentValue, Progress = @Progress where Id = @Id"; + SaveData(sql, goal); + } + + public int GetGoalCount() + { + string sql = "select count(*) from Goals"; + return LoadData(sql).First(); + } + +} diff --git a/codingTracker.jzhartman/CodingTracker.Data/Repositories/RepositoryGenerics.cs b/codingTracker.jzhartman/CodingTracker.Data/Repositories/RepositoryGenerics.cs new file mode 100644 index 000000000..13993ac33 --- /dev/null +++ b/codingTracker.jzhartman/CodingTracker.Data/Repositories/RepositoryGenerics.cs @@ -0,0 +1,36 @@ +using CodingTracker.Data.Interfaces; +using Dapper; + +namespace CodingTracker.Data.Repositories; +public class RepositoryGenerics +{ + protected internal readonly ISqliteConnectionFactory _connectionFactory; + + public RepositoryGenerics(ISqliteConnectionFactory connectionFactory) + { + _connectionFactory = connectionFactory; + } + + protected internal List LoadData(string sql) + { + using var connection = _connectionFactory.CreateConnection(); + List sessions = connection.Query(sql).ToList(); + return sessions; + } + protected internal List LoadData(string sql, U parameters) + { + using var connection = _connectionFactory.CreateConnection(); + List sessions = connection.Query(sql, parameters).ToList(); + return sessions; + } + protected internal void SaveData(string sql) + { + using var connection = _connectionFactory.CreateConnection(); + connection.Execute(sql); + } + protected internal void SaveData(string sql, T parameters) + { + using var connection = _connectionFactory.CreateConnection(); + connection.Execute(sql, parameters); + } +} diff --git a/codingTracker.jzhartman/CodingTracker.Data/SqliteConnectionFactory.cs b/codingTracker.jzhartman/CodingTracker.Data/SqliteConnectionFactory.cs new file mode 100644 index 000000000..ee2d3de86 --- /dev/null +++ b/codingTracker.jzhartman/CodingTracker.Data/SqliteConnectionFactory.cs @@ -0,0 +1,21 @@ +using CodingTracker.Data.Interfaces; +using Microsoft.Data.Sqlite; +using System.Data; + +namespace CodingTracker.Data; +public class SqliteConnectionFactory : ISqliteConnectionFactory +{ + private readonly string _connectionString; + + public SqliteConnectionFactory(string connectionString) + { + _connectionString = connectionString; + } + + public IDbConnection CreateConnection() + { + var connection = new SqliteConnection(_connectionString); + connection.Open(); + return connection; + } +} diff --git a/codingTracker.jzhartman/CodingTracker.Data/TypeHandlers/DateTimeHandler.cs b/codingTracker.jzhartman/CodingTracker.Data/TypeHandlers/DateTimeHandler.cs new file mode 100644 index 000000000..f2a28e7c5 --- /dev/null +++ b/codingTracker.jzhartman/CodingTracker.Data/TypeHandlers/DateTimeHandler.cs @@ -0,0 +1,36 @@ +using Dapper; +using System.Data; + +namespace CodingTracker.Data.TypeHandlers; +public class DateTimeHandler : SqlMapper.TypeHandler +{ + private readonly string _format; + + public DateTimeHandler(string format) + { + _format = format; + } + + public override DateTime Parse(object value) + { + if (value is string s + && DateTime.TryParseExact + ( + s, + _format, + System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.AssumeLocal, + out var dt)) + { + return dt; + } + + return Convert.ToDateTime(value); + } + + public override void SetValue(IDbDataParameter parameter, DateTime value) + { + parameter.DbType = DbType.String; + parameter.Value = value.ToString(_format); + } +} diff --git a/codingTracker.jzhartman/CodingTracker.Data/TypeHandlers/GoalStatusHandler.cs b/codingTracker.jzhartman/CodingTracker.Data/TypeHandlers/GoalStatusHandler.cs new file mode 100644 index 000000000..24dda737b --- /dev/null +++ b/codingTracker.jzhartman/CodingTracker.Data/TypeHandlers/GoalStatusHandler.cs @@ -0,0 +1,23 @@ +using CodingTracker.Models.Entities; +using Dapper; +using System.Data; + +namespace CodingTracker.Data.TypeHandlers; +public class GoalStatusHandler : SqlMapper.TypeHandler +{ + public override GoalStatus Parse(object value) + { + if (value == null || value is DBNull) + { + return default(GoalStatus); + } + + return (GoalStatus)Enum.Parse(typeof(GoalStatus), value.ToString()); + } + + public override void SetValue(IDbDataParameter parameter, GoalStatus value) + { + parameter.DbType = DbType.String; + parameter.Value = value.ToString(); + } +} diff --git a/codingTracker.jzhartman/CodingTracker.Data/TypeHandlers/GoalTypeHandler.cs b/codingTracker.jzhartman/CodingTracker.Data/TypeHandlers/GoalTypeHandler.cs new file mode 100644 index 000000000..8f0075fd2 --- /dev/null +++ b/codingTracker.jzhartman/CodingTracker.Data/TypeHandlers/GoalTypeHandler.cs @@ -0,0 +1,23 @@ +using CodingTracker.Models.Entities; +using Dapper; +using System.Data; + +namespace CodingTracker.Data.TypeHandlers; +public class GoalTypeHandler : SqlMapper.TypeHandler +{ + public override GoalType Parse(object value) + { + if (value == null || value is DBNull) + { + return default(GoalType); + } + + return (GoalType)Enum.Parse(typeof(GoalType), value.ToString(), true); + } + + public override void SetValue(IDbDataParameter parameter, GoalType value) + { + parameter.Value = value.ToString(); + parameter.DbType = DbType.String; + } +} diff --git a/codingTracker.jzhartman/CodingTracker.Models/CodingTracker.Models.csproj b/codingTracker.jzhartman/CodingTracker.Models/CodingTracker.Models.csproj new file mode 100644 index 000000000..fa71b7ae6 --- /dev/null +++ b/codingTracker.jzhartman/CodingTracker.Models/CodingTracker.Models.csproj @@ -0,0 +1,9 @@ + + + + net8.0 + enable + enable + + + diff --git a/codingTracker.jzhartman/CodingTracker.Models/Entities/CodingSession.cs b/codingTracker.jzhartman/CodingTracker.Models/Entities/CodingSession.cs new file mode 100644 index 000000000..1af2ca837 --- /dev/null +++ b/codingTracker.jzhartman/CodingTracker.Models/Entities/CodingSession.cs @@ -0,0 +1,26 @@ +namespace CodingTracker.Models.Entities; +public class CodingSession +{ + public DateTime StartTime { get; set; } + public DateTime EndTime { get; set; } + public long Duration { get; private set; } + public string DurationText { get; private set;} + + public CodingSession(DateTime startTime, DateTime endTime) + { + StartTime = startTime; + EndTime = endTime; + CalculateDuration(); + GenerateDurationText(); + } + + private void CalculateDuration() + { + Duration = (long)EndTime.Subtract(StartTime).TotalSeconds; + } + + private void GenerateDurationText() + { + DurationText = TimeSpan.FromSeconds(Duration).ToString(); + } +} diff --git a/codingTracker.jzhartman/CodingTracker.Models/Entities/CodingSessionDataRecord.cs b/codingTracker.jzhartman/CodingTracker.Models/Entities/CodingSessionDataRecord.cs new file mode 100644 index 000000000..998eb049e --- /dev/null +++ b/codingTracker.jzhartman/CodingTracker.Models/Entities/CodingSessionDataRecord.cs @@ -0,0 +1,8 @@ +namespace CodingTracker.Models.Entities; +public class CodingSessionDataRecord +{ + public long Id { get; set; } + public DateTime StartTime { get; set; } + public DateTime EndTime { get; set; } + public int Duration { get; set; } +} diff --git a/codingTracker.jzhartman/CodingTracker.Models/Entities/Enums.cs b/codingTracker.jzhartman/CodingTracker.Models/Entities/Enums.cs new file mode 100644 index 000000000..f58b1fe7d --- /dev/null +++ b/codingTracker.jzhartman/CodingTracker.Models/Entities/Enums.cs @@ -0,0 +1,16 @@ +namespace CodingTracker.Models.Entities; +public enum GoalType +{ + TotalTime, + AverageTime, + DaysPerPeriod, + Unknown +} + +public enum GoalStatus +{ + InProgress, + Complete, + Failed, + Abandoned +} \ No newline at end of file diff --git a/codingTracker.jzhartman/CodingTracker.Models/Entities/GoalDTO.cs b/codingTracker.jzhartman/CodingTracker.Models/Entities/GoalDTO.cs new file mode 100644 index 000000000..c8c2537ef --- /dev/null +++ b/codingTracker.jzhartman/CodingTracker.Models/Entities/GoalDTO.cs @@ -0,0 +1,12 @@ +namespace CodingTracker.Models.Entities; +public class GoalDTO +{ + public int Id { get; set; } + public GoalType Type { get; set; } + public DateTime StartTime { get; set; } + public DateTime EndTime { get; set; } + public GoalStatus Status { get; set; } + public long GoalValue { get; set; } + public long CurrentValue { get; set; } + public double Progress { get; set; } +} diff --git a/codingTracker.jzhartman/CodingTracker.Models/Entities/GoalModel.cs b/codingTracker.jzhartman/CodingTracker.Models/Entities/GoalModel.cs new file mode 100644 index 000000000..aa8697aa5 --- /dev/null +++ b/codingTracker.jzhartman/CodingTracker.Models/Entities/GoalModel.cs @@ -0,0 +1,11 @@ +namespace CodingTracker.Models.Entities; +public class GoalModel +{ + public GoalType Type { get; set; } + public DateTime StartTime { get; set; } + public DateTime EndTime { get; set; } + public GoalStatus Status { get; set; } + public long GoalValue { get; set; } + public long CurrentValue { get; set; } + public double Progress { get; set; } +} diff --git a/codingTracker.jzhartman/CodingTracker.Models/Entities/ReportModel.cs b/codingTracker.jzhartman/CodingTracker.Models/Entities/ReportModel.cs new file mode 100644 index 000000000..6cdbee599 --- /dev/null +++ b/codingTracker.jzhartman/CodingTracker.Models/Entities/ReportModel.cs @@ -0,0 +1,43 @@ +namespace CodingTracker.Models.Entities; +public class ReportModel +{ + public List SessionList { get; set; } + public double TotalTime { get; set; } + public double AverageTime { get; set; } + public CodingSessionDataRecord FirstEntry { get; set; } + public CodingSessionDataRecord LastEntry { get; set; } + public int SessionCount { get; set; } + + public ReportModel(List sessionList) + { + SessionList = sessionList; + CalculateTotalTime(); + CalculateAverageTime(); + GetFirstAndLastEntry(); + GetSessionCount(); + } + + private void CalculateTotalTime() + { + foreach (var session in SessionList) + { + TotalTime += session.Duration; + } + } + + private void CalculateAverageTime() + { + AverageTime = TotalTime / SessionList.Count; + } + private void GetFirstAndLastEntry() + { + var orderedList = SessionList.OrderBy(s=>s.StartTime).ToList(); + + FirstEntry = orderedList.First(); + LastEntry = orderedList.Last(); + } + private void GetSessionCount() + { + SessionCount = SessionList.Count; + } +} diff --git a/codingTracker.jzhartman/CodingTracker.Models/Validation/ValidationResult.cs b/codingTracker.jzhartman/CodingTracker.Models/Validation/ValidationResult.cs new file mode 100644 index 000000000..d6e1862ab --- /dev/null +++ b/codingTracker.jzhartman/CodingTracker.Models/Validation/ValidationResult.cs @@ -0,0 +1,27 @@ +namespace CodingTracker.Models.Validation; +public class ValidationResult +{ + public bool IsValid { get;} + public T? Value { get;} + public string? Parameter { get;} + public string? Message { get;} + + private ValidationResult(bool isValid, T? value,string parameter, string message) + { + IsValid = isValid; + Value = value; + Parameter = parameter; + Message = message; + } + + public static ValidationResult Success(T value) + { + return new ValidationResult(true, value, default, default); + } + + public static ValidationResult Fail(string parameter, string message) + { + return new ValidationResult(false, default, parameter, message); + } + +} diff --git a/codingTracker.jzhartman/CodingTracker.Services/CodingSessionDataService.cs b/codingTracker.jzhartman/CodingTracker.Services/CodingSessionDataService.cs new file mode 100644 index 000000000..4fd4fa3ce --- /dev/null +++ b/codingTracker.jzhartman/CodingTracker.Services/CodingSessionDataService.cs @@ -0,0 +1,215 @@ +using CodingTracker.Data.Interfaces; +using CodingTracker.Models.Entities; +using CodingTracker.Models.Validation; +using CodingTracker.Services.Interfaces; + +namespace CodingTracker.Services; +public class CodingSessionDataService : ICodingSessionDataService +{ + private readonly ICodingSessionRepository _repository; + + public CodingSessionDataService(ICodingSessionRepository repository) + { + _repository = repository; + } + + + // CRUD SERVICES + public void DeleteSessionById(int id) + { + _repository.DeleteById(id); + } + public void UpdateSession(CodingSessionDataRecord session) + { + _repository.UpdateSession(session); + } + public void AddSession(CodingSession session) + { + _repository.AddSession(session); + } + public List GetAllCodingSessions() + { + return _repository.GetAll(); + } + public List GetSessionListByDateRange(DateTime startTime, DateTime endTime) + { + return _repository.GetByDateRange(startTime, endTime); + } + public CodingSessionDataRecord GetSessionById(int id) + { + return _repository.GetById(id); + } + + + + // DATA SERVICES + public (DateTime, DateTime) GetBasicDateRange(string selection) + { + DateTime startTime = new DateTime(); + DateTime endTime = new DateTime(); + + switch (selection) + { + case "All": + (startTime, endTime) = GetAllDates(); + break; + case "Past Year": + (startTime, endTime) = GetDateRangeForPastYear(); + break; + case "Year to Date": + (startTime, endTime) = GetDateRangeForYearToDate(); + break; + } + + return (startTime, endTime); + } + public DateTime GetEndTimeForAdvancedDateRange(string selection, DateTime startTime) + { + DateTime endTime = new DateTime(); + + switch (selection) + { + + case "Custom Week": + endTime = endTime = startTime.AddDays(7); + break; + case "Custom Month": + endTime = startTime.AddMonths(1); + break; + case "Custom Year": + endTime = startTime.AddYears(1); + break; + } + + return endTime; + } + private (DateTime, DateTime) GetAllDates() + { + var startTime = DateTime.MinValue; + var endTime = DateTime.MaxValue; + return (startTime, endTime); + } + private (DateTime, DateTime) GetDateRangeForPastYear() + { + var endTime = DateTime.Now; + var startTime = endTime.AddYears(-1); + return (startTime, endTime); + } + private (DateTime, DateTime) GetDateRangeForYearToDate() + { + var endTime = DateTime.Now; + var startTime = new DateTime(endTime.Year, 1, 1); + return (startTime, endTime); + } + + + + // VALIDATION SERVICES + public ValidationResult ValidateStartTime(DateTime input) + { + if (_repository.ExistsWithinTimeFrame(input)) + return ValidationResult.Fail("Start Time", "A record already exists for this time"); + else if (input > DateTime.Now) + return ValidationResult.Fail("Start Time", "Cannot enter a future time"); + else + return ValidationResult.Success(input); + } + public ValidationResult ValidateEndTime(DateTime input, DateTime startTime) + { + if (_repository.ExistsWithinTimeFrame(input)) + return ValidationResult.Fail("End Time", "A record already exists for this time"); + else if (TimeOverlapsNextEntry(input, startTime)) + return ValidationResult.Fail("End Time", "This end time overlaps an existing session"); + else if (input <= startTime) + return ValidationResult.Fail("End Time", $"The end time must be later than {startTime.ToString("yyyy-MM-dd HH:mm:ss")}"); + else if (input > DateTime.Now) + return ValidationResult.Fail("End Time", "Cannot enter a future time"); + else + return ValidationResult.Success(input); + } + public ValidationResult ValidateUpdatedStartTime(CodingSessionDataRecord session, DateTime newStartTime) + { + if (newStartTime == DateTime.MinValue) + return ValidationResult.Success(session.StartTime); + else if (_repository.ExistsWithinTimeFrameExcludingSessionById(session, newStartTime)) + return ValidationResult.Fail("Start Time", "A record already exists for this time"); + else if (newStartTime > DateTime.Now) + return ValidationResult.Fail("Start Time", "Cannot enter a future time"); + else + return ValidationResult.Success(newStartTime); + } + public ValidationResult ValidateUpdatedEndTime(CodingSessionDataRecord session, DateTime newStartTime, DateTime newEndTime) + { + if (newEndTime == DateTime.MinValue && session.EndTime > newStartTime) + return ValidationResult.Success(session.EndTime); + else if (newEndTime <= newStartTime) + return ValidationResult.Fail("End Time", "End time must be later than start time."); + else if (_repository.ExistsWithinTimeFrameExcludingSessionById(session, newEndTime)) + return ValidationResult.Fail("End Time", "A record already exists for this time"); + else if (TimeOverlapsNextEntry(newEndTime, newStartTime, session.Id)) + return ValidationResult.Fail("End Time", "This end time overlaps an existing session"); + else if (newEndTime > DateTime.Now) + return ValidationResult.Fail("End Time", "Cannot enter a future time"); + else + return ValidationResult.Success(newEndTime); + } + public ValidationResult ValidateReportEndTime(DateTime input, DateTime startTime) + { + if (input <= startTime) + return ValidationResult.Fail("End Time", $"The end time must be later than {startTime.ToString("yyyy-MM-dd HH:mm:ss")}"); + else + return ValidationResult.Success(input); + } + + + public ValidationResult ValidateGoalStartTime(DateTime input) + { + if (input < DateTime.MinValue) + return ValidationResult.Fail("Start Time", "Invalid time!"); + else + return ValidationResult.Success(input); + } + public ValidationResult ValidateGoalEndTime(DateTime input, DateTime startTime) + { + if (input < DateTime.Now) + return ValidationResult.Fail("End Time", "Goal must end at a future time"); + else if (input <= startTime) + return ValidationResult.Fail("End Time", "Goal end time cannot be before start time"); + else if (input >= startTime.AddYears(1)) + return ValidationResult.Fail("End Time", "Goal time duration cannot exceed one year"); + else + return ValidationResult.Success(input); + } + public ValidationResult ValidateDateRangeStartTime(DateTime input) + { + if (input > DateTime.Now) + return ValidationResult.Fail("Start Time", "Cannot enter a future time"); + else if (input > _repository.GetStartTimeOfLastRecord()) + return ValidationResult.Fail("Start Time", "There are no records within this time period."); + else + return ValidationResult.Success(input); + } + + private bool TimeOverlapsNextEntry(DateTime input, DateTime startTime) + { + var nextRecordStartTime = _repository.GetStartTimeOfNextRecord(startTime); + + if (nextRecordStartTime == null || nextRecordStartTime == DateTime.MinValue) + return false; + else if (input >= nextRecordStartTime) + return true; + else + return false; + } + private bool TimeOverlapsNextEntry(DateTime input, DateTime startTime, long sessionId) + { + var nextRecordStartTime = _repository.GetStartTimeOfNextRecordExcludingCurrentSession(startTime, sessionId); + + if (nextRecordStartTime == null || nextRecordStartTime == DateTime.MinValue) + return false; + else if (input >= nextRecordStartTime) + return true; + else + return false; + } +} diff --git a/codingTracker.jzhartman/CodingTracker.Services/CodingTracker.Services.csproj b/codingTracker.jzhartman/CodingTracker.Services/CodingTracker.Services.csproj new file mode 100644 index 000000000..d1406a2b6 --- /dev/null +++ b/codingTracker.jzhartman/CodingTracker.Services/CodingTracker.Services.csproj @@ -0,0 +1,14 @@ + + + + net8.0 + enable + enable + + + + + + + + diff --git a/codingTracker.jzhartman/CodingTracker.Services/GoalDataService.cs b/codingTracker.jzhartman/CodingTracker.Services/GoalDataService.cs new file mode 100644 index 000000000..0cb572b07 --- /dev/null +++ b/codingTracker.jzhartman/CodingTracker.Services/GoalDataService.cs @@ -0,0 +1,163 @@ +using CodingTracker.Data.Interfaces; +using CodingTracker.Models.Entities; +using CodingTracker.Models.Validation; +using CodingTracker.Services.Interfaces; + +namespace CodingTracker.Services; +public class GoalDataService : IGoalDataService +{ + private readonly IGoalRepository _repository; + + public GoalDataService(IGoalRepository repository) + { + _repository = repository; + } + + public void AddGoal(GoalModel goal) + { + _repository.AddGoal(goal); + } + public void UpdateGoal(GoalDTO goal) + { + _repository.UpdateGoal(goal); + } + public void DeleteGoalById(int id) + { + _repository.DeleteById(id); + } + public void EvaluateGoal(GoalDTO goal) + { + _repository.EvaluateGoal(goal); + } + public List GetAllGoalsByStatus(GoalStatus status) + { + return _repository.GetAllGoalsByStatus(status); + } + public List GetAllGoals() + { + return _repository.GetAllGoals(); + } + public int GetGoalCount() + { + return _repository.GetGoalCount(); + } + + + + + // Unit Conversions + public long GetGoalValueFromTimeSpan(TimeSpan time) + { + return (long)time.TotalSeconds; + } + public int GetGoalDaysFromSeconds(long seconds) + { + return (int)TimeSpan.FromSeconds(seconds).TotalDays; + } + public TimeSpan GetGoalTimeFromSeconds(long seconds) + { + return TimeSpan.FromSeconds(seconds); + } + + + + // Evaluations + public void EvaluateTotalTimeGoal(GoalDTO goal, List codingSessions) + { + var timeRemaining = (goal.EndTime - DateTime.Now).TotalSeconds; + + goal.CurrentValue = SumTotalTimeFromSessions(codingSessions); + goal.Progress = ((double)goal.CurrentValue / goal.GoalValue) * 100; + + if (goal.Progress >= 100 && timeRemaining < 0) + goal.Status = GoalStatus.Complete; + + else if (timeRemaining > 0 && (timeRemaining + goal.CurrentValue) >= goal.GoalValue) + goal.Status = GoalStatus.InProgress; + + else + goal.Status = GoalStatus.Failed; + } + public void EvaluateAverageTimeGoal(GoalDTO goal, List codingSessions) + { + var timeRemaining = (goal.EndTime - DateTime.Now).TotalSeconds; + var daysRemaining = (goal.EndTime - DateTime.Now).TotalDays; + + var totalTime = SumTotalTimeFromSessions(codingSessions); + goal.CurrentValue = (long)(totalTime / (goal.EndTime - goal.StartTime).TotalDays); + goal.Progress = ((double)goal.CurrentValue / goal.GoalValue) * 100; + + if (goal.Progress >= 100 && timeRemaining < 0) + goal.Status = GoalStatus.Complete; + + else if (timeRemaining > 0 && (totalTime + timeRemaining)/daysRemaining >= goal.GoalValue) + goal.Status = GoalStatus.InProgress; + + else + goal.Status = GoalStatus.Failed; + } + + public void EvaluateDaysPerPeriodGoal(GoalDTO goal, List codingSessions) + { + var daysRemaining = (goal.EndTime - DateTime.Now).TotalDays; + + if (daysRemaining > (goal.EndTime - goal.StartTime).TotalDays) + daysRemaining = (goal.EndTime - goal.StartTime).TotalDays; + + + goal.CurrentValue = GetUniqueDaysPerPeriod(codingSessions); + goal.Progress = ((double)goal.CurrentValue / goal.GoalValue) * 100; + + if (goal.Progress >= 100 && daysRemaining < 0) + goal.Status = GoalStatus.Complete; + + else if (daysRemaining > 0 && (goal.CurrentValue + daysRemaining) >= goal.GoalValue) + goal.Status = GoalStatus.InProgress; + + else + goal.Status = GoalStatus.Failed; + } + + private int GetUniqueDaysPerPeriod(List codingSessions) + { + if (codingSessions.Count == 0) + return 0; + + int uniqueDays = 1; + + for (int i = 1; i < codingSessions.Count; i++) + { + if (codingSessions[i].StartTime.Date != codingSessions[i - 1].StartTime.Date) + uniqueDays++; + } + + return uniqueDays; + } + private long SumTotalTimeFromSessions(List codingSessions) + { + long totalTime = 0; + + foreach (var session in codingSessions) + { + totalTime += session.Duration; + } + + return totalTime; + } + + + + + + //Validation + public ValidationResult ValidateGoalValueInput(GoalType goalType, long input, long maxTime) + { + if (input < 1) + return ValidationResult.Fail("Goal Value", "Goal value cannot be zero or lower."); + else if (input > maxTime) + return ValidationResult.Fail("Goal Value", $"Goal value exceeds maximum time {TimeSpan.FromSeconds(maxTime)}"); + else + return ValidationResult.Success(input); + + } +} \ No newline at end of file diff --git a/codingTracker.jzhartman/CodingTracker.Services/Interfaces/ICodingSessionDataService.cs b/codingTracker.jzhartman/CodingTracker.Services/Interfaces/ICodingSessionDataService.cs new file mode 100644 index 000000000..00c6dfaca --- /dev/null +++ b/codingTracker.jzhartman/CodingTracker.Services/Interfaces/ICodingSessionDataService.cs @@ -0,0 +1,22 @@ +using CodingTracker.Models.Entities; +using CodingTracker.Models.Validation; + +namespace CodingTracker.Services.Interfaces; +public interface ICodingSessionDataService +{ + void AddSession(CodingSession session); + void DeleteSessionById(int id); + List GetAllCodingSessions(); + List GetSessionListByDateRange(DateTime startTime, DateTime endTime); + CodingSessionDataRecord GetSessionById(int id); + void UpdateSession(CodingSessionDataRecord session); + ValidationResult ValidateEndTime(DateTime input, DateTime startTime); + ValidationResult ValidateStartTime(DateTime input); + ValidationResult ValidateUpdatedEndTime(CodingSessionDataRecord session, DateTime newStartTime, DateTime newEndTime); + ValidationResult ValidateUpdatedStartTime(CodingSessionDataRecord session, DateTime updatedStartTime); + (DateTime, DateTime) GetBasicDateRange(string selection); + DateTime GetEndTimeForAdvancedDateRange(string selection, DateTime startTime); + ValidationResult ValidateDateRangeStartTime(DateTime input); + ValidationResult ValidateGoalStartTime(DateTime input); + ValidationResult ValidateGoalEndTime(DateTime input, DateTime startTime); +} \ No newline at end of file diff --git a/codingTracker.jzhartman/CodingTracker.Services/Interfaces/IGoalDataService.cs b/codingTracker.jzhartman/CodingTracker.Services/Interfaces/IGoalDataService.cs new file mode 100644 index 000000000..ce74f7f76 --- /dev/null +++ b/codingTracker.jzhartman/CodingTracker.Services/Interfaces/IGoalDataService.cs @@ -0,0 +1,18 @@ +using CodingTracker.Models.Entities; +using CodingTracker.Models.Validation; + +namespace CodingTracker.Services.Interfaces; +public interface IGoalDataService +{ + void AddGoal(GoalModel goal); + void DeleteGoalById(int id); + void EvaluateAverageTimeGoal(GoalDTO goal, List codingSessions); + void EvaluateDaysPerPeriodGoal(GoalDTO goal, List codingSessions); + void EvaluateGoal(GoalDTO goal); + void EvaluateTotalTimeGoal(GoalDTO goal, List codingSessions); + List GetAllGoals(); + List GetAllGoalsByStatus(GoalStatus status); + int GetGoalCount(); + void UpdateGoal(GoalDTO goal); + ValidationResult ValidateGoalValueInput(GoalType goalType, long input, long maxTime); +} \ No newline at end of file diff --git a/codingTracker.jzhartman/CodingTracker.Views/CodingTracker.Views.csproj b/codingTracker.jzhartman/CodingTracker.Views/CodingTracker.Views.csproj new file mode 100644 index 000000000..348b7bfc6 --- /dev/null +++ b/codingTracker.jzhartman/CodingTracker.Views/CodingTracker.Views.csproj @@ -0,0 +1,17 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + diff --git a/codingTracker.jzhartman/CodingTracker.Views/ConsoleOutputView.cs b/codingTracker.jzhartman/CodingTracker.Views/ConsoleOutputView.cs new file mode 100644 index 000000000..46aac9e6c --- /dev/null +++ b/codingTracker.jzhartman/CodingTracker.Views/ConsoleOutputView.cs @@ -0,0 +1,210 @@ +using CodingTracker.Models.Entities; +using CodingTracker.Views.Interfaces; +using Spectre.Console; + +namespace CodingTracker.Views; +public class ConsoleOutputView : IConsoleOutputView +{ + public void WelcomeMessage() + { + AnsiConsole.Clear(); + AnsiConsole.Write(new Rule()); + AnsiConsole.MarkupLine("[bold blue]CODING TRACKER[/]"); + AnsiConsole.MarkupLine("[bold blue]Version 1.0[/]"); + AnsiConsole.Write(new Rule()); + AddNewLines(1); + } + public void ErrorMessage(string parameter, string message) + { + AnsiConsole.MarkupInterpolated($"[bold red]ERROR:[/] The value for {parameter} encountered the error: [yellow]{message}[/]"); + AddNewLines(2); + } + public void ConfirmationMessage(string valueText) + { + AnsiConsole.MarkupInterpolated($"[bold green]ACCEPTED[/]: Value set to {valueText}"); + AddNewLines(2); + } + public void ActionCompleteMessage(bool isSuccess, string state, string message) + { + AddNewLines(1); + if (isSuccess) + { + AnsiConsole.MarkupInterpolated($"[bold green]{state.ToUpper()}![/] {message}"); + } + else + { + AnsiConsole.MarkupInterpolated($"[bold red]{state.ToUpper()}![/] {message}"); + } + AddNewLines(2); + } + public void ActionCancelledMessage(string action) + { + AddNewLines(1); + AnsiConsole.MarkupInterpolated($"Cancelled {action} of coding session!"); + } + public void GoalCancelledMessage(string action) + { + AddNewLines(1); + AnsiConsole.MarkupInterpolated($"Cancelled {action} of goal!"); + } + + + public void PrintCodingSessionListAsTable(List sessions) + { + int count = 1; + var grid = new Grid(); + grid.AddColumn(); + grid.AddColumn(); + grid.AddColumn(); + grid.AddColumn(); + grid.AddRow(new Text[] {new Text("Id").Centered(), + new Text("Start Time").Centered(), + new Text("End Time").Centered(), + new Text("Duration").Centered()}); + + foreach (var session in sessions) + { + grid.AddRow(new string[] { $"[blue]{count}[/]", + $"{session.StartTime.ToString("yyyy-MM-dd")} [yellow]{session.StartTime.ToString("HH:mm:ss")}[/]", + $"{session.EndTime.ToString("yyyy-MM-dd")} [yellow]{session.EndTime.ToString("HH:mm:ss")}[/]", + $"{ConvertTimeFromSecondsToText(session.Duration)}" }); + count++; + } + + AnsiConsole.Write(grid); + AddNewLines(2); + } + public void PrintCodingSessionToUpdateById(CodingSessionDataRecord session, int rowId) + { + var grid = new Grid(); + grid.AddColumn(); + grid.AddColumn(); + grid.AddColumn(); + grid.AddColumn(); + + grid.AddRow(new string[] { $"[blue]{rowId}[/]", + $"{session.StartTime.ToString("yyyy-MM-dd")} [yellow]{session.StartTime.ToString("HH:mm:ss")}[/]", + $"{session.EndTime.ToString("yyyy-MM-dd")} [yellow]{session.EndTime.ToString("HH:mm:ss")}[/]", + $"{ConvertTimeFromSecondsToText(session.Duration)}" }); + + AddNewLines(1); + AnsiConsole.Write("Updating Record: "); + AnsiConsole.Write(grid); + + AddNewLines(1); + } + public void PrintReportDataAsTable(ReportModel report) + { + AnsiConsole.WriteLine(); + + var grid = new Grid(); + grid.AddColumn(); + grid.AddColumn(); + grid.AddRow(new string[] { "[bold blue]First Entry:[/]", $"[green]{report.FirstEntry.StartTime.ToString("yyyy-MM-dd HH:mm:ss")}[/] to [red]{report.FirstEntry.EndTime.ToString("yyyy-MM-dd HH:mm:ss")}[/]" }); + grid.AddRow(new string[] { "[bold blue]Last Entry:[/]", $"[green]{report.LastEntry.StartTime.ToString("yyyy-MM-dd HH:mm:ss")}[/] to [red]{report.LastEntry.EndTime.ToString("yyyy-MM-dd HH:mm:ss")}[/]" }); + grid.AddRow(new string[] { "[bold blue]Total Sessions:[/]", $"{report.SessionCount}" }); + grid.AddRow(new string[] { "[bold blue]Total Time:[/]", $"{ConvertTimeFromSecondsToText(report.TotalTime)}" }); + grid.AddRow(new string[] { "[bold blue]Average Session:[/]", $"{ConvertTimeFromSecondsToText(report.AverageTime)}"}); + + AnsiConsole.Write(grid); + + AddNewLines(2); + AnsiConsole.Write("Press any key to continue... "); + AnsiConsole.Console.Input.ReadKey(false); + } + + + + public void NoRecordsMessage(string recordType) + { + AnsiConsole.MarkupInterpolated($"No {recordType} records exist!"); + AddNewLines(2); + } + public void PrintGoalListAsTable(List goals) + { + int count = 1; + var grid = new Grid(); + grid.AddColumn(); + grid.AddColumn(); + grid.AddColumn(); + grid.AddColumn(); + grid.AddColumn(); + grid.AddColumn(); + grid.AddColumn(); + grid.AddColumn(); + grid.AddRow(new Text[] {new Text("Id").Centered(), + new Text("Type").Centered(), + new Text("Status").Centered(), + new Text("Start Time").Centered(), + new Text("End Time").Centered(), + new Text("Goal Value").Centered(), + new Text("Current Value").Centered(), + new Text("Progress").Centered()}); + + + foreach (var goal in goals) + { + + grid.AddRow(new string[] { $"[blue]{count}[/]", + $"{goal.Type}", + $"{goal.Status}", + $"{goal.StartTime.ToString("yyyy-MM-dd")} [yellow]{goal.StartTime.ToString("HH:mm:ss")}[/]", + $"{goal.EndTime.ToString("yyyy-MM-dd")} [yellow]{goal.EndTime.ToString("HH:mm:ss")}[/]", + $"{GenerateValueText(goal.Type, goal.GoalValue)}", + $"{GenerateValueText(goal.Type, goal.CurrentValue)}", + $"{goal.Progress:f1}%"}); + count++; + } + + AnsiConsole.Write(grid); + AddNewLines(2); + } + public void GoalEvaluationMessage(GoalDTO goal) + { + string preamble = string.Empty; + + if (goal.Status == GoalStatus.Complete) + preamble = "[bold green]CONGRATULATIONS![/] Successfully completed"; + if (goal.Status == GoalStatus.Failed) + preamble = "[bold red]FAILED[/] Did not successfully complete"; + + string message = $"{preamble} the goal to reach [yellow]{GenerateValueText(goal.Type, goal.GoalValue)}[/] [blue]{goal.Type}[/]\n\r" + + $"Between the times [green]{goal.StartTime.ToString("yyyy-MM-dd HH:mm:ss")}[/] and [red]{goal.EndTime.ToString("yyyy-MM-dd HH:mm:ss")}[/]\n\r" + + $"With a total completed value of [yellow]{GenerateValueText(goal.Type, goal.CurrentValue)}[/] for a total of [yellow]{goal.Progress:f1}%[/]"; + + AnsiConsole.Markup(message); + AddNewLines(2); + } + + + private string GenerateValueText(GoalType goalType, long value) + { + var valueText = string.Empty; + + if (goalType == GoalType.TotalTime || goalType == GoalType.AverageTime) + valueText = TimeSpan.FromSeconds(value).ToString(); + if (goalType == GoalType.DaysPerPeriod) + valueText = TimeSpan.FromDays(value).ToString("%d"); + + return valueText; + } + private void AddNewLines(int lines) + { + for (int i = 0; i < lines; i++) + { + AnsiConsole.WriteLine(); + } + } + private string ConvertTimeFromSecondsToText(double input) + { + int miliseconds = TimeSpan.FromSeconds(input).Milliseconds; + int seconds = TimeSpan.FromSeconds(input).Seconds; + + if ((double)miliseconds / 1000 >= 0.5) seconds++; + + int minutes = TimeSpan.FromSeconds(input).Minutes; + int hours = TimeSpan.FromSeconds(input).Hours + TimeSpan.FromSeconds(input).Days * 24; + + return $"[yellow]{hours,4}[/] hours [yellow]{minutes,2}[/] minutes [yellow]{seconds,2}[/] seconds"; + } +} diff --git a/codingTracker.jzhartman/CodingTracker.Views/Interfaces/IConsoleOutputView.cs b/codingTracker.jzhartman/CodingTracker.Views/Interfaces/IConsoleOutputView.cs new file mode 100644 index 000000000..28587fbbc --- /dev/null +++ b/codingTracker.jzhartman/CodingTracker.Views/Interfaces/IConsoleOutputView.cs @@ -0,0 +1,18 @@ +using CodingTracker.Models.Entities; + +namespace CodingTracker.Views.Interfaces; +public interface IConsoleOutputView +{ + void ActionCancelledMessage(string action); + void ActionCompleteMessage(bool isSuccess, string state, string message); + void ConfirmationMessage(string valueText); + void ErrorMessage(string parameter, string message); + void PrintCodingSessionListAsTable(List sessions); + void PrintReportDataAsTable(ReportModel report); + void PrintCodingSessionToUpdateById(CodingSessionDataRecord session, int rowId); + void WelcomeMessage(); + void PrintGoalListAsTable(List goals); + void NoRecordsMessage(string recordType); + void GoalCancelledMessage(string action); + void GoalEvaluationMessage(GoalDTO goal); +} \ No newline at end of file diff --git a/codingTracker.jzhartman/CodingTracker.Views/Interfaces/IMenuView.cs b/codingTracker.jzhartman/CodingTracker.Views/Interfaces/IMenuView.cs new file mode 100644 index 000000000..bddbeba9e --- /dev/null +++ b/codingTracker.jzhartman/CodingTracker.Views/Interfaces/IMenuView.cs @@ -0,0 +1,10 @@ +namespace CodingTracker.Views.Interfaces; +public interface IMenuView +{ + string PrintEntryViewOptionsAndGetSelection(); + string PrintGoalOptionsAndGetSelection(); + string PrintGoalTypesAndGetSelection(); + string PrintMainMenuAndGetSelection(); + string PrintTrackingMenuAndGetSelection(); + string PrintUpdateOrDeleteOptionsAndGetSelection(); +} \ No newline at end of file diff --git a/codingTracker.jzhartman/CodingTracker.Views/Interfaces/IUserInputView.cs b/codingTracker.jzhartman/CodingTracker.Views/Interfaces/IUserInputView.cs new file mode 100644 index 000000000..fd45df42b --- /dev/null +++ b/codingTracker.jzhartman/CodingTracker.Views/Interfaces/IUserInputView.cs @@ -0,0 +1,18 @@ +using CodingTracker.Models.Entities; + +namespace CodingTracker.Views.Interfaces; +public interface IUserInputView +{ + int GetRecordIdFromUser(string action, int max); + bool GetAddSessionConfirmationFromUser(CodingSession session); + DateTime GetTimeFromUser(string parameterName, bool allowNull = false); + bool GetUpdateSessionConfirmationFromUser(CodingSessionDataRecord session, CodingSession updatedSession); + bool GetDeleteSessionConfirmationFromUser(CodingSessionDataRecord session); + void StartStopwatch(); + void StopStopwatch(); + long GetGoalValueTime(GoalType goalType); + long GetGoalValueForDaysPerPeriod(); + bool GetAddGoalConfirmationFromUser(GoalModel goal); + bool GetDeleteGoalConfirmationFromUser(GoalDTO goal); + void PressAnyKeyToContinue(); +} \ No newline at end of file diff --git a/codingTracker.jzhartman/CodingTracker.Views/MenuView.cs b/codingTracker.jzhartman/CodingTracker.Views/MenuView.cs new file mode 100644 index 000000000..5a4736c6d --- /dev/null +++ b/codingTracker.jzhartman/CodingTracker.Views/MenuView.cs @@ -0,0 +1,104 @@ +using CodingTracker.Views.Interfaces; +using Spectre.Console; + +namespace CodingTracker.Views; +public class MenuView : IMenuView +{ + public string PrintMainMenuAndGetSelection() + { + var selection = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Select from the options below:") + .AddChoices(new[] + { + "Track Session", + "View/Manage Entries", + "View Reports", + "Manage Goal", + "Exit" + }) + ); + + return selection; + } + + public string PrintTrackingMenuAndGetSelection() + { + var selection = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("How would you like to track your coding session?") + .AddChoices(new[] + { + "Enter Start and End Times", + "Begin Timer", + "Return to Main Menu" + }) + ); + + return selection; + } + + public string PrintEntryViewOptionsAndGetSelection() + { + var selection = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("How would you like your entries displayed?") + .AddChoices(new[] + { + "All", + "Past Year", + "Year to Date", + "Custom Week", + "Custom Month", + "Custom Year", + "Return to Previous Menu" + }) + ); + return selection; + } + public string PrintUpdateOrDeleteOptionsAndGetSelection() + { + var selection = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Please select the next operation:") + .AddChoices(new[] + { + "Change Record", + "Delete Record", + "Return to Previous Menu" + }) + ); + return selection; + } + + public string PrintGoalOptionsAndGetSelection() + { + var selection = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Please select the next operation:") + .AddChoices(new[] + { + "Add Goal", + "Delete Goal", + "View Completed Goals", + "Return to Previous Menu" + }) + ); + return selection; + } + + public string PrintGoalTypesAndGetSelection() + { + var selection = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Please select the next operation:") + .AddChoices(new[] + { + "Total Time", + "Average Time", + "Days Per Period" + }) + ); + return selection; + } +} diff --git a/codingTracker.jzhartman/CodingTracker.Views/UserInputView.cs b/codingTracker.jzhartman/CodingTracker.Views/UserInputView.cs new file mode 100644 index 000000000..dded91e91 --- /dev/null +++ b/codingTracker.jzhartman/CodingTracker.Views/UserInputView.cs @@ -0,0 +1,274 @@ +using CodingTracker.Models.Entities; +using CodingTracker.Views.Interfaces; +using Spectre.Console; +using System.Globalization; + +namespace CodingTracker.Views; +public class UserInputView : IUserInputView +{ + private readonly string _dateFormat; + public UserInputView(string dateFormat) + { + _dateFormat = dateFormat; + } + public DateTime GetTimeFromUser(string parameterName, bool allowNull = false) + { + var timeInput = string.Empty; + var promptText = GenerateEnterDatePromptText(parameterName, allowNull); + var errorMessageText = $"[bold red]ERROR:[/] The value you entered does not match the required format!\r\n"; + + + if (allowNull) + { + timeInput = AnsiConsole.Prompt( + + new TextPrompt(promptText) + .AllowEmpty() + .ValidationErrorMessage(errorMessageText) + .Validate(input => + { + if (String.IsNullOrWhiteSpace(input)) + { + return ValidationResult.Success(); + } + else if (DateTime.TryParseExact(input, _dateFormat, CultureInfo.InvariantCulture, DateTimeStyles.None, out _)) + { + return ValidationResult.Success(); + } + else + return ValidationResult.Error(); + })); + + if (timeInput == "") timeInput = DateTime.MinValue.ToString(_dateFormat); + } + + else timeInput = AnsiConsole.Prompt( + new TextPrompt(promptText) + .ValidationErrorMessage(errorMessageText) + .Validate(input => + { + if (DateTime.TryParseExact(input, _dateFormat, CultureInfo.InvariantCulture, DateTimeStyles.None, out _)) + { + return ValidationResult.Success(); + } + return ValidationResult.Error(); + })); + + return DateTime.ParseExact(timeInput, _dateFormat, CultureInfo.InvariantCulture); + } + public void StartStopwatch() + { + AnsiConsole.WriteLine(); + AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Press the ENTER key when you are ready to begin your coding session.") + .AddChoices(new[] + { + "Start Stopwatch", + }) + ); + } + public void StopStopwatch() + { + AnsiConsole.WriteLine(); + AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Press the ENTER key when you are ready to end your coding session.") + .AddChoices(new[] + { + "Stop Stopwatch", + }) + ); + } + public int GetRecordIdFromUser(string action, int max) + { + var id = AnsiConsole.Prompt( + new TextPrompt($"Please enter the [yellow]ID[/] of the record you wish to {action.ToLower()}:") + .Validate(input => + { + if (input < 1) return Spectre.Console.ValidationResult.Error($"[red]ERROR:[/] A record for this value does not exist. Please enter a value between [yellow]1[/] and [yellow]{max}[/].\r\n"); + else if (input > max) return Spectre.Console.ValidationResult.Error($"[red]ERROR:[/] A record for this value does not exist. Please enter a value between [yellow]1[/] and [yellow]{max}[/].\r\n"); + else return Spectre.Console.ValidationResult.Success(); + })); + + return id; + } + + + + public bool GetAddSessionConfirmationFromUser(CodingSession session) + { + var duration = ConvertTimeFromSecondsToText(session.Duration); + + var confirmation = AnsiConsole.Prompt( + new TextPrompt($"Add coding session starting at [yellow]{session.StartTime.ToString("yyyy-MM-dd HH:mm:ss")}[/] and ending at [yellow]{session.EndTime.ToString("yyyy-MM-dd HH:mm:ss")}[/] with duration [yellow]{duration}[/]?") + .AddChoice(true) + .AddChoice(false) + .WithConverter(choice => choice ? "y" : "n")); + + return confirmation; + } + public bool GetUpdateSessionConfirmationFromUser(CodingSessionDataRecord session, CodingSession updatedSession) + { + string promptText = GenerateUpdateSessionConfirmationPrompt(session, updatedSession); + + var confirmation = AnsiConsole.Prompt( + new TextPrompt(promptText) + .AddChoice(true) + .AddChoice(false) + .WithConverter(choice => choice ? "y" : "n")); + + return confirmation; + } + public bool GetDeleteSessionConfirmationFromUser(CodingSessionDataRecord session) + { + AnsiConsole.WriteLine(); + var duration = ConvertTimeFromSecondsToText(session.Duration); + + string promptText = $"Confirm deletion of coding session with start time [yellow]{session.StartTime.ToString("yyyy-MM-dd HH:mm:ss")}[/]" + + $" and end time [yellow]{session.EndTime.ToString("yyyy-MM-dd HH:mm:ss")}[/]" + + $" with duration [yellow]{duration}[/]"; + + var confirmation = AnsiConsole.Prompt( + new TextPrompt(promptText) + .AddChoice(true) + .AddChoice(false) + .WithConverter(choice => choice ? "y" : "n")); + + return confirmation; + } + + + + public long GetGoalValueTime(GoalType goalType) + { + string promptText = "Please enter the value for the "; + if (goalType == GoalType.TotalTime) promptText += "total time to code within this timeframe using the format [yellow]d.HH:mm:ss[/]:"; + if (goalType == GoalType.AverageTime) promptText += "average daily time coding within this timeframe using the format [yellow]HH:mm:ss[/]:"; + + var goalValue = AnsiConsole.Prompt( + new TextPrompt(promptText)); + + return (long)goalValue.TotalSeconds; + } + public long GetGoalValueForDaysPerPeriod() + { + var goalValue = AnsiConsole.Prompt( + new TextPrompt($"Please enter the goal value for the days per period:")); + + return goalValue; + } + public bool GetAddGoalConfirmationFromUser(GoalModel goal) + { + string promptText = $"Add {GenerateGoalConfirmationPromptText( new GoalDTO {Id = 0, Type = goal.Type, StartTime = goal.StartTime, + EndTime = goal.EndTime, Status = goal.Status, + GoalValue = goal.GoalValue, CurrentValue = goal.CurrentValue, + Progress = goal.Progress + }) + }"; + + var confirmation = AnsiConsole.Prompt( + new TextPrompt($"{promptText}?") + .AddChoice(true) + .AddChoice(false) + .WithConverter(choice => choice ? "y" : "n")); + + return confirmation; + } + public bool GetDeleteGoalConfirmationFromUser(GoalDTO goal) + { + string promptText = $"Confirm deletion of {GenerateGoalConfirmationPromptText(goal)}"; + + var confirmation = AnsiConsole.Prompt( + new TextPrompt(promptText) + .AddChoice(true) + .AddChoice(false) + .WithConverter(choice => choice ? "y" : "n")); + + return confirmation; + } + + public void PressAnyKeyToContinue() + { + AnsiConsole.WriteLine(); + AnsiConsole.Markup("[yellow]Press any key to continue...[/]"); + Console.ReadKey(true); + } + + + + private string GenerateGoalConfirmationPromptText(GoalDTO goal) + { + string valueText = string.Empty; + + if (goal.Type == GoalType.TotalTime || goal.Type == GoalType.AverageTime) + valueText = TimeSpan.FromSeconds(goal.GoalValue).ToString(); + if (goal.Type == GoalType.DaysPerPeriod) + valueText = TimeSpan.FromDays(goal.GoalValue).ToString("%d"); + + string promptText = "[yellow]" + goal.Type + "[/] goal " + + "starting at [yellow]" + goal.StartTime.ToString("yyyy-MM-dd HH:mm:ss") + "[/] " + + "and ending at [yellow]" + goal.EndTime.ToString("yyyy-MM-dd HH:mm:ss") + "[/] " + + "with value [yellow]" + valueText + "[/]"; + return promptText; + } + private string ConvertTimeFromSecondsToText(double input) + { + int miliseconds = TimeSpan.FromSeconds(input).Milliseconds; + int seconds = TimeSpan.FromSeconds(input).Seconds; + + if ((double)miliseconds / 1000 >= 0.5) seconds++; + + int minutes = TimeSpan.FromSeconds(input).Minutes; + int hours = TimeSpan.FromSeconds(input).Hours + TimeSpan.FromSeconds(input).Days * 24; + + return $"{hours}:{minutes:00}:{seconds:00}"; + } + private string GenerateUpdateSessionConfirmationPrompt(CodingSessionDataRecord session, CodingSession updatedSession) + { + string prompt = $"Update coding session "; + + if (session.StartTime == updatedSession.StartTime && session.EndTime == updatedSession.EndTime) + return $"No changes were made to the coding session with start time [yellow]{session.StartTime.ToString("yyyy-MM-dd HH:mm:ss")}[/] and end time [yellow]{session.EndTime.ToString("yyyy-MM-dd HH:mm:ss")}[/]"; + + if (session.StartTime != updatedSession.StartTime) + prompt += $"start time from [yellow]{session.StartTime.ToString("yyyy-MM-dd HH:mm:ss")}[/] to [green]{updatedSession.StartTime.ToString("yyyy-MM-dd HH:mm:ss")}[/]"; + + if (session.StartTime != updatedSession.StartTime && session.EndTime != updatedSession.EndTime) + prompt += " and "; + + if (session.EndTime != updatedSession.EndTime) + prompt += $"end time from [yellow]{session.EndTime.ToString("yyyy-MM-dd HH:mm:ss")}[/] to [green]{updatedSession.EndTime.ToString("yyyy-MM-dd HH:mm:ss")}[/]"; + + prompt += $" for a new duration of [green]{updatedSession.DurationText}[/]."; + + return prompt; + } + private string GenerateEnterDatePromptText(string parameterName, bool allowNull) + { + string article = GenerateArticleForDatePromptText(parameterName); + string promptText = $"Please enter {article} {parameterName} using the format [yellow]'yyyy-MM-dd HH:mm:ss'[/]"; + + if (allowNull) + { + if (parameterName == "new start time") + promptText += $".\r\nOr, leave blank and press ENTER to keep the existing start time:"; + else if (parameterName == "new end time") + promptText += $".\r\nOr, leave blank and press ENTER to keep the existing end time:"; + } + else + { + promptText += ":"; + } + + return promptText; + } + private string GenerateArticleForDatePromptText(string noun) + { + string article = "a"; + char firstLetter = noun.ToLower()[0]; + if (firstLetter == 'a' || firstLetter == 'e' || firstLetter == 'i' || firstLetter == 'o' || firstLetter == 'u') article += "n"; + + return article; + } +} \ No newline at end of file diff --git a/codingTracker.jzhartman/CodingTracker/App.config b/codingTracker.jzhartman/CodingTracker/App.config new file mode 100644 index 000000000..46fe3b5cd --- /dev/null +++ b/codingTracker.jzhartman/CodingTracker/App.config @@ -0,0 +1,7 @@ + + + + + + + diff --git a/codingTracker.jzhartman/CodingTracker/CodingTracker.ConsoleApp.csproj b/codingTracker.jzhartman/CodingTracker/CodingTracker.ConsoleApp.csproj new file mode 100644 index 000000000..262884db4 --- /dev/null +++ b/codingTracker.jzhartman/CodingTracker/CodingTracker.ConsoleApp.csproj @@ -0,0 +1,29 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/codingTracker.jzhartman/CodingTracker/Configuration/DateTimeOptions.cs b/codingTracker.jzhartman/CodingTracker/Configuration/DateTimeOptions.cs new file mode 100644 index 000000000..8353e0553 --- /dev/null +++ b/codingTracker.jzhartman/CodingTracker/Configuration/DateTimeOptions.cs @@ -0,0 +1,5 @@ +namespace CodingTracker.ConsoleApp.Configuration; +public class DateTimeOptions +{ + public string Format { get; set; } +} diff --git a/codingTracker.jzhartman/CodingTracker/Program.cs b/codingTracker.jzhartman/CodingTracker/Program.cs new file mode 100644 index 000000000..8daa3c78a --- /dev/null +++ b/codingTracker.jzhartman/CodingTracker/Program.cs @@ -0,0 +1,17 @@ +using CodingTracker.Controller.Interfaces; +using CodingTracker.Data.Interfaces; +using Microsoft.Extensions.DependencyInjection; +using SQLitePCL; + +namespace CodingTracker.ConsoleApp; +internal class Program +{ + static void Main(string[] args) + { + Batteries.Init(); + var serviceProvider = Startup.ConfigureServices(); + serviceProvider.GetRequiredService().Run(); + + serviceProvider.GetRequiredService().Run(); + } +} diff --git a/codingTracker.jzhartman/CodingTracker/Startup.cs b/codingTracker.jzhartman/CodingTracker/Startup.cs new file mode 100644 index 000000000..d2c62cdac --- /dev/null +++ b/codingTracker.jzhartman/CodingTracker/Startup.cs @@ -0,0 +1,57 @@ +using CodingTracker.Controller; +using CodingTracker.Controller.Interfaces; +using CodingTracker.Data; +using CodingTracker.Data.Interfaces; +using CodingTracker.Data.Repositories; +using CodingTracker.Data.TypeHandlers; +using CodingTracker.Services; +using CodingTracker.Services.Interfaces; +using CodingTracker.Views; +using CodingTracker.Views.Interfaces; +using Dapper; +using Microsoft.Extensions.DependencyInjection; +using System.Configuration; + +namespace CodingTracker.ConsoleApp; +internal static class Startup +{ + public static IServiceProvider ConfigureServices() + { + var connectionString = ConfigurationManager.AppSettings.Get("connectionString"); + + var dateTimeFormat = ConfigurationManager.AppSettings.Get("timeAndDateFormat"); + SqlMapper.RemoveTypeMap(typeof(DateTime)); + SqlMapper.RemoveTypeMap(typeof(DateTime?)); + SqlMapper.AddTypeHandler(new DateTimeHandler(dateTimeFormat)); + SqlMapper.AddTypeHandler(new GoalTypeHandler()); + SqlMapper.AddTypeHandler(new GoalStatusHandler()); + + + var services = new ServiceCollection(); + + //Register All Controllers + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + //Register All Services + services.AddSingleton(); + services.AddSingleton(); + + //Resgister All Views + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(new UserInputView(dateTimeFormat)); + + //Register repos and data items + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(provider => new SqliteConnectionFactory(connectionString)); + services.AddSingleton(); + + return services.BuildServiceProvider(); + } + +} diff --git a/codingTracker.jzhartman/codingTracker.jzhartman.sln b/codingTracker.jzhartman/codingTracker.jzhartman.sln new file mode 100644 index 000000000..8b4fbbfc7 --- /dev/null +++ b/codingTracker.jzhartman/codingTracker.jzhartman.sln @@ -0,0 +1,55 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.14.36310.24 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodingTracker.ConsoleApp", "CodingTracker\CodingTracker.ConsoleApp.csproj", "{E718A170-B8DA-40F4-965C-5C112E649C99}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodingTracker.Controller", "CodingTracker.Controller\CodingTracker.Controller.csproj", "{350388E8-9796-437A-84E2-01BC1F133CF8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodingTracker.Data", "CodingTracker.Data\CodingTracker.Data.csproj", "{BCC76984-3A99-4DD8-97A3-216FCB166F02}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodingTracker.Models", "CodingTracker.Models\CodingTracker.Models.csproj", "{AE0E7C34-E681-47F8-B016-4BBD48955078}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodingTracker.Views", "CodingTracker.Views\CodingTracker.Views.csproj", "{5ED55C55-FC49-437B-A8A8-B454F51A5CC9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodingTracker.Services", "CodingTracker.Services\CodingTracker.Services.csproj", "{489125D9-C868-43B6-93D9-A0A1EECAD57D}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {E718A170-B8DA-40F4-965C-5C112E649C99}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E718A170-B8DA-40F4-965C-5C112E649C99}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E718A170-B8DA-40F4-965C-5C112E649C99}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E718A170-B8DA-40F4-965C-5C112E649C99}.Release|Any CPU.Build.0 = Release|Any CPU + {350388E8-9796-437A-84E2-01BC1F133CF8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {350388E8-9796-437A-84E2-01BC1F133CF8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {350388E8-9796-437A-84E2-01BC1F133CF8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {350388E8-9796-437A-84E2-01BC1F133CF8}.Release|Any CPU.Build.0 = Release|Any CPU + {BCC76984-3A99-4DD8-97A3-216FCB166F02}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BCC76984-3A99-4DD8-97A3-216FCB166F02}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BCC76984-3A99-4DD8-97A3-216FCB166F02}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BCC76984-3A99-4DD8-97A3-216FCB166F02}.Release|Any CPU.Build.0 = Release|Any CPU + {AE0E7C34-E681-47F8-B016-4BBD48955078}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AE0E7C34-E681-47F8-B016-4BBD48955078}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AE0E7C34-E681-47F8-B016-4BBD48955078}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AE0E7C34-E681-47F8-B016-4BBD48955078}.Release|Any CPU.Build.0 = Release|Any CPU + {5ED55C55-FC49-437B-A8A8-B454F51A5CC9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5ED55C55-FC49-437B-A8A8-B454F51A5CC9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5ED55C55-FC49-437B-A8A8-B454F51A5CC9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5ED55C55-FC49-437B-A8A8-B454F51A5CC9}.Release|Any CPU.Build.0 = Release|Any CPU + {489125D9-C868-43B6-93D9-A0A1EECAD57D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {489125D9-C868-43B6-93D9-A0A1EECAD57D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {489125D9-C868-43B6-93D9-A0A1EECAD57D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {489125D9-C868-43B6-93D9-A0A1EECAD57D}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {CF0F147B-48DF-4950-AB1E-F0DB27899A6B} + EndGlobalSection +EndGlobal