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
+
+
+
+
+
+
+
+
+
+
+