diff --git a/Controllers/DatabaseController.cs b/Controllers/DatabaseController.cs new file mode 100644 index 00000000..3680a906 --- /dev/null +++ b/Controllers/DatabaseController.cs @@ -0,0 +1,218 @@ +using Dapper; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Configuration; +using Spectre.Console; +using System.Data; +using System.Text; + +internal class DatabaseController +{ + private static string _tableName = "Sessions"; + private static readonly IConfiguration _configuration = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json") + .Build(); + private static string connectionString = _configuration.GetConnectionString("Default"); + + public static void StartConnection() + { + using(var connection = new SqliteConnection(connectionString)) + { + try + { + connection.Open(); + var tableCmd = connection.CreateCommand(); + + tableCmd.CommandText = @$"CREATE TABLE IF NOT EXISTS {_tableName}( + Id INTEGER PRIMARY KEY AUTOINCREMENT, + Name TEXT, + Date TEXT, + Start TEXT, + End TEXT, + Duration TEXT + );"; + + tableCmd.ExecuteNonQuery(); + } + catch (SqliteException) + { + AnsiConsole.Write("An error occured! Could not create table."); + AnsiConsole.Prompt(new SelectionPrompt().AddChoices("Return")); + } + finally + { + connection.Close(); + } + } + } + + public static void AddRecord(CodingSession record) + { + using(var connection = new SqliteConnection(connectionString)) + { + try + { + connection.Open(); + var tableCmd = $@"INSERT INTO {_tableName} (name, date, start, end, duration) + VALUES (@name, @date, @start, @end, @duration)"; + connection.Execute(tableCmd, new { name = record.RecordName, date = record.Date, start = record.StartTime, end = record.EndTime, duration = record.Duration }); + } + catch (SqliteException) + { + AnsiConsole.Write("An error occured! Could not add record."); + AnsiConsole.Prompt(new SelectionPrompt().AddChoices("Return")); + } + finally + { + connection.Close(); + } + } + } + + public static List GetRecords(List filters) + { + var readerCmd = $"SELECT * FROM {_tableName} "; + var filterCommands = new List(); + var table = new DataTable(); + + foreach (FilterCondition filter in filters) + { + filterCommands.Add($" {filter.Type} {filter.Relational} \"{filter.ComparedTo}\" "); + } + + if (filters.Count > 0) + { + readerCmd += $"WHERE " + string.Join("AND", filterCommands) + ";"; + } + + using (var connection = new SqliteConnection(connectionString)) + { + try + { + connection.Open(); + var reader = connection.ExecuteReader(readerCmd); + + table.Load(reader); + } + catch (SqliteException) + { + AnsiConsole.Clear(); + AnsiConsole.Write("An error occured! Could not retrieve records."); + AnsiConsole.Prompt(new SelectionPrompt().AddChoices("Return")); + } + finally + { + connection.Close(); + } + } + + var allRows = new List(); + + foreach (DataRow rows in table.Rows) + { + StringBuilder output = new StringBuilder(); + + foreach (DataColumn column in table.Columns) + { + output.AppendFormat("{0} ", rows[column]); + } + allRows.Add(output.ToString()); + } + + return allRows; + } + + public static void RemoveRecord(CodingSession record) + { + using (var connection = new SqliteConnection(connectionString)) + { + try + { + connection.Open(); + + var removeCmd = @$"DELETE FROM {_tableName} WHERE + id = @id AND + name = @name AND + date = @date AND + start = @start AND + end = @end AND + duration = @duration;"; + + connection.Execute(removeCmd, new { id = record.Id, + name = record.RecordName, + date = record.Date, + start = record.StartTime, + end = record.EndTime, + duration = record.Duration}); + + } + catch (SqliteException ex) + { + AnsiConsole.Clear(); + AnsiConsole.WriteLine("Something went wrong! Could not remove record! " + ex); + AnsiConsole.Prompt(new SelectionPrompt().AddChoices("Return")); + } + finally + { + connection.Close(); + } + + } + } + + public static void EditRecord(CodingSession record, string valueToChange, string newValue) + { + using (var connection = new SqliteConnection(connectionString)) + { + try + { + connection.Open(); + + var editCmd = string.Empty; + + if(valueToChange == "start" || valueToChange == "end") + { + editCmd = @$"Update {_tableName} SET {valueToChange} = @value, duration = @duration + WHERE id = @id AND + name = @name AND + date = @date AND + start = @start AND + end = @end"; + } + else + { + editCmd = @$"Update {_tableName} SET {valueToChange} = @value + WHERE id = @id AND + name = @name AND + date = @date AND + start = @start AND + end = @end AND + duration = @duration;"; + } + + connection.Execute(editCmd, new + { + value = newValue, + id = record.Id, + name = record.RecordName, + date = record.Date, + start = record.StartTime, + end = record.EndTime, + duration = record.Duration + }); + + } + catch (SqliteException ex) + { + AnsiConsole.Clear(); + AnsiConsole.WriteLine("Something went wrong! Could not edit record! " + ex); + AnsiConsole.Prompt(new SelectionPrompt().AddChoices("Return")); + } + finally + { + connection.Close(); + } + + } + } +} diff --git a/Models/CodingSession.cs b/Models/CodingSession.cs new file mode 100644 index 00000000..0dcb9ea7 --- /dev/null +++ b/Models/CodingSession.cs @@ -0,0 +1,80 @@ +using Spectre.Console; + +internal class CodingSession +{ + public int Id { get; set; } + public string RecordName { get; set; } + public string Date { get; set; } + public string StartTime { get; set; } + public string EndTime { get; set; } + public string Duration { get; set; } + + public CodingSession(string name, string date, string start, string end) + { + this.Id = 0; + this.RecordName = name; + this.Date = date; + this.StartTime = start; + this.EndTime = end; + this.Duration = CalculateDuration(); + } + + public CodingSession(string record) + { + var recordDetails = record.Split(' '); + + try + { + this.Id = Int32.Parse(recordDetails[0]); + this.RecordName = recordDetails[1]; + this.Date = recordDetails[2]; + this.StartTime = recordDetails[3]; + this.EndTime = recordDetails[4]; + this.Duration = recordDetails[5]; + } + catch (Exception ex) + { + AnsiConsole.WriteLine("Failed! record may be corrupted or in the wrong format"); + AnsiConsole.Ask("Press enter to return"); + } + } + + public void ChangeTime(string start, string end) + { + this.Duration = CalculateDuration(start, end); + } + + private string CalculateDuration(string start, string end) + { + var startTime = DateTime.ParseExact( + start, + Globals.TIME_FORMAT, + Globals.CULTURE_INFO); + + var endTime = DateTime.ParseExact( + end, + Globals.TIME_FORMAT, + Globals.CULTURE_INFO); + + var duration = endTime.Subtract(startTime); + + return duration.ToString(); + } + + private string CalculateDuration() + { + var startTime = DateTime.ParseExact( + this.StartTime, + Globals.TIME_FORMAT, + Globals.CULTURE_INFO); + + var endTime = DateTime.ParseExact( + this.EndTime, + Globals.TIME_FORMAT, + Globals.CULTURE_INFO); + + var duration = endTime.Subtract(startTime); + + return duration.ToString(); + } +} \ No newline at end of file diff --git a/Models/FilterCondition.cs b/Models/FilterCondition.cs new file mode 100644 index 00000000..c5033b87 --- /dev/null +++ b/Models/FilterCondition.cs @@ -0,0 +1,62 @@ +public class FilterCondition +{ + public string ComparedTo { get; set; } + public string Relational { get; set; } + public string Type { get; set; } + + public FilterCondition(ConditionType type, RelationalCondition relational, string compare) + { + this.Type = ConvertCondtionType(type); + this.Relational = ConvertRelational(relational); + this.ComparedTo = compare; + } + private string ConvertCondtionType(ConditionType type) + { + switch (type) + { + case ConditionType.Date: + return "date"; + case ConditionType.StartTime: + return "start"; + case ConditionType.EndTime: + return "end"; + case ConditionType.Duration: + return "duration"; + default: + return "start"; + } + } + + private string ConvertRelational(RelationalCondition relational) + { + switch (relational) + { + case RelationalCondition.GreaterThanOrEqual: + return ">="; + case RelationalCondition.LessThanOrEqual: + return "<="; + case RelationalCondition.Exactly: + return "="; + default: + return "="; + } + } + + public enum ConditionType + { + Date, + StartTime, + EndTime, + Duration, + None + } + + public enum RelationalCondition + { + GreaterThanOrEqual, + LessThanOrEqual, + Exactly, + None + } +} + diff --git a/Models/Globals.cs b/Models/Globals.cs new file mode 100644 index 00000000..8ce6851e --- /dev/null +++ b/Models/Globals.cs @@ -0,0 +1,9 @@ +using System.Globalization; + +public static class Globals +{ + public static readonly string DATE_FORMAT = "dd/MM/yyyy"; + public static readonly string TIME_FORMAT = "HH:mm:ss"; + public static readonly IFormatProvider CULTURE_INFO = new CultureInfo("en-US"); +} + diff --git a/Models/Validation.cs b/Models/Validation.cs new file mode 100644 index 00000000..f8483d32 --- /dev/null +++ b/Models/Validation.cs @@ -0,0 +1,42 @@ +using System.Globalization; + +internal class Validation +{ + public static bool CheckValidDate(string userDate) + { + var validDate = DateTime.MinValue; + var isValid = DateTime.TryParseExact( + userDate, + Globals.DATE_FORMAT, + Globals.CULTURE_INFO, + DateTimeStyles.None, + out validDate); + + return isValid; + } + + public static bool CheckValidTime(string userTime) + { + var validTime = DateTime.MinValue; + var isValid = DateTime.TryParseExact( + userTime, + Globals.TIME_FORMAT, + Globals.CULTURE_INFO, + DateTimeStyles.None, + out validTime); + + return isValid; + } + + public static bool CheckValidEndTime(string start, string end) + { + var startTime = DateTime.Parse(start); + var endTime = DateTime.Parse(end); + + if (startTime <= endTime) + { + return true; + } + return false; + } +} diff --git a/Program.cs b/Program.cs new file mode 100644 index 00000000..d1d950ef --- /dev/null +++ b/Program.cs @@ -0,0 +1,2 @@ +DatabaseController.StartConnection(); +UserInterface.MainMenu(); \ No newline at end of file diff --git a/Properties/launchSettings.json b/Properties/launchSettings.json new file mode 100644 index 00000000..8382dc05 --- /dev/null +++ b/Properties/launchSettings.json @@ -0,0 +1,8 @@ +{ + "profiles": { + "codeTracker.davetn657": { + "commandName": "Project", + "workingDirectory": ".\\" + } + } +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 00000000..e1dab886 --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +# Code Tracking Console Application +## Overview +A console based application to track time spent coding. Created for the C# Academy project based learning + +## Requirements +- Application where you can log coding sessions +- Tracked by time spent coding. Calculated based off user input +- Users are able to input date, start time, end time +- Application stores data from a database +- Creates a table in a database to store and retrieve data +- Console interface using Spectre.Console library +- OOP based project using multiple classes +- Uses Dapper ORM +- Follow Dry principle + +### Technologies +- C# +- SQLite +- Dapper ORM +- Spectre.Console + +## Features +Features user friendly interface allowing navigation through menu options. + +Launching the application will display a simple menu with the follwoing options: + +See Existing Records -> Displays all records stored in local database +Create Coding Session -> Allows creation of new records +Exit -> Closes application + +### Creating a new coding session +From the main menu users are able to choose "Create Coding Session", where they will be prompted to choose, the record name, date, start time, and end time. + +Using this information, the application will calculate the duration based off of the start and end time then log it into the database. + +### Edit/Removing existing coding sessions +From the main menu users are able to choose "See Existing Records", which will display all records in the database. From there users will be able to select a record of their choosing and edit or remove the record from the database + +Choosing edit will allow the user to edit the following: +- Record Name +- Date +- Start Time +- End Time + +Choosing to edit either start or end time will also update the record duration \ No newline at end of file diff --git a/User Interface/UserInput.cs b/User Interface/UserInput.cs new file mode 100644 index 00000000..d62e46a5 --- /dev/null +++ b/User Interface/UserInput.cs @@ -0,0 +1,48 @@ +using Spectre.Console; + +internal class UserInput +{ + public static string GetUserDate() + { + var endAsk = false; + + while (!endAsk) + { + var userDate = AnsiConsole.Ask($"Enter a date ({Globals.DATE_FORMAT}): "); + var validDate = Validation.CheckValidDate(userDate); + + if(validDate) + { + return userDate; + } + else + { + AnsiConsole.WriteLine($"Enter a valid date {Globals.DATE_FORMAT}", new Style(Color.Red)); + } + } + + return string.Empty; + } + + public static string GetUserTime() + { + var endAsk = false; + + while (!endAsk) + { + var userTime = AnsiConsole.Ask($"Enter a time ({Globals.TIME_FORMAT}): "); + var validTime = Validation.CheckValidTime(userTime); + + if (userTime != null && validTime) + { + return userTime; + } + else + { + AnsiConsole.WriteLine($"Enter a valid Time {Globals.TIME_FORMAT}", new Style(Color.Red)); + } + } + + return string.Empty; + } +} \ No newline at end of file diff --git a/User Interface/UserInterface.cs b/User Interface/UserInterface.cs new file mode 100644 index 00000000..c74c9571 --- /dev/null +++ b/User Interface/UserInterface.cs @@ -0,0 +1,312 @@ +using Spectre.Console; + +internal class UserInterface +{ + private static List filterConditions = new List(); + + public static void MainMenu() + { + CreateTitlePanel("Welcome to the Coding Tracker!"); + + var menuOptions = new[] { + "See Existing Records", + "Create Coding Session", + "Exit" + }; + var menuInput = AnsiConsole.Prompt(new SelectionPrompt().AddChoices(menuOptions)); + + AnsiConsole.Clear(); + + switch (menuInput) + { + case "See Existing Records": + ExistingRecordsInterface(); + break; + case "Create Coding Session": + NewRecordInterface(); + break; + case "Exit": + Environment.Exit(0); + return; + } + } + + private static void ExistingRecordsInterface() + { + var title = "Existing Records!"; + CreateTitlePanel(title); + + var menuOptions = DatabaseController.GetRecords(filterConditions); + + if (filterConditions.Count > 0) + { + menuOptions.Add("Reset Filters"); + } + + menuOptions.Add("Add Filter"); + menuOptions.Add("Exit"); + + var menuInput = AnsiConsole.Prompt(new SelectionPrompt().AddChoices(menuOptions)); + + switch (menuInput) + { + case "Reset Filters": + filterConditions.Clear(); + ExistingRecordsInterface(); + break; + case "Add Filter": + FiltersInterface(); + break; + case "Exit": + filterConditions.Clear(); + MainMenu(); + break; + default: + var record = new CodingSession(menuInput); + EditRecordInterface(record); + break; + } + } + + private static void FiltersInterface() + { + var title = "Filter Options"; + CreateTitlePanel(title); + + var filterOptions = new[] + { + "Date", + "Start Time", + "End Time", + "Duration", + "Return" + }; + + var menuInput = AnsiConsole.Prompt(new SelectionPrompt().AddChoices(filterOptions)); + var condition = FilterCondition.ConditionType.None; + + AnsiConsole.Clear(); + + switch (menuInput) + { + case "Date": + condition = FilterCondition.ConditionType.Date; + RelationalFiltersInterface(condition); + return; + case "Start Time": + condition = FilterCondition.ConditionType.StartTime; + RelationalFiltersInterface(condition); + break; + case "End Time": + condition = FilterCondition.ConditionType.EndTime; + RelationalFiltersInterface(condition); + break; + case "Duration": + condition = FilterCondition.ConditionType.Duration; + RelationalFiltersInterface(condition); + break; + case "Return": + return; + default: + AnsiConsole.WriteLine("Something went wrong! Could not use condition type! Try Again!"); + FiltersInterface(); + return; + } + + } + + private static void RelationalFiltersInterface(FilterCondition.ConditionType type) + { + var title = "Filter Options"; + CreateTitlePanel(title); + + var additionalFilterOptions = new[] + { + "Greater than or Equal", + "Less than or Equal", + "Exactly" + }; + + var menuInput = AnsiConsole.Prompt(new SelectionPrompt().AddChoices(additionalFilterOptions)); + var relational = FilterCondition.RelationalCondition.None; + + AnsiConsole.Clear(); + + switch (menuInput) + { + case "Greater than or Equal": + relational = FilterCondition.RelationalCondition.GreaterThanOrEqual; + break; + case "Less than or Equal": + relational = FilterCondition.RelationalCondition.LessThanOrEqual; + break; + case "Exactly": + relational |= FilterCondition.RelationalCondition.Exactly; + break; + default: + AnsiConsole.WriteLine("Something went wrong! Could not use relational! Try Again!"); + RelationalFiltersInterface(type); + return; + } + + ComparisonFilterInterface(type, relational); + } + + private static void ComparisonFilterInterface(FilterCondition.ConditionType type, FilterCondition.RelationalCondition relational) + { + var title = "Filter Options"; + CreateTitlePanel(title); + + var endAsk = false; + var comparison = string.Empty; + var format = string.Empty; + + if (type == FilterCondition.ConditionType.Date) format = Globals.DATE_FORMAT; + else format = Globals.TIME_FORMAT; + + while (!endAsk) + { + comparison = AnsiConsole.Ask($"Compared to ({format}):"); + endAsk = Validation.CheckValidTime(comparison); + + if (endAsk == false) + { + AnsiConsole.Clear(); + CreateTitlePanel(title); + AnsiConsole.WriteLine($"Enter a valid format ({format})"); + } + } + + var filter = new FilterCondition(type, relational, comparison); + + filterConditions.Add(filter); + ExistingRecordsInterface(); + } + + private static void EditRecordInterface(CodingSession record) + { + var title = "Edit Record"; + CreateTitlePanel(title); + + var menuOptions = new[] + { + "Edit", + "Remove", + "Return" + }; + + var menuInput = AnsiConsole.Prompt(new SelectionPrompt().AddChoices(menuOptions)); + + switch (menuInput) + { + case "Edit": + ChangeRecordInfoInterface(record); + break; + case "Remove": + DatabaseController.RemoveRecord(record); + break; + case "Return": + return; + default: + AnsiConsole.WriteLine("Something went wrong!"); + break; + } + + ExistingRecordsInterface(); + } + + private static void ChangeRecordInfoInterface(CodingSession record) + { + var title = "Choose what to edit"; + CreateTitlePanel(title); + + var menuOptions = new[] + { + "Record Name", + "Date", + "Start Time", + "End Time", + "Return" + }; + + var valueToChange = string.Empty; + var newValue = string.Empty; + + var menuInput = AnsiConsole.Prompt(new SelectionPrompt().AddChoices(menuOptions)); + + switch (menuInput) + { + case "Record Name": + valueToChange = "name"; + newValue = AnsiConsole.Ask("Change To:"); + break; + case "Date": + valueToChange = "date"; + newValue = UserInput.GetUserDate(); + break; + case "Start Time": + valueToChange = "start"; + newValue = UserInput.GetUserTime(); + record.ChangeTime(newValue, record.EndTime); + break; + case "End Time": + valueToChange = "end"; + newValue = UserInput.GetUserTime(); + record.ChangeTime(record.StartTime, newValue); + break; + case "Return": + return; + } + + AnsiConsole.WriteLine(record.Duration); + DatabaseController.EditRecord(record, valueToChange, newValue); + } + + private static void NewRecordInterface() + { + var title = "Log a Custom Coding Session!"; + CreateTitlePanel(title); + + var startTime = string.Empty; + var endTime = string.Empty; + + AnsiConsole.WriteLine("Record Name:"); + var recordName = AnsiConsole.Ask("Enter Here: "); + + CreateTitlePanel(title); + var date = UserInput.GetUserDate(); + + CreateTitlePanel(title); + startTime = UserInput.GetUserTime(); + + + while (true) + { + CreateTitlePanel(title); + endTime = UserInput.GetUserTime(); + + if(Validation.CheckValidEndTime(startTime, endTime)) + { + break; + } + } + + + CodingSession newRecord = new CodingSession(recordName, date, startTime, endTime); + DatabaseController.AddRecord(newRecord); + + MainMenu(); + } + + private static void CreateTitlePanel(string title) + { + var titlePanel = new Panel(title) + { + Border = BoxBorder.Double, + Padding = new Padding(2, 0) + }; + + AnsiConsole.Clear(); + AnsiConsole.Write(titlePanel); + } +} \ No newline at end of file diff --git a/appsettings.json b/appsettings.json new file mode 100644 index 00000000..f22f755c --- /dev/null +++ b/appsettings.json @@ -0,0 +1,6 @@ +{ + "ConnectionStrings": { + "Default": "Data Source=codeTracker.davetn657.db" + }, + "DatabasePath": "codeTracker.davetn657.db" + } \ No newline at end of file diff --git a/codeTracker.davetn657.csproj b/codeTracker.davetn657.csproj new file mode 100644 index 00000000..95548f88 --- /dev/null +++ b/codeTracker.davetn657.csproj @@ -0,0 +1,18 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + + + + diff --git a/codeTracker.davetn657.db b/codeTracker.davetn657.db new file mode 100644 index 00000000..274f7d5b Binary files /dev/null and b/codeTracker.davetn657.db differ diff --git a/codeTracker.davetn657.sln b/codeTracker.davetn657.sln new file mode 100644 index 00000000..840ae76f --- /dev/null +++ b/codeTracker.davetn657.sln @@ -0,0 +1,34 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "codeTracker.davetn657", "codeTracker.davetn657.csproj", "{FB57B162-F31D-48D7-A02E-3E1CE68BDCD2}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {FB57B162-F31D-48D7-A02E-3E1CE68BDCD2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FB57B162-F31D-48D7-A02E-3E1CE68BDCD2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FB57B162-F31D-48D7-A02E-3E1CE68BDCD2}.Debug|x64.ActiveCfg = Debug|Any CPU + {FB57B162-F31D-48D7-A02E-3E1CE68BDCD2}.Debug|x64.Build.0 = Debug|Any CPU + {FB57B162-F31D-48D7-A02E-3E1CE68BDCD2}.Debug|x86.ActiveCfg = Debug|Any CPU + {FB57B162-F31D-48D7-A02E-3E1CE68BDCD2}.Debug|x86.Build.0 = Debug|Any CPU + {FB57B162-F31D-48D7-A02E-3E1CE68BDCD2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FB57B162-F31D-48D7-A02E-3E1CE68BDCD2}.Release|Any CPU.Build.0 = Release|Any CPU + {FB57B162-F31D-48D7-A02E-3E1CE68BDCD2}.Release|x64.ActiveCfg = Release|Any CPU + {FB57B162-F31D-48D7-A02E-3E1CE68BDCD2}.Release|x64.Build.0 = Release|Any CPU + {FB57B162-F31D-48D7-A02E-3E1CE68BDCD2}.Release|x86.ActiveCfg = Release|Any CPU + {FB57B162-F31D-48D7-A02E-3E1CE68BDCD2}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal