diff --git a/.gitignore b/.gitignore index 49976e11..b6dea80a 100644 --- a/.gitignore +++ b/.gitignore @@ -478,3 +478,4 @@ $RECYCLE.BIN/ *.lnk /MathGame2 /CodingTracker.TomDonegan/TextFile1.txt +KelvinTawiah.CodingTracker/appsettings.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..1b3fdd24 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "dotnet.defaultSolution": "KelvinTawiah.slnx" +} diff --git a/KelvinTawiah.CodingTracker/CodingController.cs b/KelvinTawiah.CodingTracker/CodingController.cs new file mode 100644 index 00000000..2aec88b7 --- /dev/null +++ b/KelvinTawiah.CodingTracker/CodingController.cs @@ -0,0 +1,209 @@ +using Spectre.Console; +using KelvinTawiah.CodingTracker.model; +using KelvinTawiah.CodingTracker.service; + +namespace KelvinTawiah.CodingTracker; + +public class CodingController +{ + private readonly SessionService _sessionService; + private readonly SessionLogService _logService; + + public CodingController(SessionService sessionService, SessionLogService logService) + { + _sessionService = sessionService; + _logService = logService; + } + + public void Run() + { + while (true) + { + var choice = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("[bold blue]Coding Tracker[/]") + .PageSize(10) + .AddChoices( + "Add session", + "View all sessions", + "View session details", + "Update session", + "Delete session", + "View logs", + "Exit") + ); + + switch (choice) + { + case "Add session": + AddSession(); + break; + case "View all sessions": + ShowAllSessions(); + break; + case "View session details": + ShowSessionDetails(); + break; + case "Update session": + UpdateSession(); + break; + case "Delete session": + DeleteSession(); + break; + case "View logs": + ShowLogs(); + break; + case "Exit": + return; + } + } + } + + private void AddSession() + { + var session = UserInput.PromptForSession(); + _sessionService.AddSession(session); + _logService.AddLog(new SessionLog + { + SessionId = session.Id, + Action = "Create", + Message = "Session added", + LoggedAt = DateTime.Now + }); + + AnsiConsole.MarkupLine("[green]Session added successfully.[/]"); + WaitForKey(); + } + + private void ShowAllSessions() + { + var sessions = _sessionService.GetAllSessions(); + var table = new Table().RoundedBorder(); + table.AddColumn("Id"); + table.AddColumn("Start"); + table.AddColumn("End"); + table.AddColumn("Duration (min)"); + table.AddColumn("Notes"); + + foreach (var s in sessions) + { + table.AddRow( + s.Id.ToString(), + s.StartTime.ToString(Validation.DateFormat), + s.EndTime.ToString(Validation.DateFormat), + s.DurationMinutes.ToString(), + string.IsNullOrWhiteSpace(s.Notes) ? "-" : s.Notes + ); + } + + if (sessions.Count == 0) + { + AnsiConsole.MarkupLine("[yellow]No sessions found.[/]"); + } + else + { + AnsiConsole.Write(table); + } + + WaitForKey(); + } + + private void ShowSessionDetails() + { + var id = UserInput.PromptForSessionId("Enter session Id:"); + var session = _sessionService.GetSessionById(id); + if (session == null) + { + AnsiConsole.MarkupLine("[red]Session not found.[/]"); + WaitForKey(); + return; + } + + var panel = new Panel($"Id: {session.Id}\nStart: {session.StartTime:yyyy-MM-dd HH:mm}\nEnd: {session.EndTime:yyyy-MM-dd HH:mm}\nDuration (min): {session.DurationMinutes}\nNotes: {session.Notes}") + .Border(BoxBorder.Rounded) + .Header("Session Details", Justify.Center); + + AnsiConsole.Write(panel); + WaitForKey(); + } + + private void UpdateSession() + { + var id = UserInput.PromptForSessionId("Enter session Id:"); + var session = _sessionService.GetSessionById(id); + if (session == null) + { + AnsiConsole.MarkupLine("[red]Session not found.[/]"); + WaitForKey(); + return; + } + + var updated = UserInput.PromptForSessionUpdate(session); + _sessionService.UpdateSession(updated); + _logService.AddLog(new SessionLog + { + SessionId = updated.Id, + Action = "Update", + Message = "Session updated", + LoggedAt = DateTime.Now + }); + + AnsiConsole.MarkupLine("[green]Session updated.[/]"); + WaitForKey(); + } + + private void DeleteSession() + { + var id = UserInput.PromptForSessionId("Enter session Id:"); + _sessionService.DeleteSession(id); + _logService.AddLog(new SessionLog + { + SessionId = id, + Action = "Delete", + Message = "Session deleted", + LoggedAt = DateTime.Now + }); + + AnsiConsole.MarkupLine("[green]Session deleted.[/]"); + WaitForKey(); + } + + private void ShowLogs() + { + var logs = _logService.ViewLogs(); + var table = new Table().RoundedBorder(); + table.AddColumn("Id"); + table.AddColumn("SessionId"); + table.AddColumn("Action"); + table.AddColumn("Message"); + table.AddColumn("LoggedAt"); + + foreach (var log in logs) + { + table.AddRow( + log.Id.ToString(), + log.SessionId?.ToString() ?? "-", + log.Action, + string.IsNullOrWhiteSpace(log.Message) ? "-" : log.Message, + log.LoggedAt.ToString(Validation.DateFormat) + ); + } + + if (logs.Count == 0) + { + AnsiConsole.MarkupLine("[yellow]No logs found.[/]"); + } + else + { + AnsiConsole.Write(table); + } + + WaitForKey(); + } + + private static void WaitForKey() + { + AnsiConsole.MarkupLine("\n[grey]Press any key to continue...[/]"); + Console.ReadKey(); + } +} diff --git a/KelvinTawiah.CodingTracker/KelvinTawiah.CodingTracker.csproj b/KelvinTawiah.CodingTracker/KelvinTawiah.CodingTracker.csproj new file mode 100644 index 00000000..6d350b4c --- /dev/null +++ b/KelvinTawiah.CodingTracker/KelvinTawiah.CodingTracker.csproj @@ -0,0 +1,16 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + + diff --git a/KelvinTawiah.CodingTracker/Program.cs b/KelvinTawiah.CodingTracker/Program.cs new file mode 100644 index 00000000..11667aa9 --- /dev/null +++ b/KelvinTawiah.CodingTracker/Program.cs @@ -0,0 +1,117 @@ +using System.Text.Json; +using Microsoft.Data.SqlClient; +using KelvinTawiah.CodingTracker; +using KelvinTawiah.CodingTracker.service; + +public class Program +{ + static readonly string APP_SETTING_PATH = FindPathUpward("appsettings.json"); + static readonly string MASTER_DB_USER = "master"; + static string? DATABASE_NAME; + static string? DATABASE_PASSWORD; + static string? DATABASE_USER; + static string? DATABASE_SERVER; + static string? CONNECTION_STRING; + + + private static string FindPathUpward(string fileName) + { + string path = fileName; + + DirectoryInfo? currDir = new(Directory.GetCurrentDirectory()); + + while (currDir != null) + { + string potentialPath = Path.Combine(currDir.FullName, fileName); + + if (File.Exists(potentialPath)) return potentialPath; + currDir = currDir.Parent; + } + + return path; + } + + public static void Main(string[] args) + { + var appSettings = LoadAppSettings(); + DATABASE_NAME = appSettings.GetProperty("Database").GetProperty("Name").GetString(); + DATABASE_USER = appSettings.GetProperty("Database").GetProperty("User").GetString(); + DATABASE_PASSWORD = appSettings.GetProperty("Database").GetProperty("Password").GetString(); + DATABASE_SERVER = appSettings.GetProperty("Database").GetProperty("Server").GetString(); + + InitializeDatabase(); + CONNECTION_STRING = $"Server={DATABASE_SERVER};Database={DATABASE_NAME};User Id={DATABASE_USER};Password={DATABASE_PASSWORD};TrustServerCertificate=True"; + + var sessionService = new SessionService(CONNECTION_STRING!); + var sessionLogService = new SessionLogService(CONNECTION_STRING!); + var controller = new CodingController(sessionService, sessionLogService); + + controller.Run(); + } + + private static void InitializeDatabase() + { + var masterConnString = $"Server={DATABASE_SERVER};Database={MASTER_DB_USER};User Id={DATABASE_USER};Password={DATABASE_PASSWORD};TrustServerCertificate=True"; + var appConnString = $"Server={DATABASE_SERVER};Database={DATABASE_NAME};User Id={DATABASE_USER};Password={DATABASE_PASSWORD};TrustServerCertificate=True"; + + using (var conn = new SqlConnection(masterConnString)) + { + conn.Open(); + var cmd = conn.CreateCommand(); + cmd.CommandText = $"IF DB_ID('{DATABASE_NAME}') IS NULL CREATE DATABASE {DATABASE_NAME}"; + cmd.ExecuteNonQuery(); + } + + // Create tables + using (var conn = new SqlConnection(appConnString)) + { + conn.Open(); + var cmd = conn.CreateCommand(); + cmd.CommandText = @" + IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'CodingSessions') + BEGIN + CREATE TABLE CodingSessions ( + Id INT PRIMARY KEY IDENTITY(1,1), + StartTime DATETIME NOT NULL, + EndTime DATETIME NOT NULL, + DurationMinutes INT NOT NULL, + Notes NVARCHAR(MAX) NULL + ) + END + ELSE IF COL_LENGTH('CodingSessions', 'DurationMinutes') IS NULL + BEGIN + ALTER TABLE CodingSessions ADD DurationMinutes INT NOT NULL DEFAULT(0); + END + + IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'SessionLogs') + BEGIN + CREATE TABLE SessionLogs ( + Id INT PRIMARY KEY IDENTITY(1,1), + SessionId INT NULL, + Action NVARCHAR(100) NOT NULL, + Message NVARCHAR(MAX) NULL, + LoggedAt DATETIME NOT NULL DEFAULT(GETDATE()), + FOREIGN KEY (SessionId) REFERENCES CodingSessions(Id) + ) + END + ELSE + BEGIN + IF COL_LENGTH('SessionLogs', 'SessionId') IS NULL + ALTER TABLE SessionLogs ADD SessionId INT NULL; + IF COL_LENGTH('SessionLogs', 'Action') IS NULL + ALTER TABLE SessionLogs ADD Action NVARCHAR(100) NOT NULL DEFAULT(''); + IF COL_LENGTH('SessionLogs', 'Message') IS NULL + ALTER TABLE SessionLogs ADD Message NVARCHAR(MAX) NULL; + IF COL_LENGTH('SessionLogs', 'LoggedAt') IS NULL + ALTER TABLE SessionLogs ADD LoggedAt DATETIME NOT NULL DEFAULT(GETDATE()); + END"; + cmd.ExecuteNonQuery(); + } + } + + private static JsonElement LoadAppSettings() + { + var json = File.ReadAllText(APP_SETTING_PATH); + return JsonElement.Parse(json); + } +} \ No newline at end of file diff --git a/KelvinTawiah.CodingTracker/UserInput.cs b/KelvinTawiah.CodingTracker/UserInput.cs new file mode 100644 index 00000000..83a4ded9 --- /dev/null +++ b/KelvinTawiah.CodingTracker/UserInput.cs @@ -0,0 +1,88 @@ +using Spectre.Console; +using KelvinTawiah.CodingTracker.model; + +namespace KelvinTawiah.CodingTracker; + +public static class UserInput +{ + public static Session PromptForSession() + { + var start = PromptDate("start time"); + var end = PromptEndDate(start); + var notes = AnsiConsole.Prompt( + new TextPrompt("Notes (optional):") + .AllowEmpty() + ); + + var duration = (int)(end - start).TotalMinutes; + + return new Session + { + StartTime = start, + EndTime = end, + DurationMinutes = duration, + Notes = notes + }; + } + + public static Session PromptForSessionUpdate(Session existing) + { + var start = PromptDate("new start time", existing.StartTime); + var end = PromptEndDate(start, existing.EndTime); + var notes = AnsiConsole.Prompt( + new TextPrompt("Notes (optional):") + .DefaultValue(existing.Notes) + .AllowEmpty() + ); + + var duration = (int)(end - start).TotalMinutes; + + existing.StartTime = start; + existing.EndTime = end; + existing.DurationMinutes = duration; + existing.Notes = notes; + return existing; + } + + public static int PromptForSessionId(string message) + { + return AnsiConsole.Prompt( + new TextPrompt(message) + .Validate(id => id > 0 ? ValidationResult.Success() : ValidationResult.Error("Id must be positive")) + ); + } + + private static DateTime PromptDate(string label, DateTime? defaultValue = null) + { + while (true) + { + var prompt = new TextPrompt($"Enter {label} ({Validation.DateFormat}):") + .PromptStyle("green"); + + if (defaultValue.HasValue) + { + prompt = prompt.DefaultValue(defaultValue.Value.ToString(Validation.DateFormat)); + } + + var input = AnsiConsole.Prompt(prompt); + + if (Validation.TryParseDateTime(input, out var dt)) + { + return dt; + } + + AnsiConsole.MarkupLine("[red]Invalid format. Please use {0}[/]", Validation.DateFormat); + } + } + + private static DateTime PromptEndDate(DateTime start, DateTime? defaultValue = null) + { + while (true) + { + var end = PromptDate("end time", defaultValue); + if (Validation.IsEndAfterStart(start, end)) return end; + + AnsiConsole.MarkupLine("[red]End time must be after start time.[/]"); + } + } +} diff --git a/KelvinTawiah.CodingTracker/Validation.cs b/KelvinTawiah.CodingTracker/Validation.cs new file mode 100644 index 00000000..0c0daae7 --- /dev/null +++ b/KelvinTawiah.CodingTracker/Validation.cs @@ -0,0 +1,21 @@ +using System.Globalization; + +namespace KelvinTawiah.CodingTracker; + +public static class Validation +{ + public const string DateFormat = "yyyy-MM-dd HH:mm"; + + public static bool TryParseDateTime(string input, out DateTime value) + { + return DateTime.TryParseExact( + input, + DateFormat, + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out value + ); + } + + public static bool IsEndAfterStart(DateTime start, DateTime end) => end > start; +} diff --git a/KelvinTawiah.CodingTracker/appsettings.example.json b/KelvinTawiah.CodingTracker/appsettings.example.json new file mode 100644 index 00000000..f041a2b6 --- /dev/null +++ b/KelvinTawiah.CodingTracker/appsettings.example.json @@ -0,0 +1,8 @@ +{ + "Database": { + "Name": "", + "User": "", + "Password": "", + "Server": "" + } +} diff --git a/KelvinTawiah.CodingTracker/model/Session.cs b/KelvinTawiah.CodingTracker/model/Session.cs new file mode 100644 index 00000000..33765c37 --- /dev/null +++ b/KelvinTawiah.CodingTracker/model/Session.cs @@ -0,0 +1,10 @@ +namespace KelvinTawiah.CodingTracker.model; + +public class Session +{ + public int Id { get; set; } + public DateTime StartTime { get; set; } + public DateTime EndTime { get; set; } + public int DurationMinutes { get; set; } + public string Notes { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/KelvinTawiah.CodingTracker/model/SessionLog.cs b/KelvinTawiah.CodingTracker/model/SessionLog.cs new file mode 100644 index 00000000..5c4c6f0a --- /dev/null +++ b/KelvinTawiah.CodingTracker/model/SessionLog.cs @@ -0,0 +1,10 @@ +namespace KelvinTawiah.CodingTracker.model; + +public class SessionLog +{ + public int Id { get; set; } + public int? SessionId { get; set; } + public string Action { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; + public DateTime LoggedAt { get; set; } +} \ No newline at end of file diff --git a/KelvinTawiah.CodingTracker/repository/SessionLogRepository.cs b/KelvinTawiah.CodingTracker/repository/SessionLogRepository.cs new file mode 100644 index 00000000..1b61d292 --- /dev/null +++ b/KelvinTawiah.CodingTracker/repository/SessionLogRepository.cs @@ -0,0 +1,53 @@ +using Microsoft.Data.SqlClient; +using Dapper; +using KelvinTawiah.CodingTracker.model; + +namespace KelvinTawiah.CodingTracker.repository; + +public class SessionLogRepository +{ + private readonly string _connectionString; + + public SessionLogRepository(string connectionString) + { + _connectionString = connectionString; + } + + public void Add(SessionLog log) + { + using var connection = new SqlConnection(_connectionString); + + var query = @" + INSERT INTO SessionLogs (SessionId, Action, Message, LoggedAt) + VALUES (@SessionId, @Action, @Message, @LoggedAt)"; + + connection.Execute(query, log); + } + + public void Clear() + { + using var connection = new SqlConnection(_connectionString); + + var query = "DELETE FROM SessionLogs"; + + connection.Execute(query); + } + + public List View() + { + using var connection = new SqlConnection(_connectionString); + + var query = "SELECT * FROM SessionLogs ORDER BY LoggedAt DESC"; + + return connection.Query(query).ToList(); + } + + public List FindBy(int id) + { + using var connection = new SqlConnection(_connectionString); + + var query = "SELECT * FROM SessionLogs WHERE SessionId = @Id ORDER BY LoggedAt DESC"; + + return connection.Query(query, new { Id = id }).ToList(); + } +} \ No newline at end of file diff --git a/KelvinTawiah.CodingTracker/repository/SessionRepository.cs b/KelvinTawiah.CodingTracker/repository/SessionRepository.cs new file mode 100644 index 00000000..1d226146 --- /dev/null +++ b/KelvinTawiah.CodingTracker/repository/SessionRepository.cs @@ -0,0 +1,69 @@ +using Microsoft.Data.SqlClient; +using Dapper; +using KelvinTawiah.CodingTracker.model; + +namespace KelvinTawiah.CodingTracker.repository; + +public class SessionRepository +{ + private readonly string _connectionString; + + public SessionRepository(string connectionString) + { + _connectionString = connectionString; + } + + public int Create(Session session) + { + using var connection = new SqlConnection(_connectionString); + + var query = @" + INSERT INTO CodingSessions (StartTime, EndTime, DurationMinutes, Notes) + OUTPUT INSERTED.Id + VALUES (@StartTime, @EndTime, @DurationMinutes, @Notes)"; + + return connection.ExecuteScalar(query, session); + } + + public Session? FindById(int id) + { + using var connection = new SqlConnection(_connectionString); + + var query = "SELECT * FROM CodingSessions WHERE Id = @Id"; + + return connection.QueryFirstOrDefault(query, new { Id = id }); + } + + public void Update(Session session) + { + using var connection = new SqlConnection(_connectionString); + + var query = @" + UPDATE CodingSessions + SET StartTime = @StartTime, + EndTime = @EndTime, + DurationMinutes = @DurationMinutes, + Notes = @Notes + WHERE Id = @Id"; + + connection.Execute(query, session); + } + + public List GetAll() + { + using var connection = new SqlConnection(_connectionString); + + var query = "SELECT * FROM CodingSessions"; + + return connection.Query(query).ToList(); + } + + public void Delete(int id) + { + using var connection = new SqlConnection(_connectionString); + + var query = "DELETE FROM CodingSessions WHERE Id = @Id"; + + connection.Execute(query, new { Id = id }); + } +} \ No newline at end of file diff --git a/KelvinTawiah.CodingTracker/service/SessionLogService.cs b/KelvinTawiah.CodingTracker/service/SessionLogService.cs new file mode 100644 index 00000000..a93f4883 --- /dev/null +++ b/KelvinTawiah.CodingTracker/service/SessionLogService.cs @@ -0,0 +1,34 @@ +using KelvinTawiah.CodingTracker.model; +using KelvinTawiah.CodingTracker.repository; + +namespace KelvinTawiah.CodingTracker.service; + +public class SessionLogService +{ + private readonly SessionLogRepository _repository; + + public SessionLogService(string connectionString) + { + _repository = new SessionLogRepository(connectionString); + } + + public void AddLog(SessionLog log) + { + _repository.Add(log); + } + + public void ClearLog() + { + _repository.Clear(); + } + + public List ViewLogs() + { + return _repository.View(); + } + + public List ViewLogsFor(int id) + { + return _repository.FindBy(id); + } +} \ No newline at end of file diff --git a/KelvinTawiah.CodingTracker/service/SessionService.cs b/KelvinTawiah.CodingTracker/service/SessionService.cs new file mode 100644 index 00000000..d3f5fc3e --- /dev/null +++ b/KelvinTawiah.CodingTracker/service/SessionService.cs @@ -0,0 +1,42 @@ +using KelvinTawiah.CodingTracker.model; +using KelvinTawiah.CodingTracker.repository; + +namespace KelvinTawiah.CodingTracker.service; + +public class SessionService +{ + private readonly SessionRepository _repository; + + public SessionService(string connectionString) + { + _repository = new SessionRepository(connectionString); + } + + public void AddSession(Session session) + { + session.DurationMinutes = (int)(session.EndTime - session.StartTime).TotalMinutes; + var id = _repository.Create(session); + session.Id = id; + } + + public Session? GetSessionById(int id) + { + return _repository.FindById(id); + } + + public void UpdateSession(Session session) + { + session.DurationMinutes = (int)(session.EndTime - session.StartTime).TotalMinutes; + _repository.Update(session); + } + + public List GetAllSessions() + { + return _repository.GetAll(); + } + + public void DeleteSession(int id) + { + _repository.Delete(id); + } +} \ No newline at end of file diff --git a/KelvinTawiah.slnx b/KelvinTawiah.slnx new file mode 100644 index 00000000..d26ca584 --- /dev/null +++ b/KelvinTawiah.slnx @@ -0,0 +1,3 @@ + + + diff --git a/README.md b/README.md new file mode 100644 index 00000000..6c9946d6 --- /dev/null +++ b/README.md @@ -0,0 +1,21 @@ +# Coding Tracker (Dapper + Spectre.Console) + +A console app to log coding sessions using Dapper for data access and Spectre.Console for the UI. + +## Requirements coverage +- **Dapper ORM** for all data access (repositories). +- **Spectre.Console** menus, prompts, and tables for display. +- **Separate classes**: `CodingController`, `UserInput`, `Validation`, services, and repositories. +- **Strict datetime format**: `yyyy-MM-dd HH:mm`; validated on input. +- **Duration computed** automatically from start/end; not user-entered. +- **Manual start/end entry** enforced; both required. +- **Configuration** via `appsettings.json` for database settings. + +## Running +1. Ensure SQL Server is accessible per `KelvinTawiah.CodingTracker/appsettings.json`. +2. From `KelvinTawiah.CodingTracker/`: `dotnet run` +3. Follow the Spectre.Console menu to add or manage sessions. + +## Notes +- Database/tables are created on first run (with duration and logging columns). +- Session logs capture create/update/delete actions with timestamps for auditability.