diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..98538c8
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,28 @@
+name: CI
+
+on:
+ push:
+ branches: [ "main" ]
+ pull_request:
+ branches: [ "main" ]
+
+jobs:
+ build-and-test:
+ runs-on: windows-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup .NET 10
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '10.x'
+
+ - name: Restore
+ run: dotnet restore
+
+ - name: Build
+ run: dotnet build --no-restore --configuration Release
+
+ - name: Test
+ run: dotnet test --no-build --configuration Release --verbosity normal
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..9326ded
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+# Visual Studio
+.vs/
+*.user
+
+# Build output
+**/bin/
+**/obj/
+
+# NuGet
+*.nupkg
diff --git a/CalculateBestDaysOff.Domain/CalculateBestDaysOff.Domain.csproj b/CalculateBestDaysOff.Domain/CalculateBestDaysOff.Domain.csproj
new file mode 100644
index 0000000..6d36c6d
--- /dev/null
+++ b/CalculateBestDaysOff.Domain/CalculateBestDaysOff.Domain.csproj
@@ -0,0 +1,9 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
diff --git a/CalculateBestDaysOff.Domain/FrenchPublicHolidays.cs b/CalculateBestDaysOff.Domain/FrenchPublicHolidays.cs
new file mode 100644
index 0000000..9655468
--- /dev/null
+++ b/CalculateBestDaysOff.Domain/FrenchPublicHolidays.cs
@@ -0,0 +1,43 @@
+namespace CalculateBestDaysOff.Domain;
+
+public static class FrenchPublicHolidays
+{
+ public static IReadOnlyDictionary For(int year)
+ {
+ var easter = ComputeEaster(year);
+ return new Dictionary
+ {
+ [new(year, 1, 1)] = "Jour de l'An",
+ [easter.AddDays(1)] = "Lundi de Pâques",
+ [new(year, 5, 1)] = "Fête du Travail",
+ [new(year, 5, 8)] = "Victoire 1945",
+ [easter.AddDays(39)] = "Ascension",
+ [easter.AddDays(50)] = "Lundi de Pentecôte",
+ [new(year, 7, 14)] = "Fête Nationale",
+ [new(year, 8, 15)] = "Assomption",
+ [new(year, 11, 1)] = "Toussaint",
+ [new(year, 11, 11)] = "Armistice",
+ [new(year, 12, 25)] = "Noël",
+ };
+ }
+
+ // Algorithme de Meeus/Jones/Butcher
+ public static DateOnly ComputeEaster(int year)
+ {
+ int a = year % 19;
+ int b = year / 100;
+ int c = year % 100;
+ int d = b / 4;
+ int e = b % 4;
+ int f = (b + 8) / 25;
+ int g = (b - f + 1) / 3;
+ int h = (19 * a + b - d - g + 15) % 30;
+ int i = c / 4;
+ int k = c % 4;
+ int l = (32 + 2 * e + 2 * i - h - k) % 7;
+ int m = (a + 11 * h + 22 * l) / 451;
+ int month = (h + l - 7 * m + 114) / 31;
+ int day = ((h + l - 7 * m + 114) % 31) + 1;
+ return new DateOnly(year, month, day);
+ }
+}
diff --git a/CalculateBestDaysOff.Domain/VacationOpportunity.cs b/CalculateBestDaysOff.Domain/VacationOpportunity.cs
new file mode 100644
index 0000000..db620ca
--- /dev/null
+++ b/CalculateBestDaysOff.Domain/VacationOpportunity.cs
@@ -0,0 +1,10 @@
+namespace CalculateBestDaysOff.Domain;
+
+public sealed record VacationOpportunity(
+ DateOnly Start,
+ DateOnly End,
+ int TotalDaysOff,
+ int DaysTaken,
+ double Ratio,
+ IReadOnlyList HolidaysIncluded,
+ IReadOnlyList WorkDaysTaken);
diff --git a/CalculateBestDaysOff.Domain/VacationOptimizer.cs b/CalculateBestDaysOff.Domain/VacationOptimizer.cs
new file mode 100644
index 0000000..3c1dff8
--- /dev/null
+++ b/CalculateBestDaysOff.Domain/VacationOptimizer.cs
@@ -0,0 +1,68 @@
+namespace CalculateBestDaysOff.Domain;
+
+public static class VacationOptimizer
+{
+ ///
+ /// Trouve toutes les opportunités de congés pour l'année donnée.
+ /// Un fenêtre [start, end] est valide si elle contient entre 1 et maxDaysTaken jours ouvrés
+ /// et est maximale (ne peut pas être étendue sans ajouter un jour ouvré).
+ ///
+ public static IReadOnlyList FindBest(int year, int maxDaysTaken)
+ {
+ var holidays = FrenchPublicHolidays.For(year);
+ var jan1 = new DateOnly(year, 1, 1);
+ var dec31 = new DateOnly(year, 12, 31);
+
+ bool IsFree(DateOnly d) =>
+ d.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday
+ || holidays.ContainsKey(d);
+
+ var opportunities = new List();
+
+ for (var start = jan1; start <= dec31; start = start.AddDays(1))
+ {
+ // On ne démarre une fenêtre qu'au début d'un bloc libre (ou en Jan 1)
+ // pour garantir que la fenêtre est maximale à gauche.
+ if (start != jan1 && IsFree(start.AddDays(-1)))
+ continue;
+
+ int workCount = 0;
+ for (var end = start; end <= dec31; end = end.AddDays(1))
+ {
+ if (!IsFree(end)) workCount++;
+ if (workCount > maxDaysTaken) break;
+
+ // La fenêtre est maximale à droite si le lendemain est ouvré (ou fin d'année)
+ bool rightBlocked = end == dec31 || !IsFree(end.AddDays(1));
+ if (rightBlocked && workCount > 0)
+ {
+ int total = end.DayNumber - start.DayNumber + 1;
+ var holidaysInWindow = EnumerateDays(start, end)
+ .Where(holidays.ContainsKey)
+ .Select(d => holidays[d])
+ .ToList();
+
+ var workDaysInWindow = EnumerateDays(start, end)
+ .Where(d => !IsFree(d))
+ .ToList();
+
+ opportunities.Add(new VacationOpportunity(
+ start, end, total, workCount,
+ (double)total / workCount,
+ holidaysInWindow,
+ workDaysInWindow));
+ }
+ }
+ }
+
+ return [.. opportunities
+ .OrderByDescending(o => o.Ratio)
+ .ThenByDescending(o => o.TotalDaysOff)];
+ }
+
+ private static IEnumerable EnumerateDays(DateOnly start, DateOnly end)
+ {
+ for (var d = start; d <= end; d = d.AddDays(1))
+ yield return d;
+ }
+}
diff --git a/CalculateBestDaysOff.Tests/CalculateBestDaysOff.Tests.csproj b/CalculateBestDaysOff.Tests/CalculateBestDaysOff.Tests.csproj
new file mode 100644
index 0000000..0441cfd
--- /dev/null
+++ b/CalculateBestDaysOff.Tests/CalculateBestDaysOff.Tests.csproj
@@ -0,0 +1,25 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/CalculateBestDaysOff.Tests/FrenchPublicHolidaysTests.cs b/CalculateBestDaysOff.Tests/FrenchPublicHolidaysTests.cs
new file mode 100644
index 0000000..e3193ab
--- /dev/null
+++ b/CalculateBestDaysOff.Tests/FrenchPublicHolidaysTests.cs
@@ -0,0 +1,50 @@
+using CalculateBestDaysOff.Domain;
+
+namespace CalculateBestDaysOff.Tests;
+
+public class FrenchPublicHolidaysTests
+{
+ [Theory]
+ [InlineData(2024, 3, 31)] // Pâques 2024
+ [InlineData(2025, 4, 20)] // Pâques 2025
+ [InlineData(2026, 4, 5)] // Pâques 2026
+ public void ComputeEaster_ReturnsKnownDate(int year, int month, int day)
+ {
+ var easter = FrenchPublicHolidays.ComputeEaster(year);
+ Assert.Equal(new DateOnly(year, month, day), easter);
+ }
+
+ [Fact]
+ public void For_Returns11Holidays()
+ {
+ var holidays = FrenchPublicHolidays.For(2025);
+ Assert.Equal(11, holidays.Count);
+ }
+
+ [Theory]
+ [InlineData(2025, 1, 1, "Jour de l'An")]
+ [InlineData(2025, 5, 1, "Fête du Travail")]
+ [InlineData(2025, 5, 8, "Victoire 1945")]
+ [InlineData(2025, 7, 14, "Fête Nationale")]
+ [InlineData(2025, 8, 15, "Assomption")]
+ [InlineData(2025, 11, 1, "Toussaint")]
+ [InlineData(2025, 11, 11,"Armistice")]
+ [InlineData(2025, 12, 25,"Noël")]
+ public void For_ContainsFixedHolidays(int year, int month, int day, string name)
+ {
+ var holidays = FrenchPublicHolidays.For(year);
+ var date = new DateOnly(year, month, day);
+ Assert.True(holidays.ContainsKey(date));
+ Assert.Equal(name, holidays[date]);
+ }
+
+ [Fact]
+ public void For_2025_EasterBasedHolidaysAreCorrect()
+ {
+ // Pâques 2025 = 20 avril
+ var holidays = FrenchPublicHolidays.For(2025);
+ Assert.True(holidays.ContainsKey(new DateOnly(2025, 4, 21))); // Lundi de Pâques
+ Assert.True(holidays.ContainsKey(new DateOnly(2025, 5, 29))); // Ascension (+39)
+ Assert.True(holidays.ContainsKey(new DateOnly(2025, 6, 9))); // Lundi de Pentecôte (+50)
+ }
+}
diff --git a/CalculateBestDaysOff.Tests/VacationOptimizerTests.cs b/CalculateBestDaysOff.Tests/VacationOptimizerTests.cs
new file mode 100644
index 0000000..7ef815b
--- /dev/null
+++ b/CalculateBestDaysOff.Tests/VacationOptimizerTests.cs
@@ -0,0 +1,73 @@
+using CalculateBestDaysOff.Domain;
+
+namespace CalculateBestDaysOff.Tests;
+
+public class VacationOptimizerTests
+{
+ [Fact]
+ public void FindBest_ReturnsNonEmptyList()
+ {
+ var results = VacationOptimizer.FindBest(2025, 5);
+ Assert.NotEmpty(results);
+ }
+
+ [Fact]
+ public void FindBest_ResultsAreSortedByRatioDescending()
+ {
+ var results = VacationOptimizer.FindBest(2025, 5);
+ for (int i = 0; i < results.Count - 1; i++)
+ Assert.True(results[i].Ratio >= results[i + 1].Ratio,
+ $"Résultat {i} (ratio {results[i].Ratio}) < résultat {i + 1} (ratio {results[i + 1].Ratio})");
+ }
+
+ [Fact]
+ public void FindBest_NoDaysTakenExceedsMax()
+ {
+ const int max = 3;
+ var results = VacationOptimizer.FindBest(2025, max);
+ Assert.All(results, o => Assert.True(o.DaysTaken <= max));
+ }
+
+ [Fact]
+ public void FindBest_RatioEqualsTotal_DividedBy_Taken()
+ {
+ var results = VacationOptimizer.FindBest(2025, 5);
+ Assert.All(results, o =>
+ Assert.Equal((double)o.TotalDaysOff / o.DaysTaken, o.Ratio, precision: 10));
+ }
+
+ [Fact]
+ public void FindBest_NoWindowWithZeroDaysTaken()
+ {
+ var results = VacationOptimizer.FindBest(2025, 5);
+ Assert.All(results, o => Assert.True(o.DaysTaken >= 1));
+ }
+
+ [Fact]
+ public void FindBest_2025_BestOpportunityAroundMay1()
+ {
+ // 1er mai 2025 = jeudi (férié), 2 mai = vendredi (ouvré), 3-4 mai = week-end
+ // Poser 1 jour (2 mai) → 4 jours off (1-4 mai) → ratio 4
+ // Pâques 2025 = 20 avril (dim), Lundi de Pâques = 21 avril
+ // Le week-end 19-20 + lundi férié 21 = 3 jours free
+ // Si on ajoute 22 avril (mardi, ouvré) → 4 jours (19-22), ratio 4
+ var results = VacationOptimizer.FindBest(2025, 1);
+ Assert.True(results[0].Ratio >= 4.0,
+ $"Meilleur ratio attendu >= 4, obtenu {results[0].Ratio}");
+ }
+
+ [Fact]
+ public void FindBest_WindowEndIsAlwaysAfterOrEqualStart()
+ {
+ var results = VacationOptimizer.FindBest(2025, 10);
+ Assert.All(results, o => Assert.True(o.End >= o.Start));
+ }
+
+ [Fact]
+ public void FindBest_TotalDaysOffMatchesDateRange()
+ {
+ var results = VacationOptimizer.FindBest(2025, 5);
+ Assert.All(results, o =>
+ Assert.Equal(o.End.DayNumber - o.Start.DayNumber + 1, o.TotalDaysOff));
+ }
+}
diff --git a/CalculateBestDaysOff.slnx b/CalculateBestDaysOff.slnx
new file mode 100644
index 0000000..01ad886
--- /dev/null
+++ b/CalculateBestDaysOff.slnx
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/CalculateBestDaysOff/CalculateBestDaysOff.csproj b/CalculateBestDaysOff/CalculateBestDaysOff.csproj
new file mode 100644
index 0000000..c14cc8d
--- /dev/null
+++ b/CalculateBestDaysOff/CalculateBestDaysOff.csproj
@@ -0,0 +1,18 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
diff --git a/CalculateBestDaysOff/Program.cs b/CalculateBestDaysOff/Program.cs
new file mode 100644
index 0000000..09e2628
--- /dev/null
+++ b/CalculateBestDaysOff/Program.cs
@@ -0,0 +1,162 @@
+using System.Globalization;
+using CalculateBestDaysOff.Domain;
+using Spectre.Console;
+
+// --- Parsing des arguments ---
+int year = DateTime.Now.Year;
+int maxDays = 10;
+int top = 10;
+int weeks = 0; // --weeks N : filtre min N semaines consécutives off
+bool maxDaysExplicit = false;
+
+if (args.Contains("--help") || args.Contains("-h"))
+{
+ AnsiConsole.Write(new FigletText("Conges").Color(Color.Orange1));
+
+ var help = new Table().NoBorder().HideHeaders()
+ .AddColumn(new TableColumn("").PadRight(2))
+ .AddColumn("");
+
+ help.AddRow("[bold yellow]USAGE[/]", "");
+ help.AddRow("", "[grey]dotnet run --[/] [cyan][[année]][/] [grey][[options]][/]");
+ help.AddRow("", "");
+ help.AddRow("[bold yellow]ARGUMENT[/]", "");
+ help.AddRow("[cyan]année[/]", $"Année à analyser [grey](défaut : {DateTime.Now.Year})[/]");
+ help.AddRow("", "");
+ help.AddRow("[bold yellow]OPTIONS[/]", "");
+ help.AddRow("[cyan]--weeks [/][grey][/]", "Affiche uniquement les blocs d'au moins [bold]n semaines[/] consécutives\n[grey]Ajuste automatiquement --max-days si nécessaire[/]");
+ help.AddRow("[cyan]--max-days[/] [grey][/]", $"Nombre maximum de jours de congé posés par opportunité [grey](défaut : 10)[/]");
+ help.AddRow("[cyan]--top[/] [grey][/]", $"Nombre de résultats à afficher [grey](défaut : 10)[/]");
+ help.AddRow("[cyan]--help[/][grey], -h[/]", "Affiche cette aide");
+ help.AddRow("", "");
+ help.AddRow("[bold yellow]EXEMPLES[/]", "");
+ help.AddRow("", "[grey]dotnet run --[/] [cyan]2025[/]");
+ help.AddRow("", "[grey]dotnet run --[/] [cyan]2026 --top 5[/]");
+ help.AddRow("", "[grey]dotnet run --[/] [cyan]2025 --weeks 2[/] [dim]# meilleurs blocs de 2 semaines[/]");
+ help.AddRow("", "[grey]dotnet run --[/] [cyan]2025 --weeks 2 --max-days 7 [dim]# idem, max 7 jours posés[/][/]");
+
+ AnsiConsole.Write(new Panel(help)
+ .Header("[bold]Aide[/]")
+ .BorderColor(Color.Orange1));
+ return;
+}
+
+for (int i = 0; i < args.Length; i++)
+{
+ if (args[i] == "--max-days" && i + 1 < args.Length && int.TryParse(args[i + 1], out int md))
+ {
+ maxDays = md; maxDaysExplicit = true; i++;
+ }
+ else if (args[i] == "--top" && i + 1 < args.Length && int.TryParse(args[i + 1], out int t))
+ {
+ top = t; i++;
+ }
+ else if (args[i] == "--weeks" && i + 1 < args.Length && int.TryParse(args[i + 1], out int w))
+ {
+ weeks = w; i++;
+ }
+ else if (int.TryParse(args[i], out int y) && y is >= 1900 and <= 2100)
+ {
+ year = y;
+ }
+}
+
+// Pour N semaines on a besoin d'au moins N*5 jours posables (cas sans aucun férié)
+if (weeks > 0 && !maxDaysExplicit)
+ maxDays = Math.Max(maxDays, weeks * 5);
+
+int minDays = weeks > 0 ? weeks * 7 : 0;
+
+// --- En-tête ---
+var fr = CultureInfo.GetCultureInfo("fr-FR");
+
+AnsiConsole.Write(new FigletText("Conges").Color(Color.Orange1));
+AnsiConsole.MarkupLine($"[grey]Année :[/] [bold yellow]{year}[/] " +
+ $"[grey]Max jours posés :[/] [bold yellow]{maxDays}[/] " +
+ $"[grey]Top :[/] [bold yellow]{top}[/]" +
+ (weeks > 0 ? $" [grey]Filtre :[/] [bold cyan]≥ {weeks} sem. ({minDays} j)[/]" : ""));
+AnsiConsole.WriteLine();
+
+// --- Jours fériés ---
+var holidaysPanel = new Table().NoBorder().HideHeaders();
+holidaysPanel.AddColumn(new TableColumn("").Padding(0, 0));
+holidaysPanel.AddColumn(new TableColumn("").Padding(0, 0));
+
+var holidays = FrenchPublicHolidays.For(year);
+foreach (var (date, name) in holidays.OrderBy(kv => kv.Key))
+ holidaysPanel.AddRow(
+ $"[dim]{date.ToString("ddd d MMM", fr)}[/]",
+ $"[dim italic]{Markup.Escape(name)}[/]");
+
+AnsiConsole.Write(new Panel(holidaysPanel)
+ .Header("[bold]Jours fériés[/]")
+ .BorderColor(Color.Grey));
+AnsiConsole.WriteLine();
+
+// --- Calcul ---
+var results = AnsiConsole
+ .Status()
+ .Spinner(Spinner.Known.Dots)
+ .Start("[yellow]Calcul en cours…[/]", _ =>
+ VacationOptimizer.FindBest(year, maxDays)
+ .Where(o => minDays == 0 || o.TotalDaysOff >= minDays)
+ .Take(top)
+ .ToList());
+
+if (results.Count == 0)
+{
+ AnsiConsole.MarkupLine($"[red]Aucune opportunité trouvée avec ≥ {minDays} jours consécutifs (max {maxDays} jours posés).[/]");
+ AnsiConsole.MarkupLine("[dim]Essayez d'augmenter --max-days.[/]");
+ return;
+}
+
+// --- Tableau des résultats ---
+string tableTitle = weeks > 0
+ ? $"[bold gold1]🏖 Top {results.Count} blocs de {weeks} semaine(s) — {year}[/]"
+ : $"[bold gold1]🏖 Top {results.Count} opportunités de congés {year}[/]";
+var table = new Table()
+ .Border(TableBorder.Rounded)
+ .BorderColor(Color.Grey)
+ .Title(tableTitle)
+ .AddColumn(new TableColumn("[bold]#[/]").RightAligned())
+ .AddColumn("[bold]Période[/]")
+ .AddColumn(new TableColumn("[bold]Durée[/]").RightAligned())
+ .AddColumn(new TableColumn("[bold]Posés[/]").RightAligned())
+ .AddColumn(new TableColumn("[bold]Ratio[/]").RightAligned())
+ .AddColumn("[bold]Fériés inclus[/]")
+ .AddColumn("[bold]Jours à poser[/]");
+
+for (int rank = 0; rank < results.Count; rank++)
+{
+ var o = results[rank];
+
+ var (ratioColor, ratioEmoji) = o.Ratio switch
+ {
+ >= 7 => ("bold gold1", "✨"),
+ >= 5 => ("bold green", "🌟"),
+ >= 3 => ("green", "👍"),
+ >= 2 => ("yellow", ""),
+ _ => ("white", ""),
+ };
+
+ string rankStr = rank == 0 ? "[bold gold1]🥇 1[/]"
+ : rank == 1 ? "[bold silver]🥈 2[/]"
+ : rank == 2 ? "[bold orange1]🥉 3[/]"
+ : $"[dim]{rank + 1}[/]";
+
+ string period = $"{o.Start.ToString("ddd d MMM", fr)} [dim]→[/] {o.End.ToString("ddd d MMM", fr)}";
+ string total = $"[bold]{o.TotalDaysOff} j[/]";
+ string taken = $"{o.DaysTaken} j";
+ string ratio = $"[{ratioColor}]{ratioEmoji} {o.Ratio:F1}[/]";
+ string feries = o.HolidaysIncluded.Count > 0
+ ? string.Join(", ", o.HolidaysIncluded.Select(Markup.Escape))
+ : "[dim]-[/]";
+ string workDays = string.Join(", ", o.WorkDaysTaken.Select(d => d.ToString("dd/MM", fr)));
+
+ table.AddRow(rankStr, period, total, taken, ratio, feries, workDays);
+}
+
+AnsiConsole.Write(table);
+AnsiConsole.WriteLine();
+AnsiConsole.MarkupLine("[dim]Ratio = jours off consécutifs obtenus / jours de congé posés[/]");
+
diff --git a/Conges/Conges.csproj b/Conges/Conges.csproj
new file mode 100644
index 0000000..a8b3618
--- /dev/null
+++ b/Conges/Conges.csproj
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+ WinExe
+ net10.0-windows
+ enable
+ true
+ enable
+
+
+
\ No newline at end of file
diff --git a/Conges/MainForm.Designer.cs b/Conges/MainForm.Designer.cs
new file mode 100644
index 0000000..7966bb1
--- /dev/null
+++ b/Conges/MainForm.Designer.cs
@@ -0,0 +1,20 @@
+namespace Conges;
+
+partial class MainForm
+{
+ private System.ComponentModel.IContainer components = null!;
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing && components != null) components.Dispose();
+ base.Dispose(disposing);
+ }
+
+ private void InitializeComponent()
+ {
+ components = new System.ComponentModel.Container();
+ SuspendLayout();
+ AutoScaleMode = AutoScaleMode.Font;
+ ResumeLayout(false);
+ }
+}
diff --git a/Conges/MainForm.cs b/Conges/MainForm.cs
new file mode 100644
index 0000000..4f4db1a
--- /dev/null
+++ b/Conges/MainForm.cs
@@ -0,0 +1,247 @@
+using System.Globalization;
+using CalculateBestDaysOff.Domain;
+
+namespace Conges;
+
+public partial class MainForm : Form
+{
+ private static readonly CultureInfo Fr = CultureInfo.GetCultureInfo("fr-FR");
+
+ // ── toolbar ──────────────────────────────────────────────────────────────
+ private readonly NumericUpDown _nudYear = new() { Minimum = 1900, Maximum = 2100, Width = 70 };
+ private readonly NumericUpDown _nudMaxDays = new() { Minimum = 1, Maximum = 60, Width = 55, Value = 10 };
+ private readonly NumericUpDown _nudWeeks = new() { Minimum = 0, Maximum = 12, Width = 55, Value = 0 };
+ private readonly NumericUpDown _nudTop = new() { Minimum = 1, Maximum = 200, Width = 55, Value = 10 };
+ private readonly Button _btnCalc = new() { Text = "Calculer ▶", AutoSize = true, FlatStyle = FlatStyle.Flat };
+
+ // ── layout ───────────────────────────────────────────────────────────────
+ private readonly DataGridView _grid = new();
+ private readonly Label _lblStatus = new() { Dock = DockStyle.Fill, TextAlign = ContentAlignment.MiddleLeft };
+
+ public MainForm()
+ {
+ InitializeComponent();
+ SetupForm();
+ _nudYear.Value = DateTime.Now.Year;
+ RunCalculation();
+ }
+
+ private void SetupForm()
+ {
+ // form
+ Text = "Congés";
+ MinimumSize = new Size(1100, 620);
+ Size = new Size(1200, 700);
+ StartPosition = FormStartPosition.CenterScreen;
+ BackColor = Color.FromArgb(30, 30, 30);
+ ForeColor = Color.WhiteSmoke;
+ Font = new Font("Segoe UI", 10f);
+
+ // ── toolbar panel ─────────────────────────────────────────────────
+ var toolbar = new FlowLayoutPanel
+ {
+ Dock = DockStyle.Top,
+ Height = 46,
+ BackColor = Color.FromArgb(40, 40, 40),
+ Padding = new Padding(8, 6, 8, 6),
+ FlowDirection = FlowDirection.LeftToRight,
+ WrapContents = false,
+ };
+
+ toolbar.Controls.Add(MakeLabel("Année :"));
+ StyleNud(_nudYear);
+ toolbar.Controls.Add(_nudYear);
+
+ toolbar.Controls.Add(MakeSep());
+ toolbar.Controls.Add(MakeLabel("Max jours posés :"));
+ StyleNud(_nudMaxDays);
+ toolbar.Controls.Add(_nudMaxDays);
+
+ toolbar.Controls.Add(MakeSep());
+ toolbar.Controls.Add(MakeLabel("Semaines min :"));
+ StyleNud(_nudWeeks);
+ var weeksNote = MakeLabel("(0 = tous)");
+ weeksNote.ForeColor = Color.Gray;
+ toolbar.Controls.Add(_nudWeeks);
+ toolbar.Controls.Add(weeksNote);
+
+ toolbar.Controls.Add(MakeSep());
+ toolbar.Controls.Add(MakeLabel("Top :"));
+ StyleNud(_nudTop);
+ toolbar.Controls.Add(_nudTop);
+
+ toolbar.Controls.Add(MakeSep());
+ StyleButton(_btnCalc);
+ toolbar.Controls.Add(_btnCalc);
+
+ // ── status bar ────────────────────────────────────────────────────
+ var statusBar = new Panel { Dock = DockStyle.Bottom, Height = 26, BackColor = Color.FromArgb(40, 40, 40) };
+ _lblStatus.ForeColor = Color.Gray;
+ _lblStatus.Font = new Font("Segoe UI", 9f);
+ statusBar.Controls.Add(_lblStatus);
+
+ // ── main area ────────────────────────────────────────────────────
+ SetupGrid();
+
+ Controls.Add(_grid);
+ Controls.Add(toolbar);
+ Controls.Add(statusBar);
+
+ _btnCalc.Click += (_, _) => RunCalculation();
+ _nudYear.ValueChanged += (_, _) => RunCalculation();
+ }
+
+ private void SetupGrid()
+ {
+ _grid.Dock = DockStyle.Fill;
+ _grid.ReadOnly = true;
+ _grid.AllowUserToAddRows = false;
+ _grid.AllowUserToResizeRows = false;
+ _grid.RowHeadersVisible = false;
+ _grid.SelectionMode = DataGridViewSelectionMode.FullRowSelect;
+ _grid.BackgroundColor = Color.FromArgb(30, 30, 30);
+ _grid.GridColor = Color.FromArgb(60, 60, 60);
+ _grid.BorderStyle = BorderStyle.None;
+ _grid.CellBorderStyle = DataGridViewCellBorderStyle.SingleHorizontal;
+ _grid.DefaultCellStyle = new DataGridViewCellStyle
+ {
+ BackColor = Color.FromArgb(38, 38, 38),
+ ForeColor = Color.WhiteSmoke,
+ SelectionBackColor = Color.FromArgb(70, 100, 140),
+ SelectionForeColor = Color.White,
+ Font = new Font("Segoe UI", 10f),
+ Padding = new Padding(4, 2, 4, 2),
+ };
+ _grid.ColumnHeadersDefaultCellStyle = new DataGridViewCellStyle
+ {
+ BackColor = Color.FromArgb(50, 50, 50),
+ ForeColor = Color.Orange,
+ Font = new Font("Segoe UI", 10f, FontStyle.Bold),
+ Padding = new Padding(4, 4, 4, 4),
+ };
+ _grid.EnableHeadersVisualStyles = false;
+ _grid.ColumnHeadersHeightSizeMode = DataGridViewColumnHeadersHeightSizeMode.DisableResizing;
+ _grid.ColumnHeadersHeight = 32;
+ _grid.RowTemplate.Height = 28;
+
+ _grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "Rank", HeaderText = "#", Width = 36, DefaultCellStyle = { Alignment = DataGridViewContentAlignment.MiddleCenter } });
+ _grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "Start", HeaderText = "Début", Width = 110 });
+ _grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "End", HeaderText = "Fin", Width = 110 });
+ _grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "Total", HeaderText = "Jours off", Width = 80, DefaultCellStyle = { Alignment = DataGridViewContentAlignment.MiddleCenter } });
+ _grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "Taken", HeaderText = "Posés", Width = 60, DefaultCellStyle = { Alignment = DataGridViewContentAlignment.MiddleCenter } });
+ _grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "Ratio", HeaderText = "Ratio", Width = 70, DefaultCellStyle = { Alignment = DataGridViewContentAlignment.MiddleCenter } });
+ _grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "WorkDays", HeaderText = "Jours à poser", MinimumWidth = 120, AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill });
+ _grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "Holidays", HeaderText = "Fériés inclus", MinimumWidth = 120, AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill });
+
+ _grid.CellFormatting += Grid_CellFormatting;
+ }
+
+ private void RunCalculation()
+ {
+ int year = (int)_nudYear.Value;
+ int maxDays = (int)_nudMaxDays.Value;
+ int weeks = (int)_nudWeeks.Value;
+ int top = (int)_nudTop.Value;
+
+ if (weeks > 0)
+ maxDays = Math.Max(maxDays, weeks * 5);
+
+ int minDays = weeks > 0 ? weeks * 7 : 0;
+
+ var results = VacationOptimizer.FindBest(year, maxDays)
+ .Where(o => minDays == 0 || o.TotalDaysOff >= minDays)
+ .Take(top)
+ .ToList();
+
+ // grid
+ _grid.Rows.Clear();
+ for (int i = 0; i < results.Count; i++)
+ {
+ var o = results[i];
+ _grid.Rows.Add(
+ i + 1,
+ o.Start.ToString("ddd d MMM", Fr),
+ o.End.ToString("ddd d MMM", Fr),
+ o.TotalDaysOff,
+ o.DaysTaken,
+ $"{o.Ratio:F1}",
+ string.Join(", ", o.WorkDaysTaken.Select(d => d.ToString("dd/MM", Fr))),
+ string.Join(", ", o.HolidaysIncluded));
+ }
+
+ _lblStatus.Text = $" {results.Count} résultat(s) — ratio = jours off consécutifs / jours posés";
+ }
+
+ private static void Grid_CellFormatting(object? sender, DataGridViewCellFormattingEventArgs e)
+ {
+ if (e.RowIndex < 0) return;
+ var grid = (DataGridView)sender!;
+ var row = grid.Rows[e.RowIndex];
+
+ // alternating row color
+ row.DefaultCellStyle.BackColor = e.RowIndex % 2 == 0
+ ? Color.FromArgb(38, 38, 38)
+ : Color.FromArgb(44, 44, 44);
+
+ if (grid.Columns[e.ColumnIndex].Name == "Ratio" && e.Value is string ratioStr
+ && double.TryParse(ratioStr, System.Globalization.NumberStyles.Any,
+ System.Globalization.CultureInfo.InvariantCulture, out double ratio))
+ {
+ e.CellStyle.ForeColor = ratio switch
+ {
+ >= 7 => Color.Gold,
+ >= 5 => Color.LimeGreen,
+ >= 3 => Color.MediumSpringGreen,
+ >= 2 => Color.Khaki,
+ _ => Color.WhiteSmoke,
+ };
+ e.CellStyle.Font = new Font("Segoe UI", 10f, FontStyle.Bold);
+ }
+
+ if (grid.Columns[e.ColumnIndex].Name == "Rank" && e.RowIndex < 3)
+ {
+ e.CellStyle.ForeColor = e.RowIndex switch
+ {
+ 0 => Color.Gold,
+ 1 => Color.Silver,
+ 2 => Color.Peru,
+ _ => Color.WhiteSmoke,
+ };
+ e.CellStyle.Font = new Font("Segoe UI", 10f, FontStyle.Bold);
+ }
+ }
+
+ // ── helpers ───────────────────────────────────────────────────────────────
+ private static Label MakeLabel(string text) => new()
+ {
+ Text = text,
+ AutoSize = true,
+ ForeColor = Color.Silver,
+ Margin = new Padding(6, 8, 2, 0),
+ };
+
+ private static Label MakeSep() => new()
+ {
+ Text = "|",
+ AutoSize = true,
+ ForeColor = Color.FromArgb(70, 70, 70),
+ Margin = new Padding(6, 8, 6, 0),
+ };
+
+ private static void StyleNud(NumericUpDown nud)
+ {
+ nud.BackColor = Color.FromArgb(55, 55, 55);
+ nud.ForeColor = Color.WhiteSmoke;
+ nud.Margin = new Padding(2, 5, 2, 0);
+ }
+
+ private static void StyleButton(Button btn)
+ {
+ btn.BackColor = Color.FromArgb(200, 100, 20);
+ btn.ForeColor = Color.White;
+ btn.FlatAppearance.BorderSize = 0;
+ btn.Font = new Font("Segoe UI", 10f, FontStyle.Bold);
+ btn.Margin = new Padding(10, 4, 2, 0);
+ btn.Cursor = Cursors.Hand;
+ }
+}
diff --git a/Conges/Program.cs b/Conges/Program.cs
new file mode 100644
index 0000000..0db81a1
--- /dev/null
+++ b/Conges/Program.cs
@@ -0,0 +1,11 @@
+namespace Conges;
+
+static class Program
+{
+ [STAThread]
+ static void Main()
+ {
+ ApplicationConfiguration.Initialize();
+ Application.Run(new MainForm());
+ }
+}
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..eb35a16
--- /dev/null
+++ b/README.md
@@ -0,0 +1,105 @@
+# 🏖 Congés
+
+Trouve les meilleures périodes de l'année pour poser des congés en maximisant le ratio :
+
+> **ratio = jours off consécutifs obtenus / jours de congé posés**
+
+Exemple : poser **2 jours** autour du pont du 1er mai peut donner **8 jours off** → ratio **4.0**
+
+---
+
+## Projets
+
+| Projet | Description |
+|---|---|
+| `CalculateBestDaysOff.Domain` | Logique métier partagée (jours fériés, optimiseur) |
+| `CalculateBestDaysOff` | Application console colorée |
+| `Conges` | Application WinForms |
+| `CalculateBestDaysOff.Tests` | Tests unitaires (xUnit) |
+
+---
+
+## Console
+
+```bash
+dotnet run --project CalculateBestDaysOff -- [année] [options]
+```
+
+### Arguments
+
+| Argument | Description | Défaut |
+|---|---|---|
+| `année` | Année à analyser | année courante |
+| `--max-days ` | Max jours de congé posés par opportunité | `10` |
+| `--weeks ` | Filtre : blocs d'au moins N semaines consécutives | `0` (tous) |
+| `--top ` | Nombre de résultats à afficher | `10` |
+| `--help`, `-h` | Affiche l'aide | |
+
+### Exemples
+
+```bash
+# Top 10 opportunités de 2025
+dotnet run --project CalculateBestDaysOff -- 2025
+
+# Meilleurs blocs de 2 semaines
+dotnet run --project CalculateBestDaysOff -- 2025 --weeks 2
+
+# Top 5, max 3 jours posés, pour 2026
+dotnet run --project CalculateBestDaysOff -- 2026 --max-days 3 --top 5
+```
+
+### Exemple de sortie
+
+```
+╭──────┬─────────────────────────────┬───────┬───────┬────────┬────────────────────┬──────────────╮
+│ # │ Période │ Durée │ Posés │ Ratio │ Fériés inclus │ Jours à │
+│ │ │ │ │ │ │ poser │
+├──────┼─────────────────────────────┼───────┼───────┼────────┼────────────────────┼──────────────┤
+│ 🥇 1 │ ven. 18 avr. → lun. 21 avr. │ 4 j │ 1 j │ 👍 4.0 │ Lundi de Pâques │ 18/04 │
+│ 🥈 2 │ jeu. 1 mai → dim. 4 mai │ 4 j │ 1 j │ 👍 4.0 │ Fête du Travail │ 02/05 │
+│ 🥉 3 │ sam. 19 avr. → mar. 22 avr. │ 4 j │ 1 j │ 👍 4.0 │ Lundi de Pâques │ 22/04 │
+╰──────┴─────────────────────────────┴───────┴───────┴────────┴────────────────────┴──────────────╯
+```
+
+---
+
+## WinForms
+
+Lance `Conges.exe` ou :
+
+```bash
+dotnet run --project Conges
+```
+
+---
+
+## Jours fériés pris en compte
+
+Les 11 jours fériés nationaux français :
+
+- Jour de l'An (1er janvier)
+- Lundi de Pâques *(calculé)*
+- Fête du Travail (1er mai)
+- Victoire 1945 (8 mai)
+- Ascension *(calculé)*
+- Lundi de Pentecôte *(calculé)*
+- Fête Nationale (14 juillet)
+- Assomption (15 août)
+- Toussaint (1er novembre)
+- Armistice (11 novembre)
+- Noël (25 décembre)
+
+> Pâques est calculé avec l'algorithme de [Meeus/Jones/Butcher](https://en.wikipedia.org/wiki/Date_of_Easter#Anonymous_Gregorian_algorithm).
+
+---
+
+## Prérequis
+
+- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
+
+## Build & tests
+
+```bash
+dotnet build
+dotnet test
+```