diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..8182543
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,8 @@
+root = true
+
+[*.cs]
+indent_style = space
+indent_size = 2
+charset = utf-8
+end_of_line = lf
+insert_final_newline = true
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..f467841
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,20 @@
+name: CI
+
+on:
+ push:
+ pull_request:
+
+jobs:
+ build:
+ runs-on: ${{ matrix.os }}
+ strategy:
+ matrix:
+ os: [ubuntu-latest, windows-latest]
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '8.0.x'
+ - run: dotnet restore
+ - run: dotnet build -c Release
+ - run: dotnet test -c Release
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..70a5ee5
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,9 @@
+bin/
+obj/
+*.user
+*.suo
+*.DS_Store
+/Releases/
+/artifacts/
+/.vs/
+**/out/
diff --git a/Directory.Packages.props b/Directory.Packages.props
new file mode 100644
index 0000000..50c9759
--- /dev/null
+++ b/Directory.Packages.props
@@ -0,0 +1,23 @@
+
+
+ true
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
\ No newline at end of file
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..bf7b0a1
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2024 Timeboxer
+
+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..182ac5d 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,15 @@
-# taskblocker
\ No newline at end of file
+# Timeboxer
+
+Experimental console-based timeboxing planner written in C#/.NET 8.
+
+## Build
+
+```
+dotnet build
+```
+
+## Tests
+
+```
+dotnet test
+```
diff --git a/Timeboxer.Persistence/JsonStore.cs b/Timeboxer.Persistence/JsonStore.cs
new file mode 100644
index 0000000..af1e57b
--- /dev/null
+++ b/Timeboxer.Persistence/JsonStore.cs
@@ -0,0 +1,19 @@
+using System.Text.Json;
+using Timeboxer.Domain.Models;
+
+namespace Timeboxer.Persistence;
+
+public static class JsonStore
+{
+ public static IReadOnlyList LoadEvents(string path)
+ => JsonSerializer.Deserialize>(File.ReadAllText(path)) ?? new List();
+
+ public static IReadOnlyList LoadTasks(string path)
+ => JsonSerializer.Deserialize>(File.ReadAllText(path)) ?? new List();
+
+ public static Settings LoadSettings(string path)
+ => JsonSerializer.Deserialize(File.ReadAllText(path)) ?? new Settings();
+
+ public static void SaveTimeboxes(string path, IEnumerable timeboxes)
+ => File.WriteAllText(path, JsonSerializer.Serialize(timeboxes, new JsonSerializerOptions { WriteIndented = true }));
+}
diff --git a/Timeboxer.Persistence/Paths.cs b/Timeboxer.Persistence/Paths.cs
new file mode 100644
index 0000000..907e1f9
--- /dev/null
+++ b/Timeboxer.Persistence/Paths.cs
@@ -0,0 +1,6 @@
+namespace Timeboxer.Persistence;
+
+public static class Paths
+{
+ public static string DataDir => Path.Combine(Environment.CurrentDirectory, "data");
+}
diff --git a/Timeboxer.Persistence/Timeboxer.Persistence.csproj b/Timeboxer.Persistence/Timeboxer.Persistence.csproj
new file mode 100644
index 0000000..5031ab7
--- /dev/null
+++ b/Timeboxer.Persistence/Timeboxer.Persistence.csproj
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
diff --git a/Timeboxer.sln b/Timeboxer.sln
new file mode 100644
index 0000000..27f9ccc
--- /dev/null
+++ b/Timeboxer.sln
@@ -0,0 +1,63 @@
+
+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", "{0AE52D72-309A-4078-ACF0-045139DC696D}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Timeboxer.Domain", "src\Timeboxer.Domain\Timeboxer.Domain.csproj", "{7E7D213A-B73B-4D02-AA29-B8B6FE84DFA2}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Timeboxer.Cli", "src\Timeboxer.Cli\Timeboxer.Cli.csproj", "{542C6CA9-E19A-4CB2-B2C3-3C51034A69EC}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Timeboxer.Infrastructure.Google", "src\Timeboxer.Infrastructure.Google\Timeboxer.Infrastructure.Google.csproj", "{C26BDF3B-0ED7-4760-B4A6-226EC916BA41}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Timeboxer.Persistence", "Timeboxer.Persistence\Timeboxer.Persistence.csproj", "{4F96E6AA-CC0C-44F4-8845-007E8D017A5B}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{FA629DBA-298C-4BE5-BF2B-CCF716DD8E5E}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Timeboxer.Domain.Tests", "tests\Timeboxer.Domain.Tests\Timeboxer.Domain.Tests.csproj", "{85ECF150-B5FC-465B-BA46-8FF876B503A1}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Timeboxer.Infrastructure.Tests", "tests\Timeboxer.Infrastructure.Tests\Timeboxer.Infrastructure.Tests.csproj", "{6FFE4F40-1F7D-40F9-AB49-96F33B0C3A5D}"
+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
+ {7E7D213A-B73B-4D02-AA29-B8B6FE84DFA2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {7E7D213A-B73B-4D02-AA29-B8B6FE84DFA2}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {7E7D213A-B73B-4D02-AA29-B8B6FE84DFA2}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {7E7D213A-B73B-4D02-AA29-B8B6FE84DFA2}.Release|Any CPU.Build.0 = Release|Any CPU
+ {542C6CA9-E19A-4CB2-B2C3-3C51034A69EC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {542C6CA9-E19A-4CB2-B2C3-3C51034A69EC}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {542C6CA9-E19A-4CB2-B2C3-3C51034A69EC}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {542C6CA9-E19A-4CB2-B2C3-3C51034A69EC}.Release|Any CPU.Build.0 = Release|Any CPU
+ {C26BDF3B-0ED7-4760-B4A6-226EC916BA41}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {C26BDF3B-0ED7-4760-B4A6-226EC916BA41}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {C26BDF3B-0ED7-4760-B4A6-226EC916BA41}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {C26BDF3B-0ED7-4760-B4A6-226EC916BA41}.Release|Any CPU.Build.0 = Release|Any CPU
+ {4F96E6AA-CC0C-44F4-8845-007E8D017A5B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {4F96E6AA-CC0C-44F4-8845-007E8D017A5B}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {4F96E6AA-CC0C-44F4-8845-007E8D017A5B}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {4F96E6AA-CC0C-44F4-8845-007E8D017A5B}.Release|Any CPU.Build.0 = Release|Any CPU
+ {85ECF150-B5FC-465B-BA46-8FF876B503A1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {85ECF150-B5FC-465B-BA46-8FF876B503A1}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {85ECF150-B5FC-465B-BA46-8FF876B503A1}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {85ECF150-B5FC-465B-BA46-8FF876B503A1}.Release|Any CPU.Build.0 = Release|Any CPU
+ {6FFE4F40-1F7D-40F9-AB49-96F33B0C3A5D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {6FFE4F40-1F7D-40F9-AB49-96F33B0C3A5D}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {6FFE4F40-1F7D-40F9-AB49-96F33B0C3A5D}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {6FFE4F40-1F7D-40F9-AB49-96F33B0C3A5D}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {7E7D213A-B73B-4D02-AA29-B8B6FE84DFA2} = {0AE52D72-309A-4078-ACF0-045139DC696D}
+ {542C6CA9-E19A-4CB2-B2C3-3C51034A69EC} = {0AE52D72-309A-4078-ACF0-045139DC696D}
+ {C26BDF3B-0ED7-4760-B4A6-226EC916BA41} = {0AE52D72-309A-4078-ACF0-045139DC696D}
+ {85ECF150-B5FC-465B-BA46-8FF876B503A1} = {FA629DBA-298C-4BE5-BF2B-CCF716DD8E5E}
+ {6FFE4F40-1F7D-40F9-AB49-96F33B0C3A5D} = {FA629DBA-298C-4BE5-BF2B-CCF716DD8E5E}
+ EndGlobalSection
+EndGlobal
diff --git a/build.ps1 b/build.ps1
new file mode 100644
index 0000000..9e7b223
--- /dev/null
+++ b/build.ps1
@@ -0,0 +1,9 @@
+param()
+
+dotnet restore
+if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+
+dotnet build -c Release
+if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+
+dotnet test -c Release
diff --git a/build.sh b/build.sh
new file mode 100755
index 0000000..ee81054
--- /dev/null
+++ b/build.sh
@@ -0,0 +1,6 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+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..26eb5cf
--- /dev/null
+++ b/src/Timeboxer.Cli/Commands/DemoCommand.cs
@@ -0,0 +1,22 @@
+using Timeboxer.Domain.Utils;
+using Timeboxer.Domain.Scheduling;
+using Timeboxer.Domain.Export;
+
+namespace Timeboxer.Cli.Commands;
+
+public static class DemoCommand
+{
+ private const string TasksJson = "[{\"Id\":\"Tdemo\",\"Title\":\"Demo Task\",\"EstimateMinutes\":60,\"Deadline\":\"2025-08-27T18:00:00+02:00\",\"Priority\":\"P2\",\"Difficulty\":\"Med\",\"Tags\":[\"demo\"]}]";
+ private const string EventsJson = "[]";
+ private const string SettingsJson = "{\"WorkWindows\":{\"Monday\":[[\"08:30\",\"17:00\"]],\"Tuesday\":[[\"08:30\",\"17:00\"]],\"Wednesday\":[[\"08:30\",\"17:00\"]],\"Thursday\":[[\"08:30\",\"17:00\"]],\"Friday\":[[\"08:30\",\"17:00\"]]},\"DeepWorkWindows\":{},\"MaxTimeboxMinutes\":50,\"MinBreakMinutes\":10,\"DailyBufferPercent\":15,\"Contingency\":{\"Easy\":0,\"Med\":10,\"Hard\":25,\"VHard\":50},\"TagClusteringEnabled\":true}";
+
+ public static void Run(string[] args)
+ {
+ var tasks = Parsing.LoadTasks(TasksJson);
+ var events = Parsing.LoadEvents(EventsJson);
+ var settings = Parsing.LoadSettings(SettingsJson);
+ var res = Planner.Plan(tasks, events, settings, DateTime.Today, DateTime.Today.AddDays(5));
+ var summary = ConsolePrinter.Render(res.Timeboxes);
+ Console.Write(summary);
+ }
+}
diff --git a/src/Timeboxer.Cli/Commands/ImportGoogleCommand.cs b/src/Timeboxer.Cli/Commands/ImportGoogleCommand.cs
new file mode 100644
index 0000000..d674f44
--- /dev/null
+++ b/src/Timeboxer.Cli/Commands/ImportGoogleCommand.cs
@@ -0,0 +1,9 @@
+namespace Timeboxer.Cli.Commands;
+
+public static class ImportGoogleCommand
+{
+ public static void Run(string[] args)
+ {
+ Console.WriteLine("import-google not implemented");
+ }
+}
diff --git a/src/Timeboxer.Cli/Commands/PlanCommand.cs b/src/Timeboxer.Cli/Commands/PlanCommand.cs
new file mode 100644
index 0000000..94c224c
--- /dev/null
+++ b/src/Timeboxer.Cli/Commands/PlanCommand.cs
@@ -0,0 +1,41 @@
+using Timeboxer.Domain.Utils;
+using Timeboxer.Domain.Scheduling;
+using Timeboxer.Domain.Export;
+using Timeboxer.Persistence;
+
+namespace Timeboxer.Cli.Commands;
+
+public static class PlanCommand
+{
+ public static void Run(string[] args)
+ {
+ string? tasks = null, events = null, settings = null, ics = null, text = null;
+ DateTime from = DateTime.Today, to = DateTime.Today;
+ for (int i = 0; i < args.Length; i++)
+ {
+ switch (args[i])
+ {
+ case "--tasks": tasks = args[++i]; break;
+ case "--events": events = args[++i]; break;
+ case "--settings": settings = args[++i]; break;
+ case "--from": from = DateTime.Parse(args[++i]); break;
+ case "--to": to = DateTime.Parse(args[++i]); break;
+ case "--out-ics": ics = args[++i]; break;
+ case "--out-text": text = args[++i]; break;
+ }
+ }
+ if (tasks == null || events == null || settings == null)
+ {
+ Console.WriteLine("Missing required options");
+ return;
+ }
+ var tasksData = JsonStore.LoadTasks(tasks);
+ var eventsData = JsonStore.LoadEvents(events);
+ var settingsData = JsonStore.LoadSettings(settings);
+ var res = Planner.Plan(tasksData, eventsData, settingsData, from, to);
+ if (ics != null) IcsExporter.Export(res.Timeboxes, ics);
+ var summary = ConsolePrinter.Render(res.Timeboxes);
+ Console.Write(summary);
+ if (text != null) File.WriteAllText(text, summary);
+ }
+}
diff --git a/src/Timeboxer.Cli/Commands/SyncGoogleCommand.cs b/src/Timeboxer.Cli/Commands/SyncGoogleCommand.cs
new file mode 100644
index 0000000..1e7c973
--- /dev/null
+++ b/src/Timeboxer.Cli/Commands/SyncGoogleCommand.cs
@@ -0,0 +1,9 @@
+namespace Timeboxer.Cli.Commands;
+
+public static class SyncGoogleCommand
+{
+ public static void Run(string[] args)
+ {
+ Console.WriteLine("sync-google not implemented");
+ }
+}
diff --git a/src/Timeboxer.Cli/Commands/ValidateCommand.cs b/src/Timeboxer.Cli/Commands/ValidateCommand.cs
new file mode 100644
index 0000000..34999e0
--- /dev/null
+++ b/src/Timeboxer.Cli/Commands/ValidateCommand.cs
@@ -0,0 +1,9 @@
+namespace Timeboxer.Cli.Commands;
+
+public static class ValidateCommand
+{
+ public static void Run(string[] args)
+ {
+ Console.WriteLine("validate not implemented");
+ }
+}
diff --git a/src/Timeboxer.Cli/Program.cs b/src/Timeboxer.Cli/Program.cs
new file mode 100644
index 0000000..8b05664
--- /dev/null
+++ b/src/Timeboxer.Cli/Program.cs
@@ -0,0 +1,32 @@
+using Timeboxer.Cli.Commands;
+
+if (args.Length == 0)
+{
+ Console.WriteLine("Timeboxer CLI");
+ return;
+}
+
+var cmd = args[0];
+var rest = args.Skip(1).ToArray();
+
+switch (cmd)
+{
+ case "plan":
+ PlanCommand.Run(rest);
+ break;
+ case "demo":
+ DemoCommand.Run(rest);
+ break;
+ case "import-google":
+ ImportGoogleCommand.Run(rest);
+ break;
+ case "sync-google":
+ SyncGoogleCommand.Run(rest);
+ break;
+ case "validate":
+ ValidateCommand.Run(rest);
+ break;
+ default:
+ Console.WriteLine("Unknown command");
+ break;
+}
diff --git a/src/Timeboxer.Cli/Timeboxer.Cli.csproj b/src/Timeboxer.Cli/Timeboxer.Cli.csproj
new file mode 100644
index 0000000..7ff652d
--- /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..0b8c423
--- /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(DateTime startInclusive, DateTime 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..b0458b4
--- /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 timeboxes)
+ {
+ var sb = new StringBuilder();
+ foreach (var tb in timeboxes.OrderBy(t => t.Start))
+ {
+ sb.AppendLine($"{tb.Start:yyyy-MM-dd HH:mm}-{tb.End:HH:mm} {tb.Kind} {tb.TaskId}");
+ }
+ 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..d0bd05c
--- /dev/null
+++ b/src/Timeboxer.Domain/Export/IcsExporter.cs
@@ -0,0 +1,28 @@
+using Ical.Net;
+using Ical.Net.DataTypes;
+using Ical.Net.Serialization;
+using Timeboxer.Domain.Models;
+using IcalEvent = Ical.Net.CalendarComponents.CalendarEvent;
+
+namespace Timeboxer.Domain.Export;
+
+public static class IcsExporter
+{
+ public static void Export(IEnumerable timeboxes, string path)
+ {
+ var calendar = new Calendar();
+ foreach (var tb in timeboxes)
+ {
+ var evt = new IcalEvent
+ {
+ Summary = tb.Kind == TimeboxKind.Work ? $"[WORK] {tb.TaskId}" : $"[{tb.Kind}]",
+ DtStart = new CalDateTime(tb.Start.UtcDateTime),
+ DtEnd = new CalDateTime(tb.End.UtcDateTime),
+ Uid = tb.Id
+ };
+ calendar.Events.Add(evt);
+ }
+ var serializer = new CalendarSerializer();
+ File.WriteAllText(path, serializer.SerializeToString(calendar));
+ }
+}
diff --git a/src/Timeboxer.Domain/Models/CalendarEvent.cs b/src/Timeboxer.Domain/Models/CalendarEvent.cs
new file mode 100644
index 0000000..44dcf18
--- /dev/null
+++ b/src/Timeboxer.Domain/Models/CalendarEvent.cs
@@ -0,0 +1,14 @@
+namespace Timeboxer.Domain.Models;
+
+public class CalendarEvent
+{
+ public string Id { get; set; } = string.Empty;
+ public string Title { get; set; } = string.Empty;
+ public DateTimeOffset Start { get; set; }
+ public DateTimeOffset End { get; set; }
+ public bool IsAllDay { get; set; }
+ public string? Location { get; set; }
+ public bool IsBusy { get; set; }
+ public string Source { get; set; } = "Local";
+ public string? Recurrence { get; set; }
+}
diff --git a/src/Timeboxer.Domain/Models/Enums.cs b/src/Timeboxer.Domain/Models/Enums.cs
new file mode 100644
index 0000000..dd5f6b5
--- /dev/null
+++ b/src/Timeboxer.Domain/Models/Enums.cs
@@ -0,0 +1,6 @@
+namespace Timeboxer.Domain.Models;
+
+public enum Priority { P1, P2, P3, P4 }
+public enum Difficulty { Easy, Med, Hard, VHard }
+public enum Environment { Any, Office, Home }
+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..951e823
--- /dev/null
+++ b/src/Timeboxer.Domain/Models/Settings.cs
@@ -0,0 +1,20 @@
+namespace Timeboxer.Domain.Models;
+
+public class Settings
+{
+ public Dictionary> WorkWindows { get; set; } = new();
+ public Dictionary> DeepWorkWindows { 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..0d3a79d
--- /dev/null
+++ b/src/Timeboxer.Domain/Models/TaskItem.cs
@@ -0,0 +1,16 @@
+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; } = Priority.P3;
+ public Difficulty Difficulty { get; set; } = Difficulty.Med;
+ public List Tags { get; set; } = new();
+ public Environment? Environment { get; set; }
+ public DateTimeOffset? MustStartAfter { get; set; }
+ public DateTimeOffset? MustEndBefore { get; set; }
+ 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..d13b0cd
--- /dev/null
+++ b/src/Timeboxer.Domain/Models/Timebox.cs
@@ -0,0 +1,11 @@
+namespace Timeboxer.Domain.Models;
+
+public class Timebox
+{
+ public string Id { get; set; } = Guid.NewGuid().ToString();
+ public string? TaskId { get; set; }
+ public DateTimeOffset Start { get; set; }
+ public DateTimeOffset End { get; set; }
+ public TimeboxKind Kind { get; set; }
+ public string? PrimaryTag { get; set; }
+}
diff --git a/src/Timeboxer.Domain/Models/WorkWindow.cs b/src/Timeboxer.Domain/Models/WorkWindow.cs
new file mode 100644
index 0000000..c3fdc1d
--- /dev/null
+++ b/src/Timeboxer.Domain/Models/WorkWindow.cs
@@ -0,0 +1,3 @@
+namespace Timeboxer.Domain.Models;
+
+public record WorkWindow(DayOfWeek Day, TimeSpan Start, TimeSpan End);
diff --git a/src/Timeboxer.Domain/Scheduling/FreeWindowBuilder.cs b/src/Timeboxer.Domain/Scheduling/FreeWindowBuilder.cs
new file mode 100644
index 0000000..6398101
--- /dev/null
+++ b/src/Timeboxer.Domain/Scheduling/FreeWindowBuilder.cs
@@ -0,0 +1,43 @@
+using Timeboxer.Domain.Models;
+
+namespace Timeboxer.Domain.Scheduling;
+
+public static class FreeWindowBuilder
+{
+ public static List<(DateTimeOffset Start, DateTimeOffset End)> BuildFreeWindows(DateTime start, DateTime end, IEnumerable events, Settings settings)
+ {
+ var free = new List<(DateTimeOffset Start, DateTimeOffset End)>();
+ for (var day = start.Date; day < end.Date; day = day.AddDays(1))
+ {
+ var dow = day.DayOfWeek;
+ if (!settings.WorkWindows.TryGetValue(dow, out var windows)) continue;
+ foreach (var w in windows)
+ {
+ var ws = new DateTimeOffset(day + w.Start, TimeSpan.Zero);
+ var we = new DateTimeOffset(day + w.End, TimeSpan.Zero);
+ var spans = new List<(DateTimeOffset Start, DateTimeOffset End)> { (ws, we) };
+ foreach (var ev in events.Where(e => e.Start.Date == day))
+ {
+ var newSpans = new List<(DateTimeOffset Start, DateTimeOffset End)>();
+ foreach (var span in spans)
+ {
+ if (ev.End <= span.Start || ev.Start >= span.End)
+ {
+ newSpans.Add(span);
+ }
+ else
+ {
+ if (ev.Start > span.Start)
+ newSpans.Add((span.Start, ev.Start));
+ if (ev.End < span.End)
+ newSpans.Add((ev.End, span.End));
+ }
+ }
+ spans = newSpans;
+ }
+ free.AddRange(spans);
+ }
+ }
+ return free;
+ }
+}
diff --git a/src/Timeboxer.Domain/Scheduling/Heuristics.cs b/src/Timeboxer.Domain/Scheduling/Heuristics.cs
new file mode 100644
index 0000000..c19288d
--- /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 OrderTasks(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..e2d39a8
--- /dev/null
+++ b/src/Timeboxer.Domain/Scheduling/Planner.cs
@@ -0,0 +1,86 @@
+using Timeboxer.Domain.Models;
+using Timeboxer.Domain.Utils;
+
+namespace Timeboxer.Domain.Scheduling;
+
+public class PlannerResult
+{
+ public List Timeboxes { get; } = new();
+ public List Overflow { get; } = new();
+}
+
+public static class Planner
+{
+ public static PlannerResult Plan(IEnumerable tasks, IEnumerable events, Settings settings, DateTime start, DateTime end)
+ {
+ var result = new PlannerResult();
+ var free = FreeWindowBuilder.BuildFreeWindows(start, end, events, settings)
+ .OrderBy(f => f.Start)
+ .ToList();
+ var queue = new Queue<(DateTimeOffset Start, DateTimeOffset End)>(free);
+
+ foreach (var task in TagBatching.Apply(Heuristics.OrderTasks(tasks), settings.TagClusteringEnabled))
+ {
+ var remaining = (int)Math.Ceiling(task.EstimateMinutes * (1 + settings.Contingency[task.Difficulty] / 100.0));
+ var beforeDeadline = queue.Where(f => f.Start < task.Deadline).ToList();
+ queue = new Queue<(DateTimeOffset Start, DateTimeOffset End)>(beforeDeadline);
+ if (queue.Count == 0)
+ {
+ result.Overflow.Add(task);
+ continue;
+ }
+ while (remaining > 0 && queue.Count > 0)
+ {
+ var (s, e) = queue.Dequeue();
+ if (s >= task.Deadline) break;
+ if (e > task.Deadline) e = task.Deadline;
+ var available = (int)(e - s).TotalMinutes;
+ if (available <= 0) continue;
+ var chunk = Math.Min(remaining, Math.Min(settings.MaxTimeboxMinutes, available));
+ var tb = new Timebox
+ {
+ TaskId = task.Id,
+ Start = s,
+ End = s.AddMinutes(chunk),
+ Kind = TimeboxKind.Work,
+ PrimaryTag = task.Tags.FirstOrDefault()
+ };
+ result.Timeboxes.Add(tb);
+ remaining -= chunk;
+ var nextStart = tb.End.AddMinutes(settings.MinBreakMinutes);
+ result.Timeboxes.Add(new Timebox
+ {
+ Start = tb.End,
+ End = tb.End.AddMinutes(settings.MinBreakMinutes),
+ Kind = TimeboxKind.Break
+ });
+ if (nextStart < e)
+ queue.Enqueue((nextStart, e));
+ }
+ if (remaining > 0)
+ result.Overflow.Add(task);
+ }
+
+ // daily buffer
+ var grouped = result.Timeboxes.Where(t => t.Kind == TimeboxKind.Work)
+ .GroupBy(t => t.Start.Date);
+ foreach (var g in grouped)
+ {
+ var workMinutes = g.Sum(t => (int)(t.End - t.Start).TotalMinutes);
+ var bufferMinutes = (int)Math.Ceiling(workMinutes * settings.DailyBufferPercent / 100.0);
+ if (bufferMinutes > 0)
+ {
+ var endOfDay = g.Max(t => t.End);
+ result.Timeboxes.Add(new Timebox
+ {
+ Start = endOfDay,
+ End = endOfDay.AddMinutes(bufferMinutes),
+ Kind = TimeboxKind.Buffer
+ });
+ }
+ }
+
+ result.Timeboxes.Sort((a, b) => a.Start.CompareTo(b.Start));
+ return result;
+ }
+}
diff --git a/src/Timeboxer.Domain/Scheduling/TagBatching.cs b/src/Timeboxer.Domain/Scheduling/TagBatching.cs
new file mode 100644
index 0000000..646dbb8
--- /dev/null
+++ b/src/Timeboxer.Domain/Scheduling/TagBatching.cs
@@ -0,0 +1,12 @@
+using Timeboxer.Domain.Models;
+
+namespace Timeboxer.Domain.Scheduling;
+
+public static class TagBatching
+{
+ public static IEnumerable Apply(IEnumerable tasks, bool enabled)
+ {
+ if (!enabled) return 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..9388618
--- /dev/null
+++ b/src/Timeboxer.Domain/Scheduling/Validation.cs
@@ -0,0 +1,22 @@
+using Timeboxer.Domain.Models;
+
+namespace Timeboxer.Domain.Scheduling;
+
+public static class Validation
+{
+ public static bool Validate(IEnumerable timeboxes, Settings settings)
+ {
+ var ordered = timeboxes.OrderBy(t => t.Start).ToList();
+ for (int i = 1; i < ordered.Count; i++)
+ {
+ if (ordered[i].Start < ordered[i - 1].End)
+ return false;
+ if (ordered[i - 1].Kind == TimeboxKind.Work && ordered[i].Kind == TimeboxKind.Work)
+ {
+ if ((ordered[i].Start - ordered[i - 1].End).TotalMinutes < settings.MinBreakMinutes)
+ 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..fb938da
--- /dev/null
+++ b/src/Timeboxer.Domain/Timeboxer.Domain.csproj
@@ -0,0 +1,10 @@
+
+
+ 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..bad8b96
--- /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 minutes) => TimeSpan.FromMinutes(minutes);
+}
diff --git a/src/Timeboxer.Domain/Utils/Parsing.cs b/src/Timeboxer.Domain/Utils/Parsing.cs
new file mode 100644
index 0000000..71eee65
--- /dev/null
+++ b/src/Timeboxer.Domain/Utils/Parsing.cs
@@ -0,0 +1,46 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Timeboxer.Domain.Models;
+
+namespace Timeboxer.Domain.Utils;
+
+public static class Parsing
+{
+ private static readonly JsonSerializerOptions Options = new JsonSerializerOptions
+ {
+ PropertyNameCaseInsensitive = true,
+ Converters = { new JsonStringEnumConverter() }
+ };
+
+ public static List LoadEvents(string json)
+ => JsonSerializer.Deserialize>(json, Options) ?? new();
+
+ public static List LoadTasks(string json)
+ => JsonSerializer.Deserialize>(json, Options) ?? new();
+
+ public static Settings LoadSettings(string json)
+ {
+ var doc = JsonDocument.Parse(json);
+ var s = new Settings();
+ if (doc.RootElement.TryGetProperty("MaxTimeboxMinutes", out var mt)) s.MaxTimeboxMinutes = mt.GetInt32();
+ if (doc.RootElement.TryGetProperty("MinBreakMinutes", out var mb)) s.MinBreakMinutes = mb.GetInt32();
+ if (doc.RootElement.TryGetProperty("DailyBufferPercent", out var db)) s.DailyBufferPercent = db.GetInt32();
+ if (doc.RootElement.TryGetProperty("TagClusteringEnabled", out var tc)) s.TagClusteringEnabled = tc.GetBoolean();
+ if (doc.RootElement.TryGetProperty("WorkWindows", out var ww))
+ {
+ foreach (var dayProp in ww.EnumerateObject())
+ {
+ if (!Enum.TryParse(dayProp.Name, true, out var day)) continue;
+ var list = new List();
+ foreach (var arr in dayProp.Value.EnumerateArray())
+ {
+ var start = TimeSpan.Parse(arr[0].GetString()!);
+ var end = TimeSpan.Parse(arr[1].GetString()!);
+ list.Add(new WorkWindow(day, start, end));
+ }
+ s.WorkWindows[day] = list;
+ }
+ }
+ return s;
+ }
+}
diff --git a/src/Timeboxer.Infrastructure.Google/GoogleAuth.cs b/src/Timeboxer.Infrastructure.Google/GoogleAuth.cs
new file mode 100644
index 0000000..a7cbb08
--- /dev/null
+++ b/src/Timeboxer.Infrastructure.Google/GoogleAuth.cs
@@ -0,0 +1,6 @@
+namespace Timeboxer.Infrastructure.Google;
+
+public static class GoogleAuth
+{
+ // Placeholder for OAuth logic.
+}
diff --git a/src/Timeboxer.Infrastructure.Google/GoogleCalendarProvider.cs b/src/Timeboxer.Infrastructure.Google/GoogleCalendarProvider.cs
new file mode 100644
index 0000000..1a36cc5
--- /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(DateTime startInclusive, DateTime 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..0743344
--- /dev/null
+++ b/src/Timeboxer.Infrastructure.Google/GoogleMappers.cs
@@ -0,0 +1,6 @@
+namespace Timeboxer.Infrastructure.Google;
+
+public static class GoogleMappers
+{
+ // Placeholder for mapping between Google API models and 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..11b27cb
--- /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..761d3c6
--- /dev/null
+++ b/src/Timeboxer.Infrastructure.Google/appsettings.sample.json
@@ -0,0 +1,4 @@
+{
+ "ClientId": "YOUR_CLIENT_ID",
+ "ClientSecret": "YOUR_CLIENT_SECRET"
+}
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..33ef0b8
--- /dev/null
+++ b/src/Timeboxer.Infrastructure.Google/docs/GOOGLE_SETUP.md
@@ -0,0 +1,3 @@
+# Google API setup
+
+Instructions on creating OAuth credentials for Google Calendar.
diff --git a/tests/Timeboxer.Domain.Tests/BreaksAndBuffersTests.cs b/tests/Timeboxer.Domain.Tests/BreaksAndBuffersTests.cs
new file mode 100644
index 0000000..b698431
--- /dev/null
+++ b/tests/Timeboxer.Domain.Tests/BreaksAndBuffersTests.cs
@@ -0,0 +1,21 @@
+using Xunit;
+using Timeboxer.Domain.Models;
+using Timeboxer.Domain.Scheduling;
+using Timeboxer.Domain.Utils;
+using FluentAssertions;
+
+namespace Timeboxer.Domain.Tests;
+
+public class BreaksAndBuffersTests
+{
+ [Fact]
+ public void AddsDailyBuffer()
+ {
+ var tasks = Parsing.LoadTasks(File.ReadAllText("Fixtures/sample_tasks.json"));
+ var events = Parsing.LoadEvents(File.ReadAllText("Fixtures/sample_events.json"));
+ var settings = Parsing.LoadSettings(File.ReadAllText("Fixtures/sample_settings.json"));
+ var result = Planner.Plan(tasks, events, settings, new DateTime(2025,8,26), new DateTime(2025,9,2));
+ var buffers = result.Timeboxes.Where(t=>t.Kind==TimeboxKind.Buffer).ToList();
+ buffers.Should().NotBeEmpty();
+ }
+}
diff --git a/tests/Timeboxer.Domain.Tests/DeadlineFeasibilityTests.cs b/tests/Timeboxer.Domain.Tests/DeadlineFeasibilityTests.cs
new file mode 100644
index 0000000..6177176
--- /dev/null
+++ b/tests/Timeboxer.Domain.Tests/DeadlineFeasibilityTests.cs
@@ -0,0 +1,26 @@
+using Xunit;
+using Timeboxer.Domain.Models;
+using Timeboxer.Domain.Scheduling;
+using FluentAssertions;
+
+namespace Timeboxer.Domain.Tests;
+
+public class DeadlineFeasibilityTests
+{
+ [Fact]
+ public void DetectsOverflow()
+ {
+ var settings = new Settings
+ {
+ WorkWindows = new()
+ {
+ [DayOfWeek.Monday] = new List{ new(DayOfWeek.Monday, TimeSpan.FromHours(9), TimeSpan.FromHours(10)) }
+ },
+ MinBreakMinutes = 10,
+ MaxTimeboxMinutes = 50
+ };
+ var task = new TaskItem { Id="T", EstimateMinutes=120, Deadline=new DateTimeOffset(2025,8,25,12,0,0,TimeSpan.Zero) };
+ var result = Planner.Plan(new[]{task}, Array.Empty(), settings, new DateTime(2025,8,25), new DateTime(2025,8,26));
+ result.Overflow.Should().Contain(task);
+ }
+}
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..7154101
--- /dev/null
+++ b/tests/Timeboxer.Domain.Tests/Fixtures/expected_plan_day1.txt
@@ -0,0 +1,2 @@
+2025-08-26 08:30-09:20 Work T2
+2025-08-26 09:20-09:30 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..075554b
--- /dev/null
+++ b/tests/Timeboxer.Domain.Tests/Fixtures/sample_settings.json
@@ -0,0 +1,27 @@
+{
+ "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": []
+ },
+ "DeepWorkWindows": {
+ "Monday": [["08:30","11:30"]],
+ "Tuesday": [["08:30","11:30"]],
+ "Wednesday": [["08:30","11:30"]],
+ "Thursday": [["08:30","11:30"]],
+ "Friday": [["08:30","11:30"]],
+ "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..b2c56e2
--- /dev/null
+++ b/tests/Timeboxer.Domain.Tests/Fixtures/sample_tasks.json
@@ -0,0 +1,6 @@
+[
+ { "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..0b222f5
--- /dev/null
+++ b/tests/Timeboxer.Domain.Tests/FreeWindowBuilderTests.cs
@@ -0,0 +1,30 @@
+using Xunit;
+using Timeboxer.Domain.Models;
+using Timeboxer.Domain.Scheduling;
+using FluentAssertions;
+
+namespace Timeboxer.Domain.Tests;
+
+public class FreeWindowBuilderTests
+{
+ [Fact]
+ public void SubtractsFixedEvents()
+ {
+ var settings = new Settings
+ {
+ WorkWindows = new()
+ {
+ [DayOfWeek.Monday] = new List{ new(DayOfWeek.Monday, TimeSpan.FromHours(9), TimeSpan.FromHours(17)) }
+ }
+ };
+ var events = new List
+ {
+ new() { Start = new DateTimeOffset(2025,8,25,10,0,0,TimeSpan.Zero), End = new DateTimeOffset(2025,8,25,11,0,0,TimeSpan.Zero) }
+ };
+ var free = FreeWindowBuilder.BuildFreeWindows(new DateTime(2025,8,25), new DateTime(2025,8,26), events, settings);
+ free.Should().HaveCount(2);
+ free[0].Start.Hour.Should().Be(9);
+ free[0].End.Hour.Should().Be(10);
+ free[1].Start.Hour.Should().Be(11);
+ }
+}
diff --git a/tests/Timeboxer.Domain.Tests/GlobalUsings.cs b/tests/Timeboxer.Domain.Tests/GlobalUsings.cs
new file mode 100644
index 0000000..c802f44
--- /dev/null
+++ b/tests/Timeboxer.Domain.Tests/GlobalUsings.cs
@@ -0,0 +1 @@
+global using Xunit;
diff --git a/tests/Timeboxer.Domain.Tests/HeuristicOrderingTests.cs b/tests/Timeboxer.Domain.Tests/HeuristicOrderingTests.cs
new file mode 100644
index 0000000..e42ae1f
--- /dev/null
+++ b/tests/Timeboxer.Domain.Tests/HeuristicOrderingTests.cs
@@ -0,0 +1,18 @@
+using Xunit;
+using Timeboxer.Domain.Models;
+using Timeboxer.Domain.Scheduling;
+using FluentAssertions;
+
+namespace Timeboxer.Domain.Tests;
+
+public class HeuristicOrderingTests
+{
+ [Fact]
+ public void OrdersByDeadlineThenPriority()
+ {
+ var t1 = new TaskItem { Id="1", EstimateMinutes=60, Deadline=new DateTimeOffset(2025,8,28,0,0,0,TimeSpan.Zero), Priority=Priority.P2 };
+ var t2 = new TaskItem { Id="2", EstimateMinutes=60, Deadline=new DateTimeOffset(2025,8,27,0,0,0,TimeSpan.Zero), Priority=Priority.P4 };
+ var ordered = Heuristics.OrderTasks(new[]{t1,t2}).Select(t=>t.Id).ToList();
+ ordered.Should().Equal("2","1");
+ }
+}
diff --git a/tests/Timeboxer.Domain.Tests/PlannerCoreTests.cs b/tests/Timeboxer.Domain.Tests/PlannerCoreTests.cs
new file mode 100644
index 0000000..84bcf7f
--- /dev/null
+++ b/tests/Timeboxer.Domain.Tests/PlannerCoreTests.cs
@@ -0,0 +1,21 @@
+using Xunit;
+using Timeboxer.Domain.Models;
+using Timeboxer.Domain.Scheduling;
+using Timeboxer.Domain.Utils;
+using FluentAssertions;
+
+namespace Timeboxer.Domain.Tests;
+
+public class PlannerCoreTests
+{
+ [Fact]
+ public void SchedulesTasksBeforeDeadlines()
+ {
+ var tasks = Parsing.LoadTasks(File.ReadAllText("Fixtures/sample_tasks.json"));
+ var events = Parsing.LoadEvents(File.ReadAllText("Fixtures/sample_events.json"));
+ var settings = Parsing.LoadSettings(File.ReadAllText("Fixtures/sample_settings.json"));
+ var result = Planner.Plan(tasks, events, settings, new DateTime(2025,8,26), new DateTime(2025,9,2));
+ result.Overflow.Should().BeEmpty();
+ result.Timeboxes.Should().NotBeEmpty();
+ }
+}
diff --git a/tests/Timeboxer.Domain.Tests/PropertyInvariantsTests.cs b/tests/Timeboxer.Domain.Tests/PropertyInvariantsTests.cs
new file mode 100644
index 0000000..6aa2cec
--- /dev/null
+++ b/tests/Timeboxer.Domain.Tests/PropertyInvariantsTests.cs
@@ -0,0 +1,20 @@
+using Xunit;
+using Timeboxer.Domain.Models;
+using Timeboxer.Domain.Scheduling;
+using Timeboxer.Domain.Utils;
+using FluentAssertions;
+
+namespace Timeboxer.Domain.Tests;
+
+public class PropertyInvariantsTests
+{
+ [Fact]
+ public void WorkTimeboxesRespectMaxDuration()
+ {
+ var tasks = Parsing.LoadTasks(File.ReadAllText("Fixtures/sample_tasks.json"));
+ var events = Parsing.LoadEvents(File.ReadAllText("Fixtures/sample_events.json"));
+ var settings = Parsing.LoadSettings(File.ReadAllText("Fixtures/sample_settings.json"));
+ var result = Planner.Plan(tasks, events, settings, new DateTime(2025,8,26), new DateTime(2025,9,2));
+ result.Timeboxes.Where(t=>t.Kind==TimeboxKind.Work).All(t=> (t.End - t.Start).TotalMinutes <= settings.MaxTimeboxMinutes).Should().BeTrue();
+ }
+}
diff --git a/tests/Timeboxer.Domain.Tests/TagBatchingTests.cs b/tests/Timeboxer.Domain.Tests/TagBatchingTests.cs
new file mode 100644
index 0000000..9fa718b
--- /dev/null
+++ b/tests/Timeboxer.Domain.Tests/TagBatchingTests.cs
@@ -0,0 +1,19 @@
+using Xunit;
+using Timeboxer.Domain.Models;
+using Timeboxer.Domain.Scheduling;
+using FluentAssertions;
+
+namespace Timeboxer.Domain.Tests;
+
+public class TagBatchingTests
+{
+ [Fact]
+ public void GroupsByPrimaryTag()
+ {
+ var t1 = new TaskItem{ Id="1", Tags = new(){"b"} };
+ var t2 = new TaskItem{ Id="2", Tags = new(){"a"} };
+ var t3 = new TaskItem{ Id="3", Tags = new(){"a"} };
+ var ordered = TagBatching.Apply(new[]{t1,t2,t3}, true).Select(t=>t.Id).ToList();
+ ordered.Should().Equal("2","3","1");
+ }
+}
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..91d174c
--- /dev/null
+++ b/tests/Timeboxer.Domain.Tests/Timeboxer.Domain.Tests.csproj
@@ -0,0 +1,28 @@
+
+
+ net8.0
+ enable
+ enable
+ false
+ true
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/Timeboxer.Infrastructure.Tests/Fakes/FakeCalendarProvider.cs b/tests/Timeboxer.Infrastructure.Tests/Fakes/FakeCalendarProvider.cs
new file mode 100644
index 0000000..539bb68
--- /dev/null
+++ b/tests/Timeboxer.Infrastructure.Tests/Fakes/FakeCalendarProvider.cs
@@ -0,0 +1,16 @@
+using Timeboxer.Domain.Abstractions;
+using Timeboxer.Domain.Models;
+
+namespace Timeboxer.Infrastructure.Tests.Fakes;
+
+public class FakeCalendarProvider : ICalendarProvider
+{
+ public string ProviderName => "Fake";
+ public List Events { get; } = new();
+
+ public Task> GetFixedEventsAsync(DateTime startInclusive, DateTime 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/GlobalUsings.cs b/tests/Timeboxer.Infrastructure.Tests/GlobalUsings.cs
new file mode 100644
index 0000000..c802f44
--- /dev/null
+++ b/tests/Timeboxer.Infrastructure.Tests/GlobalUsings.cs
@@ -0,0 +1 @@
+global using Xunit;
diff --git a/tests/Timeboxer.Infrastructure.Tests/GoogleProviderMockTests.cs b/tests/Timeboxer.Infrastructure.Tests/GoogleProviderMockTests.cs
new file mode 100644
index 0000000..7ce9054
--- /dev/null
+++ b/tests/Timeboxer.Infrastructure.Tests/GoogleProviderMockTests.cs
@@ -0,0 +1,17 @@
+using Xunit;
+using Timeboxer.Infrastructure.Tests.Fakes;
+using FluentAssertions;
+
+namespace Timeboxer.Infrastructure.Tests;
+
+public class GoogleProviderMockTests
+{
+ [Fact]
+ public async Task FakeProviderReturnsEvents()
+ {
+ var provider = new FakeCalendarProvider();
+ provider.Events.Add(new Domain.Models.CalendarEvent{ Title="A" });
+ var events = await provider.GetFixedEventsAsync(DateTime.Now, DateTime.Now, 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..c92c236
--- /dev/null
+++ b/tests/Timeboxer.Infrastructure.Tests/Timeboxer.Infrastructure.Tests.csproj
@@ -0,0 +1,26 @@
+
+
+ net8.0
+ enable
+ enable
+ false
+ true
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+