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