diff --git a/Components/Pages/MonthlySummary.razor b/Components/Pages/MonthlySummary.razor
index 6debd76..8ebbc97 100644
--- a/Components/Pages/MonthlySummary.razor
+++ b/Components/Pages/MonthlySummary.razor
@@ -16,6 +16,7 @@
@currentMonth.ToString("MMMM yyyy")
+ Download Excel
Yearly Summary
@@ -277,6 +278,11 @@ else if (viewMode == SummaryViewMode.Timesheet && timesheet is not null)
viewMode = mode;
}
+ private string GetExcelDownloadUrl()
+ {
+ return $"/api/monthly-timesheet/{currentMonth.Year}/{currentMonth.Month}/excel?includePreview={includePreview.ToString().ToLowerInvariant()}";
+ }
+
private static string GetDayHeader(DateOnly date)
{
return ItalianCulture.TextInfo.ToTitleCase(date.ToString("ddd", ItalianCulture));
diff --git a/Program.cs b/Program.cs
index d67f0ef..31e20e5 100644
--- a/Program.cs
+++ b/Program.cs
@@ -13,6 +13,7 @@ using WorkTracker.Components;
using WorkTracker.Configuration;
using WorkTracker.Services.Auth;
using WorkTracker.Services.Festivities;
+using WorkTracker.Services.Exports;
using WorkTracker.Services.Settings;
using WorkTracker.Services.Storage;
using WorkTracker.Services.WorkDays;
@@ -74,6 +75,8 @@ builder.Services.AddScoped
builder.Services.AddScoped();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddHostedService();
@@ -245,6 +248,22 @@ app.MapGet("/healthz", [AllowAnonymous] (HttpContext context, IOptions
+{
+ if (month is < 1 or > 12)
+ {
+ return Results.BadRequest("Month must be between 1 and 12.");
+ }
+
+ var file = await exportService.ExportAsync(year, month, includePreview, cancellationToken);
+ return Results.File(file.Content, file.ContentType, file.FileName);
+}).RequireAuthorization();
+
// Development-only endpoint to reset the seeded Admin password (protected by secret in URL)
if (app.Environment.IsDevelopment())
{
diff --git a/Services/Exports/IMonthlyTimesheetExcelExportService.cs b/Services/Exports/IMonthlyTimesheetExcelExportService.cs
new file mode 100644
index 0000000..39b6643
--- /dev/null
+++ b/Services/Exports/IMonthlyTimesheetExcelExportService.cs
@@ -0,0 +1,15 @@
+namespace WorkTracker.Services.Exports;
+
+public interface IMonthlyTimesheetExcelExportService
+{
+ Task ExportAsync(int year, int month, bool includePreview, CancellationToken cancellationToken = default);
+}
+
+public sealed class MonthlyTimesheetExcelFile
+{
+ public required string FileName { get; init; }
+
+ public required byte[] Content { get; init; }
+
+ public string ContentType => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
+}
\ No newline at end of file
diff --git a/Services/Exports/IMonthlyTimesheetExcelExporter.cs b/Services/Exports/IMonthlyTimesheetExcelExporter.cs
new file mode 100644
index 0000000..59646b4
--- /dev/null
+++ b/Services/Exports/IMonthlyTimesheetExcelExporter.cs
@@ -0,0 +1,8 @@
+using WorkTracker.Domain;
+
+namespace WorkTracker.Services.Exports;
+
+public interface IMonthlyTimesheetExcelExporter
+{
+ byte[] Export(MonthlyTimesheetModel timesheet, Stream templateStream);
+}
\ No newline at end of file
diff --git a/Services/Exports/MonthlyTimesheetExcelExportService.cs b/Services/Exports/MonthlyTimesheetExcelExportService.cs
new file mode 100644
index 0000000..f4b7c9a
--- /dev/null
+++ b/Services/Exports/MonthlyTimesheetExcelExportService.cs
@@ -0,0 +1,33 @@
+using WorkTracker.Services.WorkDays;
+
+namespace WorkTracker.Services.Exports;
+
+public sealed class MonthlyTimesheetExcelExportService(
+ IWorkDayService workDayService,
+ IMonthlyTimesheetExcelExporter exporter,
+ IWebHostEnvironment environment) : IMonthlyTimesheetExcelExportService
+{
+ private readonly IWorkDayService workDayService = workDayService;
+ private readonly IMonthlyTimesheetExcelExporter exporter = exporter;
+ private readonly IWebHostEnvironment environment = environment;
+
+ public async Task ExportAsync(int year, int month, bool includePreview, CancellationToken cancellationToken = default)
+ {
+ var templatePath = Path.Combine(environment.ContentRootPath, "Templates", "monthly-timesheet-template.xlsx");
+ if (!File.Exists(templatePath))
+ {
+ throw new FileNotFoundException("Monthly timesheet template not found.", templatePath);
+ }
+
+ var timesheet = await workDayService.GetMonthlyTimesheetAsync(year, month, includePreview, cancellationToken);
+
+ await using var templateStream = File.OpenRead(templatePath);
+ var content = exporter.Export(timesheet, templateStream);
+
+ return new MonthlyTimesheetExcelFile
+ {
+ FileName = $"timesheet-{year}-{month:00}.xlsx",
+ Content = content
+ };
+ }
+}
\ No newline at end of file
diff --git a/Services/Exports/MonthlyTimesheetExcelExporter.cs b/Services/Exports/MonthlyTimesheetExcelExporter.cs
new file mode 100644
index 0000000..b6b7036
--- /dev/null
+++ b/Services/Exports/MonthlyTimesheetExcelExporter.cs
@@ -0,0 +1,230 @@
+using ClosedXML.Excel;
+using WorkTracker.Domain;
+
+namespace WorkTracker.Services.Exports;
+
+public sealed class MonthlyTimesheetExcelExporter : IMonthlyTimesheetExcelExporter
+{
+ private const int DayHeaderRow = 1;
+ private const int DayStartColumn = 3;
+ private const int TemplateDayCount = 30;
+
+ private static readonly IReadOnlyDictionary TimesheetRowMap = new Dictionary(StringComparer.Ordinal)
+ {
+ ["office"] = 2,
+ ["home"] = 4,
+ ["overtime"] = 5,
+ ["weekend"] = 6,
+ ["night"] = 7,
+ ["vacation"] = 8,
+ ["permit"] = 9,
+ ["compensatory-rest"] = 10,
+ ["sick"] = 11,
+ ["holiday"] = 12
+ };
+
+ public byte[] Export(MonthlyTimesheetModel timesheet, Stream templateStream)
+ {
+ ArgumentNullException.ThrowIfNull(timesheet);
+ ArgumentNullException.ThrowIfNull(templateStream);
+
+ if (templateStream.CanSeek)
+ {
+ templateStream.Position = 0;
+ }
+
+ using var workbook = new XLWorkbook(templateStream);
+ workbook.DefinedNames.DeleteAll();
+ var worksheet = workbook.Worksheet(1);
+ var dayCount = timesheet.Days.Count;
+
+ AdjustDayColumns(worksheet, dayCount);
+ ApplyDayHeaders(worksheet, timesheet.Days);
+ ApplyRowValues(worksheet, timesheet.Rows, dayCount);
+ ApplyTotalFormulas(worksheet, dayCount);
+
+ using var output = new MemoryStream();
+ workbook.SaveAs(output);
+ return output.ToArray();
+ }
+
+ private static void AdjustDayColumns(IXLWorksheet worksheet, int dayCount)
+ {
+ var totalColumn = DayStartColumn + TemplateDayCount;
+ var delta = dayCount - TemplateDayCount;
+
+ if (delta > 0)
+ {
+ worksheet.Column(totalColumn).InsertColumnsBefore(delta);
+ }
+ else if (delta < 0)
+ {
+ worksheet.Columns(DayStartColumn + dayCount, DayStartColumn + TemplateDayCount - 1).Delete();
+ }
+ }
+
+ private static void ApplyDayHeaders(IXLWorksheet worksheet, IReadOnlyList days)
+ {
+ var lastDayColumn = DayStartColumn + days.Count - 1;
+
+ for (var index = 0; index < days.Count; index++)
+ {
+ var day = days[index];
+ var column = DayStartColumn + index;
+
+ var headerCell = worksheet.Cell(DayHeaderRow, column);
+ headerCell.Value = day.Date.Day;
+ ApplyDayStyle(worksheet, headerCell, day, column, lastDayColumn, 1);
+
+ foreach (var rowNumber in TimesheetRowMap.Values)
+ {
+ ApplyDayStyle(worksheet, worksheet.Cell(rowNumber, column), day, column, lastDayColumn, rowNumber);
+ }
+ }
+ }
+
+ private static void ApplyRowValues(IXLWorksheet worksheet, IReadOnlyList rows, int dayCount)
+ {
+ foreach (var row in rows)
+ {
+ if (!TimesheetRowMap.TryGetValue(row.Key, out var worksheetRow))
+ {
+ continue;
+ }
+
+ for (var dayIndex = 0; dayIndex < dayCount; dayIndex++)
+ {
+ var cell = worksheet.Cell(worksheetRow, DayStartColumn + dayIndex);
+ var value = dayIndex < row.DailyValues.Count ? row.DailyValues[dayIndex] : null;
+ if (value.HasValue && value.Value > 0m)
+ {
+ cell.Value = value.Value;
+ }
+ else
+ {
+ cell.Clear(XLClearOptions.Contents);
+ }
+ }
+ }
+ }
+
+ private static void ApplyTotalFormulas(IXLWorksheet worksheet, int dayCount)
+ {
+ var lastDayColumn = DayStartColumn + dayCount - 1;
+ var totalColumn = lastDayColumn + 1;
+ var firstDayColumnLetter = XLHelper.GetColumnLetterFromNumber(DayStartColumn);
+ var lastDayColumnLetter = XLHelper.GetColumnLetterFromNumber(lastDayColumn);
+
+ foreach (var rowNumber in TimesheetRowMap.Values)
+ {
+ worksheet.Cell(rowNumber, totalColumn).FormulaA1 = $"SUM({firstDayColumnLetter}{rowNumber}:{lastDayColumnLetter}{rowNumber})";
+ }
+ }
+
+ private static void ApplyDayStyle(IXLWorksheet worksheet, IXLCell targetCell, MonthlyTimesheetDayModel day, int column, int lastDayColumn, int rowNumber)
+ {
+ var baseCell = worksheet.Cell(GetBaseStyleRowAddress(rowNumber), GetBaseStyleColumn(column, lastDayColumn));
+ var baseStyle = baseCell.Style;
+
+ if (!ShouldHighlight(day))
+ {
+ targetCell.Style = baseStyle;
+ return;
+ }
+
+ var sampleColumn = GetHighlightSampleColumn(day, rowNumber);
+ var sampleCell = worksheet.Cell(GetSampleStyleRow(rowNumber), sampleColumn);
+ var sampleStyle = sampleCell.Style;
+
+ targetCell.Style = sampleStyle;
+ targetCell.Style.Border.LeftBorder = baseStyle.Border.LeftBorder;
+ targetCell.Style.Border.LeftBorderColor = baseStyle.Border.LeftBorderColor;
+ targetCell.Style.Border.RightBorder = baseStyle.Border.RightBorder;
+ targetCell.Style.Border.RightBorderColor = baseStyle.Border.RightBorderColor;
+ targetCell.Style.Border.TopBorder = baseStyle.Border.TopBorder;
+ targetCell.Style.Border.TopBorderColor = baseStyle.Border.TopBorderColor;
+ targetCell.Style.Border.BottomBorder = baseStyle.Border.BottomBorder;
+ targetCell.Style.Border.BottomBorderColor = baseStyle.Border.BottomBorderColor;
+ }
+
+ private static bool ShouldHighlight(MonthlyTimesheetDayModel day)
+ {
+ return day.IsWeekend || day.IsHoliday;
+ }
+
+ private static int GetBaseStyleRowAddress(int rowNumber)
+ {
+ return rowNumber switch
+ {
+ 1 => 1,
+ 2 => 2,
+ 4 or 5 or 6 or 7 or 8 or 9 or 10 or 11 => 4,
+ 12 => 12,
+ _ => rowNumber
+ };
+ }
+
+ private static int GetSampleStyleRow(int rowNumber)
+ {
+ return rowNumber switch
+ {
+ 1 => 1,
+ 2 => 2,
+ 4 or 5 or 6 or 7 or 8 or 9 or 10 or 11 => 4,
+ 12 => 12,
+ _ => rowNumber
+ };
+ }
+
+ private static int GetBaseStyleColumn(int column, int lastDayColumn)
+ {
+ if (column == DayStartColumn)
+ {
+ return DayStartColumn;
+ }
+
+ if (column == lastDayColumn)
+ {
+ return lastDayColumn == DayStartColumn + TemplateDayCount - 1
+ ? DayStartColumn + TemplateDayCount - 1
+ : DayStartColumn + 1;
+ }
+
+ return DayStartColumn + 1;
+ }
+
+ private static int GetHighlightSampleColumn(MonthlyTimesheetDayModel day, int rowNumber)
+ {
+ if (rowNumber == 1)
+ {
+ if (day.IsHoliday || day.Date.DayOfWeek == DayOfWeek.Sunday)
+ {
+ return 7;
+ }
+
+ if (day.Date.DayOfWeek == DayOfWeek.Saturday)
+ {
+ return 6;
+ }
+
+ return 7;
+ }
+
+ if (rowNumber == 2)
+ {
+ if (day.IsHoliday || day.Date.DayOfWeek == DayOfWeek.Sunday)
+ {
+ return 7;
+ }
+
+ if (day.Date.DayOfWeek == DayOfWeek.Saturday)
+ {
+ return 6;
+ }
+
+ return 7;
+ }
+
+ return 6;
+ }
+}
\ No newline at end of file
diff --git a/Templates/monthly-timesheet-template.xlsx b/Templates/monthly-timesheet-template.xlsx
new file mode 100644
index 0000000..0d9df9d
Binary files /dev/null and b/Templates/monthly-timesheet-template.xlsx differ
diff --git a/WorkTracker.csproj b/WorkTracker.csproj
index c764034..c24a782 100644
--- a/WorkTracker.csproj
+++ b/WorkTracker.csproj
@@ -8,15 +8,26 @@
+
+
+
+
+
+
+
PreserveNewest
+
+ PreserveNewest
+ PreserveNewest
+
diff --git a/WorkTracker.sln b/WorkTracker.sln
index 4c0c3b9..b6f3355 100644
--- a/WorkTracker.sln
+++ b/WorkTracker.sln
@@ -1,23 +1,55 @@
+
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.5.2.0
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WorkTracker", "WorkTracker.csproj", "{CE0B6FA6-0859-11C8-4BC6-F8C91A495CE8}"
EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WorkTracker.Tests", "tests\WorkTracker.Tests\WorkTracker.Tests.csproj", "{87B6F668-25F6-4F14-B185-176514507ADE}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
+ Debug|x64 = Debug|x64
+ Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
+ Release|x64 = Release|x64
+ Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{CE0B6FA6-0859-11C8-4BC6-F8C91A495CE8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CE0B6FA6-0859-11C8-4BC6-F8C91A495CE8}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {CE0B6FA6-0859-11C8-4BC6-F8C91A495CE8}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {CE0B6FA6-0859-11C8-4BC6-F8C91A495CE8}.Debug|x64.Build.0 = Debug|Any CPU
+ {CE0B6FA6-0859-11C8-4BC6-F8C91A495CE8}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {CE0B6FA6-0859-11C8-4BC6-F8C91A495CE8}.Debug|x86.Build.0 = Debug|Any CPU
{CE0B6FA6-0859-11C8-4BC6-F8C91A495CE8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CE0B6FA6-0859-11C8-4BC6-F8C91A495CE8}.Release|Any CPU.Build.0 = Release|Any CPU
+ {CE0B6FA6-0859-11C8-4BC6-F8C91A495CE8}.Release|x64.ActiveCfg = Release|Any CPU
+ {CE0B6FA6-0859-11C8-4BC6-F8C91A495CE8}.Release|x64.Build.0 = Release|Any CPU
+ {CE0B6FA6-0859-11C8-4BC6-F8C91A495CE8}.Release|x86.ActiveCfg = Release|Any CPU
+ {CE0B6FA6-0859-11C8-4BC6-F8C91A495CE8}.Release|x86.Build.0 = Release|Any CPU
+ {87B6F668-25F6-4F14-B185-176514507ADE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {87B6F668-25F6-4F14-B185-176514507ADE}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {87B6F668-25F6-4F14-B185-176514507ADE}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {87B6F668-25F6-4F14-B185-176514507ADE}.Debug|x64.Build.0 = Debug|Any CPU
+ {87B6F668-25F6-4F14-B185-176514507ADE}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {87B6F668-25F6-4F14-B185-176514507ADE}.Debug|x86.Build.0 = Debug|Any CPU
+ {87B6F668-25F6-4F14-B185-176514507ADE}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {87B6F668-25F6-4F14-B185-176514507ADE}.Release|Any CPU.Build.0 = Release|Any CPU
+ {87B6F668-25F6-4F14-B185-176514507ADE}.Release|x64.ActiveCfg = Release|Any CPU
+ {87B6F668-25F6-4F14-B185-176514507ADE}.Release|x64.Build.0 = Release|Any CPU
+ {87B6F668-25F6-4F14-B185-176514507ADE}.Release|x86.ActiveCfg = Release|Any CPU
+ {87B6F668-25F6-4F14-B185-176514507ADE}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {87B6F668-25F6-4F14-B185-176514507ADE} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
+ EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {9E2849A1-F16A-4322-A66A-A64428230E83}
EndGlobalSection
diff --git a/tests/WorkTracker.Tests/Expected/Timesheet Aprile 2026-filled.xlsx b/tests/WorkTracker.Tests/Expected/Timesheet Aprile 2026-filled.xlsx
new file mode 100644
index 0000000..3658645
Binary files /dev/null and b/tests/WorkTracker.Tests/Expected/Timesheet Aprile 2026-filled.xlsx differ
diff --git a/tests/WorkTracker.Tests/MonthlyTimesheetExcelExporterTests.cs b/tests/WorkTracker.Tests/MonthlyTimesheetExcelExporterTests.cs
new file mode 100644
index 0000000..bc55c78
--- /dev/null
+++ b/tests/WorkTracker.Tests/MonthlyTimesheetExcelExporterTests.cs
@@ -0,0 +1,119 @@
+using ClosedXML.Excel;
+using WorkTracker.Domain;
+using WorkTracker.Services.Exports;
+using Xunit;
+
+namespace WorkTracker.Tests;
+
+public sealed class MonthlyTimesheetExcelExporterTests
+{
+ [Fact]
+ public void Export_ForApril2026EmptyTimesheet_MatchesExpectedWorkbook()
+ {
+ var exporter = new MonthlyTimesheetExcelExporter();
+ var timesheet = CreateTimesheet(new DateOnly(2026, 4, 1), new HashSet
+ {
+ new(2026, 4, 6),
+ new(2026, 4, 25)
+ });
+ var templatePath = GetTemplatePath();
+
+ using var templateStream = File.OpenRead(templatePath);
+ var workbookBytes = exporter.Export(timesheet, templateStream);
+
+ WorkbookAssert.Equivalent(GetExpectedWorkbookPath(), workbookBytes);
+ }
+
+ [Fact]
+ public void Export_ForThirtyOneDayMonth_ShiftsTotalColumnAfterLastDay()
+ {
+ var exporter = new MonthlyTimesheetExcelExporter();
+ var timesheet = CreateTimesheet(new DateOnly(2026, 5, 1), new HashSet());
+ var templatePath = GetTemplatePath();
+
+ using var templateStream = File.OpenRead(templatePath);
+ var workbookBytes = exporter.Export(timesheet, templateStream);
+
+ using var workbook = new XLWorkbook(new MemoryStream(workbookBytes));
+ var worksheet = workbook.Worksheet(1);
+
+ Assert.Equal(31d, worksheet.Cell("AG1").GetDouble());
+ Assert.Equal("SUM(C2:AG2)", worksheet.Cell("AH2").FormulaA1);
+ Assert.Equal("TOTALE", worksheet.Cell("AH1").GetString());
+ }
+
+ private static MonthlyTimesheetModel CreateTimesheet(DateOnly monthStart, ISet holidays)
+ {
+ var lastDay = monthStart.AddMonths(1).AddDays(-1);
+ var days = new List();
+ for (var date = monthStart; date <= lastDay; date = date.AddDays(1))
+ {
+ days.Add(new MonthlyTimesheetDayModel
+ {
+ Date = date,
+ IsWeekend = date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday,
+ IsHoliday = holidays.Contains(date)
+ });
+ }
+
+ return new MonthlyTimesheetModel
+ {
+ Year = monthStart.Year,
+ Month = monthStart.Month,
+ Days = days,
+ Rows =
+ [
+ CreateRow("office", days.Count),
+ CreateRow("home", days.Count),
+ CreateRow("overtime", days.Count),
+ CreateRow("weekend", days.Count),
+ CreateRow("night", days.Count),
+ CreateRow("vacation", days.Count),
+ CreateRow("permit", days.Count),
+ CreateRow("compensatory-rest", days.Count),
+ CreateRow("sick", days.Count),
+ CreateRow("holiday", days.Count)
+ ]
+ };
+ }
+
+ private static MonthlyTimesheetRowModel CreateRow(string key, int dayCount)
+ {
+ return new MonthlyTimesheetRowModel
+ {
+ Key = key,
+ DailyValues = Enumerable.Repeat(null, dayCount).ToList()
+ };
+ }
+
+ private static string GetExpectedWorkbookPath()
+ {
+ var repositoryRoot = FindRepositoryRoot();
+ var candidate = Path.Combine(repositoryRoot, "tests", "WorkTracker.Tests", "Expected", "monthly-timesheet-2026-04-empty.expected.xlsx");
+ return File.Exists(candidate)
+ ? candidate
+ : GetTemplatePath();
+ }
+
+ private static string GetTemplatePath()
+ {
+ var repositoryRoot = FindRepositoryRoot();
+ return Path.Combine(repositoryRoot, "Templates", "monthly-timesheet-template.xlsx");
+ }
+
+ private static string FindRepositoryRoot()
+ {
+ var directory = new DirectoryInfo(AppContext.BaseDirectory);
+ while (directory is not null)
+ {
+ if (File.Exists(Path.Combine(directory.FullName, "WorkTracker.sln")))
+ {
+ return directory.FullName;
+ }
+
+ directory = directory.Parent;
+ }
+
+ throw new DirectoryNotFoundException("Unable to locate the WorkTracker repository root.");
+ }
+}
diff --git a/tests/WorkTracker.Tests/WorkTracker.Tests.csproj b/tests/WorkTracker.Tests/WorkTracker.Tests.csproj
new file mode 100644
index 0000000..f8039eb
--- /dev/null
+++ b/tests/WorkTracker.Tests/WorkTracker.Tests.csproj
@@ -0,0 +1,21 @@
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
diff --git a/tests/WorkTracker.Tests/WorkbookAssert.cs b/tests/WorkTracker.Tests/WorkbookAssert.cs
new file mode 100644
index 0000000..c7a2772
--- /dev/null
+++ b/tests/WorkTracker.Tests/WorkbookAssert.cs
@@ -0,0 +1,90 @@
+using ClosedXML.Excel;
+using Xunit;
+
+namespace WorkTracker.Tests;
+
+internal static class WorkbookAssert
+{
+ public static void Equivalent(string expectedPath, byte[] actualContent)
+ {
+ using var expectedWorkbook = new XLWorkbook(expectedPath);
+ using var actualStream = new MemoryStream(actualContent);
+ using var actualWorkbook = new XLWorkbook(actualStream);
+
+ var expectedWorksheet = expectedWorkbook.Worksheet(1);
+ var actualWorksheet = actualWorkbook.Worksheet(1);
+
+ Assert.Equal(expectedWorksheet.Name, actualWorksheet.Name);
+ Assert.Equal(expectedWorksheet.RangeUsed()?.RangeAddress.ToString(), actualWorksheet.RangeUsed()?.RangeAddress.ToString());
+ Assert.Equal(expectedWorksheet.MergedRanges.Select(range => range.RangeAddress.ToString()), actualWorksheet.MergedRanges.Select(range => range.RangeAddress.ToString()));
+
+ var usedRange = expectedWorksheet.RangeUsed() ?? throw new InvalidOperationException("Expected workbook must have a used range.");
+ foreach (var column in Enumerable.Range(usedRange.RangeAddress.FirstAddress.ColumnNumber, usedRange.ColumnCount()))
+ {
+ Assert.Equal(Math.Round(expectedWorksheet.Column(column).Width, 5), Math.Round(actualWorksheet.Column(column).Width, 5));
+ }
+
+ foreach (var cell in usedRange.Cells())
+ {
+ var actualCell = actualWorksheet.Cell(cell.Address.RowNumber, cell.Address.ColumnNumber);
+ Assert.Equal(GetCellValue(cell), GetCellValue(actualCell));
+ Assert.Equal(cell.FormulaA1, actualCell.FormulaA1);
+
+ if (string.IsNullOrEmpty(cell.FormulaA1))
+ {
+ Assert.Equal(cell.DataType, actualCell.DataType);
+ }
+
+ var expectedStyle = DescribeStyle(cell.Style);
+ var actualStyle = DescribeStyle(actualCell.Style);
+ Assert.True(expectedStyle == actualStyle, $"Style mismatch at {cell.Address}: expected '{expectedStyle}' actual '{actualStyle}'");
+ }
+ }
+
+ private static string GetCellValue(IXLCell cell)
+ {
+ if (!string.IsNullOrEmpty(cell.FormulaA1))
+ {
+ return string.Empty;
+ }
+
+ return cell.Value.ToString();
+ }
+
+ private static string DescribeStyle(IXLStyle style)
+ {
+ return string.Join(
+ "|",
+ style.NumberFormat.NumberFormatId,
+ style.NumberFormat.Format,
+ style.Fill.PatternType,
+ DescribeColor(style.Fill.BackgroundColor),
+ DescribeColor(style.Fill.PatternColor),
+ style.Font.FontName,
+ style.Font.FontSize,
+ style.Font.Bold,
+ style.Alignment.Horizontal,
+ style.Alignment.Vertical,
+ style.Alignment.WrapText,
+ style.Border.LeftBorder,
+ DescribeColor(style.Border.LeftBorderColor),
+ style.Border.RightBorder,
+ DescribeColor(style.Border.RightBorderColor),
+ style.Border.TopBorder,
+ DescribeColor(style.Border.TopBorderColor),
+ style.Border.BottomBorder,
+ DescribeColor(style.Border.BottomBorderColor),
+ style.Protection.Locked);
+ }
+
+ private static string DescribeColor(XLColor color)
+ {
+ return color.ColorType switch
+ {
+ XLColorType.Color => $"rgb:{color.Color.ToArgb()}",
+ XLColorType.Indexed => $"indexed:{color.Indexed}",
+ XLColorType.Theme => $"theme:{color.ThemeColor}:{Math.Round(color.ThemeTint, 12)}",
+ _ => color.ColorType.ToString()
+ };
+ }
+}