From 40148bd01881cbd09046656726439604c3393edc Mon Sep 17 00:00:00 2001 From: Ayudh-M <61803034+Ayudh-M@users.noreply.github.com> Date: Tue, 26 Aug 2025 17:39:58 +0200 Subject: [PATCH] Add initial Timeboxer planner with CLI, domain models, and tests --- .editorconfig | 8 ++ .github/workflows/ci.yml | 22 ++++++ .gitignore | 7 ++ Directory.Packages.props | 16 ++++ LICENSE | 21 +++++ README.md | 16 +++- Timeboxer.sln | 64 ++++++++++++++++ build.ps1 | 3 + build.sh | 5 ++ src/Timeboxer.Cli/CliOptions.cs | 3 + src/Timeboxer.Cli/Commands/DemoCommand.cs | 38 ++++++++++ .../Commands/ImportGoogleCommand.cs | 29 +++++++ src/Timeboxer.Cli/Commands/PlanCommand.cs | 48 ++++++++++++ .../Commands/SyncGoogleCommand.cs | 18 +++++ src/Timeboxer.Cli/Commands/ValidateCommand.cs | 19 +++++ src/Timeboxer.Cli/Program.cs | 11 +++ src/Timeboxer.Cli/Timeboxer.Cli.csproj | 18 +++++ .../Abstractions/ICalendarProvider.cs | 10 +++ src/Timeboxer.Domain/Abstractions/IClock.cs | 6 ++ src/Timeboxer.Domain/Export/ConsolePrinter.cs | 17 +++++ src/Timeboxer.Domain/Export/IcsExporter.cs | 27 +++++++ src/Timeboxer.Domain/Models/CalendarEvent.cs | 11 +++ src/Timeboxer.Domain/Models/Enums.cs | 5 ++ src/Timeboxer.Domain/Models/Settings.cs | 19 +++++ src/Timeboxer.Domain/Models/TaskItem.cs | 15 ++++ src/Timeboxer.Domain/Models/Timebox.cs | 10 +++ src/Timeboxer.Domain/Models/WorkWindow.cs | 3 + .../Scheduling/FreeWindowBuilder.cs | 49 ++++++++++++ src/Timeboxer.Domain/Scheduling/Heuristics.cs | 15 ++++ src/Timeboxer.Domain/Scheduling/Planner.cs | 76 +++++++++++++++++++ .../Scheduling/TagBatching.cs | 11 +++ src/Timeboxer.Domain/Scheduling/Validation.cs | 33 ++++++++ src/Timeboxer.Domain/Timeboxer.Domain.csproj | 9 +++ .../Utils/DurationExtensions.cs | 6 ++ .../GoogleAuth.cs | 6 ++ .../GoogleCalendarProvider.cs | 19 +++++ .../GoogleMappers.cs | 6 ++ .../Timeboxer.Infrastructure.Google.csproj | 13 ++++ .../appsettings.sample.json | 3 + .../docs/GOOGLE_SETUP.md | 3 + src/Timeboxer.Persistence/JsonStore.cs | 29 +++++++ src/Timeboxer.Persistence/Paths.cs | 6 ++ .../Timeboxer.Persistence.csproj | 10 +++ .../DeadlineFeasibilityTests.cs | 28 +++++++ .../Fixtures/expected_plan_day1.txt | 2 + .../Fixtures/expected_plan_week.ics | 2 + .../Fixtures/sample_events.json | 4 + .../Fixtures/sample_settings.json | 18 +++++ .../Fixtures/sample_tasks.json | 7 ++ .../FreeWindowBuilderTests.cs | 22 ++++++ .../HeuristicOrderingTests.cs | 23 ++++++ .../PlannerCoreTests.cs | 32 ++++++++ .../TagBatchingTests.cs | 23 ++++++ .../Timeboxer.Domain.Tests.csproj | 22 ++++++ .../Fakes/FakeCalendarProvider.cs | 14 ++++ .../Fakes/FakeClock.cs | 8 ++ .../GoogleProviderMockTests.cs | 18 +++++ .../Timeboxer.Infrastructure.Tests.csproj | 18 +++++ 58 files changed, 1003 insertions(+), 1 deletion(-) create mode 100644 .editorconfig create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 Directory.Packages.props create mode 100644 LICENSE create mode 100644 Timeboxer.sln create mode 100644 build.ps1 create mode 100755 build.sh create mode 100644 src/Timeboxer.Cli/CliOptions.cs create mode 100644 src/Timeboxer.Cli/Commands/DemoCommand.cs create mode 100644 src/Timeboxer.Cli/Commands/ImportGoogleCommand.cs create mode 100644 src/Timeboxer.Cli/Commands/PlanCommand.cs create mode 100644 src/Timeboxer.Cli/Commands/SyncGoogleCommand.cs create mode 100644 src/Timeboxer.Cli/Commands/ValidateCommand.cs create mode 100644 src/Timeboxer.Cli/Program.cs create mode 100644 src/Timeboxer.Cli/Timeboxer.Cli.csproj create mode 100644 src/Timeboxer.Domain/Abstractions/ICalendarProvider.cs create mode 100644 src/Timeboxer.Domain/Abstractions/IClock.cs create mode 100644 src/Timeboxer.Domain/Export/ConsolePrinter.cs create mode 100644 src/Timeboxer.Domain/Export/IcsExporter.cs create mode 100644 src/Timeboxer.Domain/Models/CalendarEvent.cs create mode 100644 src/Timeboxer.Domain/Models/Enums.cs create mode 100644 src/Timeboxer.Domain/Models/Settings.cs create mode 100644 src/Timeboxer.Domain/Models/TaskItem.cs create mode 100644 src/Timeboxer.Domain/Models/Timebox.cs create mode 100644 src/Timeboxer.Domain/Models/WorkWindow.cs create mode 100644 src/Timeboxer.Domain/Scheduling/FreeWindowBuilder.cs create mode 100644 src/Timeboxer.Domain/Scheduling/Heuristics.cs create mode 100644 src/Timeboxer.Domain/Scheduling/Planner.cs create mode 100644 src/Timeboxer.Domain/Scheduling/TagBatching.cs create mode 100644 src/Timeboxer.Domain/Scheduling/Validation.cs create mode 100644 src/Timeboxer.Domain/Timeboxer.Domain.csproj create mode 100644 src/Timeboxer.Domain/Utils/DurationExtensions.cs create mode 100644 src/Timeboxer.Infrastructure.Google/GoogleAuth.cs create mode 100644 src/Timeboxer.Infrastructure.Google/GoogleCalendarProvider.cs create mode 100644 src/Timeboxer.Infrastructure.Google/GoogleMappers.cs create mode 100644 src/Timeboxer.Infrastructure.Google/Timeboxer.Infrastructure.Google.csproj create mode 100644 src/Timeboxer.Infrastructure.Google/appsettings.sample.json create mode 100644 src/Timeboxer.Infrastructure.Google/docs/GOOGLE_SETUP.md create mode 100644 src/Timeboxer.Persistence/JsonStore.cs create mode 100644 src/Timeboxer.Persistence/Paths.cs create mode 100644 src/Timeboxer.Persistence/Timeboxer.Persistence.csproj create mode 100644 tests/Timeboxer.Domain.Tests/DeadlineFeasibilityTests.cs create mode 100644 tests/Timeboxer.Domain.Tests/Fixtures/expected_plan_day1.txt create mode 100644 tests/Timeboxer.Domain.Tests/Fixtures/expected_plan_week.ics create mode 100644 tests/Timeboxer.Domain.Tests/Fixtures/sample_events.json create mode 100644 tests/Timeboxer.Domain.Tests/Fixtures/sample_settings.json create mode 100644 tests/Timeboxer.Domain.Tests/Fixtures/sample_tasks.json create mode 100644 tests/Timeboxer.Domain.Tests/FreeWindowBuilderTests.cs create mode 100644 tests/Timeboxer.Domain.Tests/HeuristicOrderingTests.cs create mode 100644 tests/Timeboxer.Domain.Tests/PlannerCoreTests.cs create mode 100644 tests/Timeboxer.Domain.Tests/TagBatchingTests.cs create mode 100644 tests/Timeboxer.Domain.Tests/Timeboxer.Domain.Tests.csproj create mode 100644 tests/Timeboxer.Infrastructure.Tests/Fakes/FakeCalendarProvider.cs create mode 100644 tests/Timeboxer.Infrastructure.Tests/Fakes/FakeClock.cs create mode 100644 tests/Timeboxer.Infrastructure.Tests/GoogleProviderMockTests.cs create mode 100644 tests/Timeboxer.Infrastructure.Tests/Timeboxer.Infrastructure.Tests.csproj diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..cdabf51 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,8 @@ +root = true + +[*.cs] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +insert_final_newline = true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..dd3d367 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,22 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-dotnet@v3 + with: + dotnet-version: '8.0.x' + - run: dotnet restore + - run: dotnet build -c Release + - run: dotnet test -c Release --no-build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..83fedb9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +bin/ +obj/ +artifacts/ +*.user +*.suo +*.swp +.vscode/ diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..8a82027 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,16 @@ + + + true + + + + + + + + + + + + + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c13f991 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 97acd0c..eabf453 100644 --- a/README.md +++ b/README.md @@ -1 +1,15 @@ -# taskblocker \ No newline at end of file +# Timeboxer + +A Windows-first, console-only timeboxing planner implemented in .NET 8. + +## Build + +```bash +./build.sh +``` + +## Demo + +```bash +dotnet run --project src/Timeboxer.Cli demo +``` diff --git a/Timeboxer.sln b/Timeboxer.sln new file mode 100644 index 0000000..e7c175a --- /dev/null +++ b/Timeboxer.sln @@ -0,0 +1,64 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{BA0DFA50-DF37-4D29-924E-5BD0DE4564B5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Timeboxer.Domain", "src\Timeboxer.Domain\Timeboxer.Domain.csproj", "{70A17E4B-CE06-40FE-84E1-0E20C226B9F0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Timeboxer.Cli", "src\Timeboxer.Cli\Timeboxer.Cli.csproj", "{9AA19A29-D4C5-4438-800A-F32E1E63E52A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Timeboxer.Infrastructure.Google", "src\Timeboxer.Infrastructure.Google\Timeboxer.Infrastructure.Google.csproj", "{7C7062D5-352C-4DF6-83B3-DCA9F982CAF4}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Timeboxer.Persistence", "src\Timeboxer.Persistence\Timeboxer.Persistence.csproj", "{B44C9688-D703-4525-A300-4D8441E17E4F}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{4F3BF0C0-D79B-4610-A361-7173F76A4ACA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Timeboxer.Domain.Tests", "tests\Timeboxer.Domain.Tests\Timeboxer.Domain.Tests.csproj", "{5936E8F7-7330-4A19-B7C2-EC7AD88B2CD8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Timeboxer.Infrastructure.Tests", "tests\Timeboxer.Infrastructure.Tests\Timeboxer.Infrastructure.Tests.csproj", "{C7A550FA-1882-4E36-AC3C-EBF37ADD6BB0}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {70A17E4B-CE06-40FE-84E1-0E20C226B9F0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {70A17E4B-CE06-40FE-84E1-0E20C226B9F0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {70A17E4B-CE06-40FE-84E1-0E20C226B9F0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {70A17E4B-CE06-40FE-84E1-0E20C226B9F0}.Release|Any CPU.Build.0 = Release|Any CPU + {9AA19A29-D4C5-4438-800A-F32E1E63E52A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9AA19A29-D4C5-4438-800A-F32E1E63E52A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9AA19A29-D4C5-4438-800A-F32E1E63E52A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9AA19A29-D4C5-4438-800A-F32E1E63E52A}.Release|Any CPU.Build.0 = Release|Any CPU + {7C7062D5-352C-4DF6-83B3-DCA9F982CAF4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7C7062D5-352C-4DF6-83B3-DCA9F982CAF4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7C7062D5-352C-4DF6-83B3-DCA9F982CAF4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7C7062D5-352C-4DF6-83B3-DCA9F982CAF4}.Release|Any CPU.Build.0 = Release|Any CPU + {B44C9688-D703-4525-A300-4D8441E17E4F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B44C9688-D703-4525-A300-4D8441E17E4F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B44C9688-D703-4525-A300-4D8441E17E4F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B44C9688-D703-4525-A300-4D8441E17E4F}.Release|Any CPU.Build.0 = Release|Any CPU + {5936E8F7-7330-4A19-B7C2-EC7AD88B2CD8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5936E8F7-7330-4A19-B7C2-EC7AD88B2CD8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5936E8F7-7330-4A19-B7C2-EC7AD88B2CD8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5936E8F7-7330-4A19-B7C2-EC7AD88B2CD8}.Release|Any CPU.Build.0 = Release|Any CPU + {C7A550FA-1882-4E36-AC3C-EBF37ADD6BB0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C7A550FA-1882-4E36-AC3C-EBF37ADD6BB0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C7A550FA-1882-4E36-AC3C-EBF37ADD6BB0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C7A550FA-1882-4E36-AC3C-EBF37ADD6BB0}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {70A17E4B-CE06-40FE-84E1-0E20C226B9F0} = {BA0DFA50-DF37-4D29-924E-5BD0DE4564B5} + {9AA19A29-D4C5-4438-800A-F32E1E63E52A} = {BA0DFA50-DF37-4D29-924E-5BD0DE4564B5} + {7C7062D5-352C-4DF6-83B3-DCA9F982CAF4} = {BA0DFA50-DF37-4D29-924E-5BD0DE4564B5} + {B44C9688-D703-4525-A300-4D8441E17E4F} = {BA0DFA50-DF37-4D29-924E-5BD0DE4564B5} + {5936E8F7-7330-4A19-B7C2-EC7AD88B2CD8} = {4F3BF0C0-D79B-4610-A361-7173F76A4ACA} + {C7A550FA-1882-4E36-AC3C-EBF37ADD6BB0} = {4F3BF0C0-D79B-4610-A361-7173F76A4ACA} + EndGlobalSection +EndGlobal diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..0c44dba --- /dev/null +++ b/build.ps1 @@ -0,0 +1,3 @@ +Dotnet restore +Dotnet build -c Release +Dotnet test -c Release diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..b92887f --- /dev/null +++ b/build.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -e +dotnet restore +dotnet build -c Release +dotnet test -c Release diff --git a/src/Timeboxer.Cli/CliOptions.cs b/src/Timeboxer.Cli/CliOptions.cs new file mode 100644 index 0000000..8b4aba8 --- /dev/null +++ b/src/Timeboxer.Cli/CliOptions.cs @@ -0,0 +1,3 @@ +namespace Timeboxer.Cli; + +public class CliOptions { } diff --git a/src/Timeboxer.Cli/Commands/DemoCommand.cs b/src/Timeboxer.Cli/Commands/DemoCommand.cs new file mode 100644 index 0000000..587fbf4 --- /dev/null +++ b/src/Timeboxer.Cli/Commands/DemoCommand.cs @@ -0,0 +1,38 @@ +using System.CommandLine; +using Timeboxer.Domain.Models; +using Timeboxer.Domain.Scheduling; +using Timeboxer.Domain.Export; + +namespace Timeboxer.Cli.Commands; + +public static class DemoCommand +{ + public static Command Create() + { + var cmd = new Command("demo", "Run demo planner"); + cmd.SetHandler(() => + { + var settings = new Settings + { + WorkWindows = new Dictionary> + { + [DayOfWeek.Monday] = new() { (TimeSpan.FromHours(8.5), TimeSpan.FromHours(17)) }, + [DayOfWeek.Tuesday] = new() { (TimeSpan.FromHours(8.5), TimeSpan.FromHours(17)) }, + [DayOfWeek.Wednesday] = new() { (TimeSpan.FromHours(8.5), TimeSpan.FromHours(17)) }, + [DayOfWeek.Thursday] = new() { (TimeSpan.FromHours(8.5), TimeSpan.FromHours(17)) }, + [DayOfWeek.Friday] = new() { (TimeSpan.FromHours(8.5), TimeSpan.FromHours(17)) } + } + }; + var tasks = new List + { + new TaskItem{ Id="T1", Title="Demo Task", EstimateMinutes=60, Deadline=DateTimeOffset.Now.AddDays(2), Priority=Priority.P1, Difficulty=Difficulty.Med, Tags=new(){"demo"}} + }; + var events = new List(); + var planner = new Planner(settings); + var today = DateOnly.FromDateTime(DateTime.Now.Date); + var result = planner.Plan(tasks, events, today, today.AddDays(5)); + Console.WriteLine(ConsolePrinter.Render(result.Timeboxes)); + }); + return cmd; + } +} diff --git a/src/Timeboxer.Cli/Commands/ImportGoogleCommand.cs b/src/Timeboxer.Cli/Commands/ImportGoogleCommand.cs new file mode 100644 index 0000000..b932145 --- /dev/null +++ b/src/Timeboxer.Cli/Commands/ImportGoogleCommand.cs @@ -0,0 +1,29 @@ +using System.CommandLine; +using Timeboxer.Domain.Abstractions; +using Timeboxer.Infrastructure.Google; +using Timeboxer.Persistence; +using Timeboxer.Domain.Models; + +namespace Timeboxer.Cli.Commands; + +public static class ImportGoogleCommand +{ + public static Command Create() + { + var cmd = new Command("import-google", "Import Google Calendar events"); + var fromOpt = new Option("--from") { IsRequired = true }; + var toOpt = new Option("--to") { IsRequired = true }; + var outOpt = new Option("--out") { IsRequired = true }; + cmd.AddOption(fromOpt); + cmd.AddOption(toOpt); + cmd.AddOption(outOpt); + cmd.SetHandler(async (DateTime from, DateTime to, string outPath) => + { + ICalendarProvider provider = new GoogleCalendarProvider(); + var events = await provider.GetFixedEventsAsync(from, to, CancellationToken.None); + await JsonStore.SaveAsync(outPath, events); + Console.WriteLine($"Saved {events.Count} events."); + }, fromOpt, toOpt, outOpt); + return cmd; + } +} diff --git a/src/Timeboxer.Cli/Commands/PlanCommand.cs b/src/Timeboxer.Cli/Commands/PlanCommand.cs new file mode 100644 index 0000000..0ac91c9 --- /dev/null +++ b/src/Timeboxer.Cli/Commands/PlanCommand.cs @@ -0,0 +1,48 @@ +using System.CommandLine; +using Timeboxer.Domain.Models; +using Timeboxer.Domain.Scheduling; +using Timeboxer.Domain.Export; +using Timeboxer.Persistence; + +namespace Timeboxer.Cli.Commands; + +public static class PlanCommand +{ + public static Command Create() + { + var cmd = new Command("plan", "Plan tasks"); + var tasksOpt = new Option("--tasks") { IsRequired = true }; + var eventsOpt = new Option("--events") { IsRequired = true }; + var settingsOpt = new Option("--settings") { IsRequired = true }; + var fromOpt = new Option("--from") { IsRequired = true }; + var toOpt = new Option("--to") { IsRequired = true }; + var icsOpt = new Option("--out-ics", () => string.Empty); + var textOpt = new Option("--out-text", () => string.Empty); + cmd.AddOption(tasksOpt); + cmd.AddOption(eventsOpt); + cmd.AddOption(settingsOpt); + cmd.AddOption(fromOpt); + cmd.AddOption(toOpt); + cmd.AddOption(icsOpt); + cmd.AddOption(textOpt); + + cmd.SetHandler(async (string tasksPath, string eventsPath, string settingsPath, DateTime from, DateTime to, string ics, string text) => + { + var tasks = await JsonStore.LoadAsync>(tasksPath); + var events = await JsonStore.LoadAsync>(eventsPath); + var settings = await JsonStore.LoadAsync(settingsPath); + var planner = new Planner(settings); + var result = planner.Plan(tasks, events, DateOnly.FromDateTime(from), DateOnly.FromDateTime(to)); + var summary = ConsolePrinter.Render(result.Timeboxes); + Console.WriteLine(summary); + if (!string.IsNullOrWhiteSpace(ics)) + await File.WriteAllTextAsync(ics, IcsExporter.Export(result.Timeboxes)); + if (!string.IsNullOrWhiteSpace(text)) + await File.WriteAllTextAsync(text, summary); + if (result.Overflow.Any()) + Environment.ExitCode = 2; + }, tasksOpt, eventsOpt, settingsOpt, fromOpt, toOpt, icsOpt, textOpt); + + return cmd; + } +} diff --git a/src/Timeboxer.Cli/Commands/SyncGoogleCommand.cs b/src/Timeboxer.Cli/Commands/SyncGoogleCommand.cs new file mode 100644 index 0000000..9f72533 --- /dev/null +++ b/src/Timeboxer.Cli/Commands/SyncGoogleCommand.cs @@ -0,0 +1,18 @@ +using System.CommandLine; + +namespace Timeboxer.Cli.Commands; + +public static class SyncGoogleCommand +{ + public static Command Create() + { + var cmd = new Command("sync-google", "Sync plan to Google Calendar"); + var icsOpt = new Option("--ics") { IsRequired = true }; + cmd.AddOption(icsOpt); + cmd.SetHandler((string _) => + { + Console.WriteLine("Sync not implemented in this sample."); + }, icsOpt); + return cmd; + } +} diff --git a/src/Timeboxer.Cli/Commands/ValidateCommand.cs b/src/Timeboxer.Cli/Commands/ValidateCommand.cs new file mode 100644 index 0000000..0ea8df6 --- /dev/null +++ b/src/Timeboxer.Cli/Commands/ValidateCommand.cs @@ -0,0 +1,19 @@ +using System.CommandLine; + +namespace Timeboxer.Cli.Commands; + +public static class ValidateCommand +{ + public static Command Create() + { + var cmd = new Command("validate", "Validate a generated plan"); + var planOpt = new Option("--plan") { IsRequired = true }; + cmd.AddOption(planOpt); + cmd.SetHandler(async (string planPath) => + { + var text = await File.ReadAllTextAsync(planPath); + Console.WriteLine($"Plan size {text.Length} chars"); + }, planOpt); + return cmd; + } +} diff --git a/src/Timeboxer.Cli/Program.cs b/src/Timeboxer.Cli/Program.cs new file mode 100644 index 0000000..111dd6d --- /dev/null +++ b/src/Timeboxer.Cli/Program.cs @@ -0,0 +1,11 @@ +using System.CommandLine; +using Timeboxer.Cli.Commands; + +var root = new RootCommand("Timeboxer CLI"); +root.AddCommand(PlanCommand.Create()); +root.AddCommand(DemoCommand.Create()); +root.AddCommand(ImportGoogleCommand.Create()); +root.AddCommand(SyncGoogleCommand.Create()); +root.AddCommand(ValidateCommand.Create()); + +return await root.InvokeAsync(args); diff --git a/src/Timeboxer.Cli/Timeboxer.Cli.csproj b/src/Timeboxer.Cli/Timeboxer.Cli.csproj new file mode 100644 index 0000000..ff97392 --- /dev/null +++ b/src/Timeboxer.Cli/Timeboxer.Cli.csproj @@ -0,0 +1,18 @@ + + + Exe + net8.0 + enable + enable + + + + + + + + + + + + diff --git a/src/Timeboxer.Domain/Abstractions/ICalendarProvider.cs b/src/Timeboxer.Domain/Abstractions/ICalendarProvider.cs new file mode 100644 index 0000000..fcbfb66 --- /dev/null +++ b/src/Timeboxer.Domain/Abstractions/ICalendarProvider.cs @@ -0,0 +1,10 @@ +using Timeboxer.Domain.Models; + +namespace Timeboxer.Domain.Abstractions; + +public interface ICalendarProvider +{ + Task> GetFixedEventsAsync(DateTimeOffset startInclusive, DateTimeOffset endExclusive, CancellationToken ct); + Task UpsertTimeboxesAsync(IReadOnlyList timeboxes, CancellationToken ct); + string ProviderName { get; } +} diff --git a/src/Timeboxer.Domain/Abstractions/IClock.cs b/src/Timeboxer.Domain/Abstractions/IClock.cs new file mode 100644 index 0000000..80d5659 --- /dev/null +++ b/src/Timeboxer.Domain/Abstractions/IClock.cs @@ -0,0 +1,6 @@ +namespace Timeboxer.Domain.Abstractions; + +public interface IClock +{ + DateTimeOffset Now { get; } +} diff --git a/src/Timeboxer.Domain/Export/ConsolePrinter.cs b/src/Timeboxer.Domain/Export/ConsolePrinter.cs new file mode 100644 index 0000000..a933afa --- /dev/null +++ b/src/Timeboxer.Domain/Export/ConsolePrinter.cs @@ -0,0 +1,17 @@ +using System.Text; +using Timeboxer.Domain.Models; + +namespace Timeboxer.Domain.Export; + +public static class ConsolePrinter +{ + public static string Render(IEnumerable boxes) + { + var sb = new StringBuilder(); + foreach (var tb in boxes.OrderBy(b => b.Start)) + { + sb.AppendLine($"{tb.Start:yyyy-MM-dd HH:mm} - {tb.End:HH:mm} | {tb.Kind} | {tb.TaskId ?? tb.Kind.ToString()} {tb.PrimaryTag}"); + } + return sb.ToString(); + } +} diff --git a/src/Timeboxer.Domain/Export/IcsExporter.cs b/src/Timeboxer.Domain/Export/IcsExporter.cs new file mode 100644 index 0000000..ea89c90 --- /dev/null +++ b/src/Timeboxer.Domain/Export/IcsExporter.cs @@ -0,0 +1,27 @@ +using System.Text; +using Timeboxer.Domain.Models; + +namespace Timeboxer.Domain.Export; + +public static class IcsExporter +{ + public static string Export(IEnumerable boxes) + { + var sb = new StringBuilder(); + sb.AppendLine("BEGIN:VCALENDAR"); + sb.AppendLine("VERSION:2.0"); + sb.AppendLine("PRODID:-//Timeboxer//EN"); + foreach (var tb in boxes) + { + sb.AppendLine("BEGIN:VEVENT"); + sb.AppendLine($"UID:{tb.Id}"); + sb.AppendLine($"DTSTART:{tb.Start.UtcDateTime:yyyyMMdd'T'HHmmss'Z'}"); + sb.AppendLine($"DTEND:{tb.End.UtcDateTime:yyyyMMdd'T'HHmmss'Z'}"); + var summary = tb.Kind == TimeboxKind.Work ? $"[WORK] {tb.PrimaryTag}" : $"[{tb.Kind}]"; + sb.AppendLine($"SUMMARY:{summary}"); + sb.AppendLine("END:VEVENT"); + } + sb.AppendLine("END:VCALENDAR"); + return sb.ToString(); + } +} diff --git a/src/Timeboxer.Domain/Models/CalendarEvent.cs b/src/Timeboxer.Domain/Models/CalendarEvent.cs new file mode 100644 index 0000000..df411bd --- /dev/null +++ b/src/Timeboxer.Domain/Models/CalendarEvent.cs @@ -0,0 +1,11 @@ +namespace Timeboxer.Domain.Models; + +public record CalendarEvent( + string Id, + string Title, + DateTimeOffset Start, + DateTimeOffset End, + bool IsAllDay, + string? Location, + bool IsBusy, + string Source); diff --git a/src/Timeboxer.Domain/Models/Enums.cs b/src/Timeboxer.Domain/Models/Enums.cs new file mode 100644 index 0000000..1ccffea --- /dev/null +++ b/src/Timeboxer.Domain/Models/Enums.cs @@ -0,0 +1,5 @@ +namespace Timeboxer.Domain.Models; + +public enum Priority { P1 = 0, P2 = 1, P3 = 2, P4 = 3 } +public enum Difficulty { Easy = 0, Med = 1, Hard = 2, VHard = 3 } +public enum TimeboxKind { Work, Break, Ritual, Buffer, WindDown, BoredSilence } diff --git a/src/Timeboxer.Domain/Models/Settings.cs b/src/Timeboxer.Domain/Models/Settings.cs new file mode 100644 index 0000000..ce3b8c4 --- /dev/null +++ b/src/Timeboxer.Domain/Models/Settings.cs @@ -0,0 +1,19 @@ +namespace Timeboxer.Domain.Models; + +public class Settings +{ + public Dictionary> WorkWindows { get; set; } = new(); + public int MaxTimeboxMinutes { get; set; } = 50; + public int MinBreakMinutes { get; set; } = 10; + public int DailyBufferPercent { get; set; } = 15; + public Dictionary Contingency { get; set; } = new() + { + [Difficulty.Easy] = 0, + [Difficulty.Med] = 10, + [Difficulty.Hard] = 25, + [Difficulty.VHard] = 50 + }; + public bool TagClusteringEnabled { get; set; } = true; + public string Timezone { get; set; } = "Europe/Amsterdam"; + public bool WritebackToGoogle { get; set; } = false; +} diff --git a/src/Timeboxer.Domain/Models/TaskItem.cs b/src/Timeboxer.Domain/Models/TaskItem.cs new file mode 100644 index 0000000..7f7ac58 --- /dev/null +++ b/src/Timeboxer.Domain/Models/TaskItem.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace Timeboxer.Domain.Models; + +public class TaskItem +{ + public string Id { get; set; } = string.Empty; + public string Title { get; set; } = string.Empty; + public int EstimateMinutes { get; set; } + public DateTimeOffset Deadline { get; set; } + public Priority Priority { get; set; } + public Difficulty Difficulty { get; set; } + public List Tags { get; set; } = new(); + public string? Notes { get; set; } +} diff --git a/src/Timeboxer.Domain/Models/Timebox.cs b/src/Timeboxer.Domain/Models/Timebox.cs new file mode 100644 index 0000000..7ed8e58 --- /dev/null +++ b/src/Timeboxer.Domain/Models/Timebox.cs @@ -0,0 +1,10 @@ +namespace Timeboxer.Domain.Models; + +public record Timebox( + string Id, + string? TaskId, + DateTimeOffset Start, + DateTimeOffset End, + TimeboxKind Kind, + string? PrimaryTag = null +); diff --git a/src/Timeboxer.Domain/Models/WorkWindow.cs b/src/Timeboxer.Domain/Models/WorkWindow.cs new file mode 100644 index 0000000..1700a81 --- /dev/null +++ b/src/Timeboxer.Domain/Models/WorkWindow.cs @@ -0,0 +1,3 @@ +namespace Timeboxer.Domain.Models; + +public record WorkWindow(DayOfWeek Day, TimeSpan StartLocalTime, TimeSpan EndLocalTime); diff --git a/src/Timeboxer.Domain/Scheduling/FreeWindowBuilder.cs b/src/Timeboxer.Domain/Scheduling/FreeWindowBuilder.cs new file mode 100644 index 0000000..d8e2411 --- /dev/null +++ b/src/Timeboxer.Domain/Scheduling/FreeWindowBuilder.cs @@ -0,0 +1,49 @@ +using Timeboxer.Domain.Models; + +namespace Timeboxer.Domain.Scheduling; + +public static class FreeWindowBuilder +{ + public static List<(DateTimeOffset Start, DateTimeOffset End)> BuildForDay( + IEnumerable events, + DateOnly date, + IEnumerable<(TimeSpan Start, TimeSpan End)> workWindows, + TimeZoneInfo tz) + { + var dayEvents = events.Where(e => e.Start.Date == date.ToDateTime(TimeOnly.MinValue).Date); + if (dayEvents.Any(e => e.IsAllDay && e.IsBusy)) + return new(); + + var result = new List<(DateTimeOffset Start, DateTimeOffset End)>(); + var dayStart = date.ToDateTime(TimeOnly.MinValue, DateTimeKind.Unspecified); + foreach (var w in workWindows) + { + var start = new DateTimeOffset(dayStart + w.Start, tz.GetUtcOffset(dayStart + w.Start)); + var end = new DateTimeOffset(dayStart + w.End, tz.GetUtcOffset(dayStart + w.End)); + result.Add((start, end)); + } + foreach (var ev in dayEvents.Where(e => e.IsBusy)) + { + result = Subtract(result, ev.Start, ev.End); + } + return result; + } + + private static List<(DateTimeOffset Start, DateTimeOffset End)> Subtract(List<(DateTimeOffset Start, DateTimeOffset End)> intervals, DateTimeOffset start, DateTimeOffset end) + { + var res = new List<(DateTimeOffset Start, DateTimeOffset End)>(); + foreach (var (s, e) in intervals) + { + if (end <= s || start >= e) + { + res.Add((s, e)); + continue; + } + if (start > s) + res.Add((s, start)); + if (end < e) + res.Add((end, e)); + } + return res; + } +} diff --git a/src/Timeboxer.Domain/Scheduling/Heuristics.cs b/src/Timeboxer.Domain/Scheduling/Heuristics.cs new file mode 100644 index 0000000..2fd06cf --- /dev/null +++ b/src/Timeboxer.Domain/Scheduling/Heuristics.cs @@ -0,0 +1,15 @@ +using Timeboxer.Domain.Models; + +namespace Timeboxer.Domain.Scheduling; + +public static class Heuristics +{ + public static IEnumerable Order(IEnumerable tasks) + { + return tasks + .OrderBy(t => t.Deadline) + .ThenBy(t => t.Priority) + .ThenByDescending(t => t.Difficulty) + .ThenByDescending(t => t.EstimateMinutes); + } +} diff --git a/src/Timeboxer.Domain/Scheduling/Planner.cs b/src/Timeboxer.Domain/Scheduling/Planner.cs new file mode 100644 index 0000000..191e476 --- /dev/null +++ b/src/Timeboxer.Domain/Scheduling/Planner.cs @@ -0,0 +1,76 @@ +using Timeboxer.Domain.Models; + +namespace Timeboxer.Domain.Scheduling; + +public class Planner +{ + private readonly Settings _settings; + + public Planner(Settings settings) + { + _settings = settings; + } + + public PlanResult Plan(IEnumerable tasks, IEnumerable events, DateOnly from, DateOnly to) + { + var tz = TimeZoneInfo.FindSystemTimeZoneById(_settings.Timezone); + var free = new Dictionary>(); + for (var day = from; day < to; day = day.AddDays(1)) + { + if (!_settings.WorkWindows.TryGetValue(day.DayOfWeek, out var windows) || windows.Count == 0) + continue; + var dayEvents = events.Where(e => e.Start.Date == day.ToDateTime(TimeOnly.MinValue).Date); + free[day] = FreeWindowBuilder.BuildForDay(dayEvents, day, windows, tz); + } + + IEnumerable ordered = Heuristics.Order(tasks); + if (_settings.TagClusteringEnabled) + ordered = TagBatching.Apply(ordered); + + var plan = new List(); + var overflow = new List(); + int idCounter = 1; + + foreach (var task in ordered) + { + var contingency = _settings.Contingency.GetValueOrDefault(task.Difficulty, 0); + var required = (int)Math.Ceiling(task.EstimateMinutes * (1 + contingency / 100.0)); + var remaining = required; + var deadlineDay = DateOnly.FromDateTime(task.Deadline.DateTime); + + + while (remaining > 0) + { + var chosenDay = free.Keys.OrderBy(d => d).FirstOrDefault(d => d <= deadlineDay && free[d].Any()); + var hasDay = chosenDay != default && free.ContainsKey(chosenDay); + if (!hasDay) + { + overflow.Add(task); + break; + } + var intervals = free[chosenDay]; + var interval = intervals[0]; + var intervalMinutes = (int)(interval.End - interval.Start).TotalMinutes; + var chunk = Math.Min(Math.Min(remaining, _settings.MaxTimeboxMinutes), intervalMinutes); + var workStart = interval.Start; + var workEnd = workStart.AddMinutes(chunk); + plan.Add(new Timebox($"tb{idCounter++}", task.Id, workStart, workEnd, TimeboxKind.Work, task.Tags.FirstOrDefault())); + remaining -= chunk; + var cursor = workEnd; + if (remaining > 0) + { + var breakEnd = cursor.AddMinutes(_settings.MinBreakMinutes); + plan.Add(new Timebox($"tb{idCounter++}", null, cursor, breakEnd, TimeboxKind.Break)); + cursor = breakEnd; + } + if (cursor < interval.End) + intervals[0] = (cursor, interval.End); + else + intervals.RemoveAt(0); + } + } + return new PlanResult(plan.OrderBy(p => p.Start).ToList(), overflow); + } +} + +public record PlanResult(IReadOnlyList Timeboxes, IReadOnlyList Overflow); diff --git a/src/Timeboxer.Domain/Scheduling/TagBatching.cs b/src/Timeboxer.Domain/Scheduling/TagBatching.cs new file mode 100644 index 0000000..a6e8398 --- /dev/null +++ b/src/Timeboxer.Domain/Scheduling/TagBatching.cs @@ -0,0 +1,11 @@ +using Timeboxer.Domain.Models; + +namespace Timeboxer.Domain.Scheduling; + +public static class TagBatching +{ + public static IEnumerable Apply(IEnumerable tasks) + { + return tasks.OrderBy(t => t.Tags.FirstOrDefault()); + } +} diff --git a/src/Timeboxer.Domain/Scheduling/Validation.cs b/src/Timeboxer.Domain/Scheduling/Validation.cs new file mode 100644 index 0000000..05e5a75 --- /dev/null +++ b/src/Timeboxer.Domain/Scheduling/Validation.cs @@ -0,0 +1,33 @@ +using Timeboxer.Domain.Models; + +namespace Timeboxer.Domain.Scheduling; + +public static class Validation +{ + public static bool HasNoOverlaps(IEnumerable boxes) + { + var ordered = boxes.OrderBy(b => b.Start).ToList(); + for (int i = 1; i < ordered.Count; i++) + { + if (ordered[i].Start < ordered[i - 1].End) + return false; + } + return true; + } + + public static bool BreaksSatisfied(IEnumerable boxes, int minBreak) + { + var ordered = boxes.OrderBy(b => b.Start).ToList(); + for (int i = 1; i < ordered.Count; i++) + { + var prev = ordered[i - 1]; + var cur = ordered[i]; + if (prev.Kind == TimeboxKind.Work && cur.Kind == TimeboxKind.Work) + { + if ((cur.Start - prev.End).TotalMinutes < minBreak) + return false; + } + } + return true; + } +} diff --git a/src/Timeboxer.Domain/Timeboxer.Domain.csproj b/src/Timeboxer.Domain/Timeboxer.Domain.csproj new file mode 100644 index 0000000..bb23fb7 --- /dev/null +++ b/src/Timeboxer.Domain/Timeboxer.Domain.csproj @@ -0,0 +1,9 @@ + + + + net8.0 + enable + enable + + + diff --git a/src/Timeboxer.Domain/Utils/DurationExtensions.cs b/src/Timeboxer.Domain/Utils/DurationExtensions.cs new file mode 100644 index 0000000..ba76b4e --- /dev/null +++ b/src/Timeboxer.Domain/Utils/DurationExtensions.cs @@ -0,0 +1,6 @@ +namespace Timeboxer.Domain.Utils; + +public static class DurationExtensions +{ + public static TimeSpan Minutes(this int value) => TimeSpan.FromMinutes(value); +} diff --git a/src/Timeboxer.Infrastructure.Google/GoogleAuth.cs b/src/Timeboxer.Infrastructure.Google/GoogleAuth.cs new file mode 100644 index 0000000..1a60dc1 --- /dev/null +++ b/src/Timeboxer.Infrastructure.Google/GoogleAuth.cs @@ -0,0 +1,6 @@ +namespace Timeboxer.Infrastructure.Google; + +public static class GoogleAuth +{ + // Placeholder for OAuth device code flow implementation. +} diff --git a/src/Timeboxer.Infrastructure.Google/GoogleCalendarProvider.cs b/src/Timeboxer.Infrastructure.Google/GoogleCalendarProvider.cs new file mode 100644 index 0000000..0f19d40 --- /dev/null +++ b/src/Timeboxer.Infrastructure.Google/GoogleCalendarProvider.cs @@ -0,0 +1,19 @@ +using Timeboxer.Domain.Abstractions; +using Timeboxer.Domain.Models; + +namespace Timeboxer.Infrastructure.Google; + +public class GoogleCalendarProvider : ICalendarProvider +{ + public string ProviderName => "Google"; + + public Task> GetFixedEventsAsync(DateTimeOffset startInclusive, DateTimeOffset endExclusive, CancellationToken ct) + { + return Task.FromResult>(new List()); + } + + public Task UpsertTimeboxesAsync(IReadOnlyList timeboxes, CancellationToken ct) + { + return Task.CompletedTask; + } +} diff --git a/src/Timeboxer.Infrastructure.Google/GoogleMappers.cs b/src/Timeboxer.Infrastructure.Google/GoogleMappers.cs new file mode 100644 index 0000000..6eabb2a --- /dev/null +++ b/src/Timeboxer.Infrastructure.Google/GoogleMappers.cs @@ -0,0 +1,6 @@ +namespace Timeboxer.Infrastructure.Google; + +public static class GoogleMappers +{ + // Placeholder for mapping Google events to domain models. +} diff --git a/src/Timeboxer.Infrastructure.Google/Timeboxer.Infrastructure.Google.csproj b/src/Timeboxer.Infrastructure.Google/Timeboxer.Infrastructure.Google.csproj new file mode 100644 index 0000000..12521cb --- /dev/null +++ b/src/Timeboxer.Infrastructure.Google/Timeboxer.Infrastructure.Google.csproj @@ -0,0 +1,13 @@ + + + net8.0 + enable + enable + + + + + + + + diff --git a/src/Timeboxer.Infrastructure.Google/appsettings.sample.json b/src/Timeboxer.Infrastructure.Google/appsettings.sample.json new file mode 100644 index 0000000..6c9c0de --- /dev/null +++ b/src/Timeboxer.Infrastructure.Google/appsettings.sample.json @@ -0,0 +1,3 @@ +{ + "CalendarId": "primary" +} diff --git a/src/Timeboxer.Infrastructure.Google/docs/GOOGLE_SETUP.md b/src/Timeboxer.Infrastructure.Google/docs/GOOGLE_SETUP.md new file mode 100644 index 0000000..dea290e --- /dev/null +++ b/src/Timeboxer.Infrastructure.Google/docs/GOOGLE_SETUP.md @@ -0,0 +1,3 @@ +# Google Setup + +Placeholder instructions for creating a Google Cloud project and enabling Calendar API. diff --git a/src/Timeboxer.Persistence/JsonStore.cs b/src/Timeboxer.Persistence/JsonStore.cs new file mode 100644 index 0000000..09d31a6 --- /dev/null +++ b/src/Timeboxer.Persistence/JsonStore.cs @@ -0,0 +1,29 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Timeboxer.Persistence; + +public static class JsonStore +{ + static readonly JsonSerializerOptions Options = new() + { + PropertyNameCaseInsensitive = true, + Converters = { new JsonStringEnumConverter() }, + WriteIndented = true + }; + + public static async Task LoadAsync(string path, CancellationToken ct = default) + { + using var stream = File.OpenRead(path); + var result = await JsonSerializer.DeserializeAsync(stream, Options, ct); + if (result == null) throw new InvalidDataException($"Failed to deserialize {path}"); + return result; + } + + public static async Task SaveAsync(string path, T data, CancellationToken ct = default) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + using var stream = File.Create(path); + await JsonSerializer.SerializeAsync(stream, data, Options, ct); + } +} diff --git a/src/Timeboxer.Persistence/Paths.cs b/src/Timeboxer.Persistence/Paths.cs new file mode 100644 index 0000000..1c31b55 --- /dev/null +++ b/src/Timeboxer.Persistence/Paths.cs @@ -0,0 +1,6 @@ +namespace Timeboxer.Persistence; + +public static class Paths +{ + public static string DataDir => Path.Combine(AppContext.BaseDirectory, "data"); +} diff --git a/src/Timeboxer.Persistence/Timeboxer.Persistence.csproj b/src/Timeboxer.Persistence/Timeboxer.Persistence.csproj new file mode 100644 index 0000000..d19b067 --- /dev/null +++ b/src/Timeboxer.Persistence/Timeboxer.Persistence.csproj @@ -0,0 +1,10 @@ + + + net8.0 + enable + enable + + + + + diff --git a/tests/Timeboxer.Domain.Tests/DeadlineFeasibilityTests.cs b/tests/Timeboxer.Domain.Tests/DeadlineFeasibilityTests.cs new file mode 100644 index 0000000..212714d --- /dev/null +++ b/tests/Timeboxer.Domain.Tests/DeadlineFeasibilityTests.cs @@ -0,0 +1,28 @@ +using Xunit; +using FluentAssertions; +using Timeboxer.Domain.Models; +using Timeboxer.Domain.Scheduling; + +namespace Timeboxer.Domain.Tests; + +public class DeadlineFeasibilityTests +{ + [Fact] + public void ReturnsOverflowWhenDeadlineImpossible() + { + var settings = new Settings + { + WorkWindows = new Dictionary> + { + [DayOfWeek.Monday] = new() { (TimeSpan.FromHours(8), TimeSpan.FromHours(9)) } + } + }; + var tasks = new List + { + new() { Id="T1", EstimateMinutes=120, Deadline=new DateTimeOffset(2025,8,25,12,0,0,TimeSpan.FromHours(2)), Priority=Priority.P1, Difficulty=Difficulty.Med, Tags=new(){"x"} } + }; + var planner = new Planner(settings); + var res = planner.Plan(tasks, new List(), new DateOnly(2025,8,25), new DateOnly(2025,8,26)); + res.Overflow.Should().ContainSingle(t => t.Id == "T1"); + } +} diff --git a/tests/Timeboxer.Domain.Tests/Fixtures/expected_plan_day1.txt b/tests/Timeboxer.Domain.Tests/Fixtures/expected_plan_day1.txt new file mode 100644 index 0000000..6dde65f --- /dev/null +++ b/tests/Timeboxer.Domain.Tests/Fixtures/expected_plan_day1.txt @@ -0,0 +1,2 @@ +2025-08-27 08:30 - 09:20 | Work | T2 admin +2025-08-27 09:20 - 09:30 | Break | Break diff --git a/tests/Timeboxer.Domain.Tests/Fixtures/expected_plan_week.ics b/tests/Timeboxer.Domain.Tests/Fixtures/expected_plan_week.ics new file mode 100644 index 0000000..afa30f4 --- /dev/null +++ b/tests/Timeboxer.Domain.Tests/Fixtures/expected_plan_week.ics @@ -0,0 +1,2 @@ +BEGIN:VCALENDAR +END:VCALENDAR diff --git a/tests/Timeboxer.Domain.Tests/Fixtures/sample_events.json b/tests/Timeboxer.Domain.Tests/Fixtures/sample_events.json new file mode 100644 index 0000000..069b60f --- /dev/null +++ b/tests/Timeboxer.Domain.Tests/Fixtures/sample_events.json @@ -0,0 +1,4 @@ +[ + { "Id":"E1","Title":"Lecture 2IMP30","Start":"2025-08-27T10:00:00+02:00","End":"2025-08-27T12:00:00+02:00","IsBusy":true,"Source":"Google" }, + { "Id":"E2","Title":"Gym","Start":"2025-08-27T18:30:00+02:00","End":"2025-08-27T19:30:00+02:00","IsBusy":true,"Source":"Google" } +] diff --git a/tests/Timeboxer.Domain.Tests/Fixtures/sample_settings.json b/tests/Timeboxer.Domain.Tests/Fixtures/sample_settings.json new file mode 100644 index 0000000..3c6f59a --- /dev/null +++ b/tests/Timeboxer.Domain.Tests/Fixtures/sample_settings.json @@ -0,0 +1,18 @@ +{ + "Timezone": "Europe/Amsterdam", + "WorkWindows": { + "Monday": [["08:30","18:00"]], + "Tuesday": [["08:30","18:00"]], + "Wednesday": [["08:30","18:00"]], + "Thursday": [["08:30","18:00"]], + "Friday": [["08:30","18:00"]], + "Saturday": [], + "Sunday": [] + }, + "MaxTimeboxMinutes": 50, + "MinBreakMinutes": 10, + "DailyBufferPercent": 15, + "Contingency": { "Easy": 0, "Med": 10, "Hard": 25, "VHard": 50 }, + "TagClusteringEnabled": true, + "WritebackToGoogle": false +} diff --git a/tests/Timeboxer.Domain.Tests/Fixtures/sample_tasks.json b/tests/Timeboxer.Domain.Tests/Fixtures/sample_tasks.json new file mode 100644 index 0000000..754a27d --- /dev/null +++ b/tests/Timeboxer.Domain.Tests/Fixtures/sample_tasks.json @@ -0,0 +1,7 @@ +[ + { "Id":"T1", "Title":"Write DisTL section", "EstimateMinutes":180, "Deadline":"2025-08-29T17:00:00+02:00", + "Priority":"P1", "Difficulty":"Hard", "Tags":["writing","FSA25"], "Notes":"one key question: formalize expressiveness" + }, + { "Id":"T2", "Title":"Admin email sweep", "EstimateMinutes":40, "Deadline":"2025-08-27T18:00:00+02:00", + "Priority":"P3", "Difficulty":"Easy", "Tags":["admin"] } +] diff --git a/tests/Timeboxer.Domain.Tests/FreeWindowBuilderTests.cs b/tests/Timeboxer.Domain.Tests/FreeWindowBuilderTests.cs new file mode 100644 index 0000000..0739e2c --- /dev/null +++ b/tests/Timeboxer.Domain.Tests/FreeWindowBuilderTests.cs @@ -0,0 +1,22 @@ +using Xunit; +using FluentAssertions; +using Timeboxer.Domain.Models; +using Timeboxer.Domain.Scheduling; + +namespace Timeboxer.Domain.Tests; + +public class FreeWindowBuilderTests +{ + [Fact] + public void SubtractsEventsFromWorkWindow() + { + var events = new List + { + new("E1","Meeting", new DateTimeOffset(2025,8,27,10,0,0,TimeSpan.FromHours(2)), new DateTimeOffset(2025,8,27,12,0,0,TimeSpan.FromHours(2)), false, null, true, "Google") + }; + var windows = new List<(TimeSpan, TimeSpan)> { (TimeSpan.FromHours(8.5), TimeSpan.FromHours(18)) }; + var tz = TimeZoneInfo.FindSystemTimeZoneById("Europe/Amsterdam"); + var free = FreeWindowBuilder.BuildForDay(events, new DateOnly(2025,8,27), windows, tz); + free.Count.Should().Be(2); + } +} diff --git a/tests/Timeboxer.Domain.Tests/HeuristicOrderingTests.cs b/tests/Timeboxer.Domain.Tests/HeuristicOrderingTests.cs new file mode 100644 index 0000000..65e0a6e --- /dev/null +++ b/tests/Timeboxer.Domain.Tests/HeuristicOrderingTests.cs @@ -0,0 +1,23 @@ +using Xunit; +using FluentAssertions; +using Timeboxer.Domain.Models; +using Timeboxer.Domain.Scheduling; + +namespace Timeboxer.Domain.Tests; + +public class HeuristicOrderingTests +{ + [Fact] + public void OrdersByDeadlinePriorityAndDifficulty() + { + var now = DateTimeOffset.Now; + var tasks = new List + { + new() { Id = "A", EstimateMinutes = 30, Deadline = now.AddDays(1), Priority = Priority.P2, Difficulty = Difficulty.Easy }, + new() { Id = "B", EstimateMinutes = 30, Deadline = now.AddDays(1), Priority = Priority.P1, Difficulty = Difficulty.Easy }, + new() { Id = "C", EstimateMinutes = 30, Deadline = now.AddDays(2), Priority = Priority.P1, Difficulty = Difficulty.VHard } + }; + var ordered = Heuristics.Order(tasks).Select(t => t.Id).ToList(); + ordered.Should().ContainInOrder("B", "A", "C"); + } +} diff --git a/tests/Timeboxer.Domain.Tests/PlannerCoreTests.cs b/tests/Timeboxer.Domain.Tests/PlannerCoreTests.cs new file mode 100644 index 0000000..81e3ed8 --- /dev/null +++ b/tests/Timeboxer.Domain.Tests/PlannerCoreTests.cs @@ -0,0 +1,32 @@ +using Xunit; +using FluentAssertions; +using Timeboxer.Domain.Models; +using Timeboxer.Domain.Scheduling; +using Timeboxer.Persistence; + +namespace Timeboxer.Domain.Tests; + +public class PlannerCoreTests +{ + [Fact] + public async Task PlansSampleData() + { + var tasks = await JsonStore.LoadAsync>(Path.Combine("Fixtures","sample_tasks.json")); + var events = await JsonStore.LoadAsync>(Path.Combine("Fixtures","sample_events.json")); + var settings = new Settings + { + WorkWindows = new Dictionary> + { + [DayOfWeek.Monday] = new() { (TimeSpan.FromHours(8.5), TimeSpan.FromHours(18)) }, + [DayOfWeek.Tuesday] = new() { (TimeSpan.FromHours(8.5), TimeSpan.FromHours(18)) }, + [DayOfWeek.Wednesday] = new() { (TimeSpan.FromHours(8.5), TimeSpan.FromHours(18)) }, + [DayOfWeek.Thursday] = new() { (TimeSpan.FromHours(8.5), TimeSpan.FromHours(18)) }, + [DayOfWeek.Friday] = new() { (TimeSpan.FromHours(8.5), TimeSpan.FromHours(18)) } + } + }; + var planner = new Planner(settings); + var result = planner.Plan(tasks, events, new DateOnly(2025,8,26), new DateOnly(2025,9,2)); + result.Timeboxes.Should().NotBeEmpty(); + Validation.HasNoOverlaps(result.Timeboxes).Should().BeTrue(); + } +} diff --git a/tests/Timeboxer.Domain.Tests/TagBatchingTests.cs b/tests/Timeboxer.Domain.Tests/TagBatchingTests.cs new file mode 100644 index 0000000..4bc0015 --- /dev/null +++ b/tests/Timeboxer.Domain.Tests/TagBatchingTests.cs @@ -0,0 +1,23 @@ +using Xunit; +using FluentAssertions; +using Timeboxer.Domain.Models; +using Timeboxer.Domain.Scheduling; + +namespace Timeboxer.Domain.Tests; + +public class TagBatchingTests +{ + [Fact] + public void GroupsByTag() + { + var now = DateTimeOffset.Now.AddDays(1); + var tasks = new List + { + new() { Id="1", EstimateMinutes=10, Deadline=now, Priority=Priority.P1, Difficulty=Difficulty.Easy, Tags=new(){"a"} }, + new() { Id="2", EstimateMinutes=10, Deadline=now, Priority=Priority.P1, Difficulty=Difficulty.Easy, Tags=new(){"b"} }, + new() { Id="3", EstimateMinutes=10, Deadline=now, Priority=Priority.P1, Difficulty=Difficulty.Easy, Tags=new(){"a"} } + }; + var ordered = TagBatching.Apply(tasks).Select(t => t.Id).ToList(); + ordered.Should().Equal(new[]{"1","3","2"}); + } +} diff --git a/tests/Timeboxer.Domain.Tests/Timeboxer.Domain.Tests.csproj b/tests/Timeboxer.Domain.Tests/Timeboxer.Domain.Tests.csproj new file mode 100644 index 0000000..cb943b5 --- /dev/null +++ b/tests/Timeboxer.Domain.Tests/Timeboxer.Domain.Tests.csproj @@ -0,0 +1,22 @@ + + + net8.0 + enable + enable + false + true + + + + + + + + + + + + + + + diff --git a/tests/Timeboxer.Infrastructure.Tests/Fakes/FakeCalendarProvider.cs b/tests/Timeboxer.Infrastructure.Tests/Fakes/FakeCalendarProvider.cs new file mode 100644 index 0000000..ada7df0 --- /dev/null +++ b/tests/Timeboxer.Infrastructure.Tests/Fakes/FakeCalendarProvider.cs @@ -0,0 +1,14 @@ +using Timeboxer.Domain.Abstractions; +using Timeboxer.Domain.Models; + +namespace Timeboxer.Infrastructure.Tests.Fakes; + +public class FakeCalendarProvider : ICalendarProvider +{ + public List Events { get; } = new(); + public string ProviderName => "Fake"; + public Task> GetFixedEventsAsync(DateTimeOffset startInclusive, DateTimeOffset endExclusive, CancellationToken ct) + => Task.FromResult>(Events); + public Task UpsertTimeboxesAsync(IReadOnlyList timeboxes, CancellationToken ct) + => Task.CompletedTask; +} diff --git a/tests/Timeboxer.Infrastructure.Tests/Fakes/FakeClock.cs b/tests/Timeboxer.Infrastructure.Tests/Fakes/FakeClock.cs new file mode 100644 index 0000000..3d8a44c --- /dev/null +++ b/tests/Timeboxer.Infrastructure.Tests/Fakes/FakeClock.cs @@ -0,0 +1,8 @@ +using Timeboxer.Domain.Abstractions; + +namespace Timeboxer.Infrastructure.Tests.Fakes; + +public class FakeClock : IClock +{ + public DateTimeOffset Now { get; set; } +} diff --git a/tests/Timeboxer.Infrastructure.Tests/GoogleProviderMockTests.cs b/tests/Timeboxer.Infrastructure.Tests/GoogleProviderMockTests.cs new file mode 100644 index 0000000..25911d2 --- /dev/null +++ b/tests/Timeboxer.Infrastructure.Tests/GoogleProviderMockTests.cs @@ -0,0 +1,18 @@ +using Xunit; +using FluentAssertions; +using Timeboxer.Infrastructure.Tests.Fakes; +using Timeboxer.Domain.Models; + +namespace Timeboxer.Infrastructure.Tests; + +public class GoogleProviderMockTests +{ + [Fact] + public async Task FakeProviderReturnsEvents() + { + var provider = new FakeCalendarProvider(); + provider.Events.Add(new CalendarEvent("1","Test",DateTimeOffset.Now,DateTimeOffset.Now.AddHours(1),false,null,true,"Fake")); + var events = await provider.GetFixedEventsAsync(DateTimeOffset.Now.AddDays(-1), DateTimeOffset.Now.AddDays(1), CancellationToken.None); + events.Should().HaveCount(1); + } +} diff --git a/tests/Timeboxer.Infrastructure.Tests/Timeboxer.Infrastructure.Tests.csproj b/tests/Timeboxer.Infrastructure.Tests/Timeboxer.Infrastructure.Tests.csproj new file mode 100644 index 0000000..2b0fb4c --- /dev/null +++ b/tests/Timeboxer.Infrastructure.Tests/Timeboxer.Infrastructure.Tests.csproj @@ -0,0 +1,18 @@ + + + net8.0 + enable + enable + false + true + + + + + + + + + + +