diff --git a/Components/Pages/MonthlySummary.razor b/Components/Pages/MonthlySummary.razor
index 8ebbc97..82445fe 100644
--- a/Components/Pages/MonthlySummary.razor
+++ b/Components/Pages/MonthlySummary.razor
@@ -16,7 +16,7 @@
@currentMonth.ToString("MMMM yyyy")
- Download Excel
+
Yearly Summary
@@ -278,6 +278,20 @@ else if (viewMode == SummaryViewMode.Timesheet && timesheet is not null)
viewMode = mode;
}
+ private async Task DownloadExcelAsync()
+ {
+ try
+ {
+ await JS.InvokeVoidAsync("workTrackerDownloads.downloadFromUrl", GetExcelDownloadUrl());
+ }
+ catch (JSDisconnectedException)
+ {
+ }
+ catch (TaskCanceledException)
+ {
+ }
+ }
+
private string GetExcelDownloadUrl()
{
return $"/api/monthly-timesheet/{currentMonth.Year}/{currentMonth.Month}/excel?includePreview={includePreview.ToString().ToLowerInvariant()}";
diff --git a/Services/Exports/MonthlyTimesheetExcelExporter.cs b/Services/Exports/MonthlyTimesheetExcelExporter.cs
index b6b7036..142c9b0 100644
--- a/Services/Exports/MonthlyTimesheetExcelExporter.cs
+++ b/Services/Exports/MonthlyTimesheetExcelExporter.cs
@@ -8,6 +8,8 @@ public sealed class MonthlyTimesheetExcelExporter : IMonthlyTimesheetExcelExport
private const int DayHeaderRow = 1;
private const int DayStartColumn = 3;
private const int TemplateDayCount = 30;
+ private const int SaturdaySampleColumn = 6;
+ private const int SundayHolidaySampleColumn = 7;
private static readonly IReadOnlyDictionary TimesheetRowMap = new Dictionary(StringComparer.Ordinal)
{
@@ -37,9 +39,10 @@ public sealed class MonthlyTimesheetExcelExporter : IMonthlyTimesheetExcelExport
workbook.DefinedNames.DeleteAll();
var worksheet = workbook.Worksheet(1);
var dayCount = timesheet.Days.Count;
+ var styleCatalog = CaptureDayStyleCatalog(worksheet);
AdjustDayColumns(worksheet, dayCount);
- ApplyDayHeaders(worksheet, timesheet.Days);
+ ApplyDayHeaders(worksheet, timesheet.Days, styleCatalog);
ApplyRowValues(worksheet, timesheet.Rows, dayCount);
ApplyTotalFormulas(worksheet, dayCount);
@@ -63,7 +66,7 @@ public sealed class MonthlyTimesheetExcelExporter : IMonthlyTimesheetExcelExport
}
}
- private static void ApplyDayHeaders(IXLWorksheet worksheet, IReadOnlyList days)
+ private static void ApplyDayHeaders(IXLWorksheet worksheet, IReadOnlyList days, DayStyleCatalog styleCatalog)
{
var lastDayColumn = DayStartColumn + days.Count - 1;
@@ -74,11 +77,11 @@ public sealed class MonthlyTimesheetExcelExporter : IMonthlyTimesheetExcelExport
var headerCell = worksheet.Cell(DayHeaderRow, column);
headerCell.Value = day.Date.Day;
- ApplyDayStyle(worksheet, headerCell, day, column, lastDayColumn, 1);
+ ApplyDayStyle(headerCell, day, column, lastDayColumn, 1, styleCatalog);
foreach (var rowNumber in TimesheetRowMap.Values)
{
- ApplyDayStyle(worksheet, worksheet.Cell(rowNumber, column), day, column, lastDayColumn, rowNumber);
+ ApplyDayStyle(worksheet.Cell(rowNumber, column), day, column, lastDayColumn, rowNumber, styleCatalog);
}
}
}
@@ -121,10 +124,32 @@ public sealed class MonthlyTimesheetExcelExporter : IMonthlyTimesheetExcelExport
}
}
- private static void ApplyDayStyle(IXLWorksheet worksheet, IXLCell targetCell, MonthlyTimesheetDayModel day, int column, int lastDayColumn, int rowNumber)
+ private static DayStyleCatalog CaptureDayStyleCatalog(IXLWorksheet worksheet)
{
- var baseCell = worksheet.Cell(GetBaseStyleRowAddress(rowNumber), GetBaseStyleColumn(column, lastDayColumn));
- var baseStyle = baseCell.Style;
+ return new DayStyleCatalog(
+ CaptureStylesForColumn(worksheet, DayStartColumn),
+ CaptureStylesForColumn(worksheet, DayStartColumn + 1),
+ CaptureStylesForColumn(worksheet, DayStartColumn + TemplateDayCount - 1),
+ CaptureStylesForColumn(worksheet, SaturdaySampleColumn),
+ CaptureStylesForColumn(worksheet, SundayHolidaySampleColumn));
+ }
+
+ private static IReadOnlyDictionary CaptureStylesForColumn(IXLWorksheet worksheet, int column)
+ {
+ var rowNumbers = new[] { 1, 2, 4, 12 };
+ var styles = new Dictionary(rowNumbers.Length);
+
+ foreach (var rowNumber in rowNumbers)
+ {
+ styles[rowNumber] = worksheet.Cell(rowNumber, column).Style;
+ }
+
+ return styles;
+ }
+
+ private static void ApplyDayStyle(IXLCell targetCell, MonthlyTimesheetDayModel day, int column, int lastDayColumn, int rowNumber, DayStyleCatalog styleCatalog)
+ {
+ var baseStyle = styleCatalog.GetBaseStyle(rowNumber, column == DayStartColumn, column == lastDayColumn);
if (!ShouldHighlight(day))
{
@@ -132,9 +157,7 @@ public sealed class MonthlyTimesheetExcelExporter : IMonthlyTimesheetExcelExport
return;
}
- var sampleColumn = GetHighlightSampleColumn(day, rowNumber);
- var sampleCell = worksheet.Cell(GetSampleStyleRow(rowNumber), sampleColumn);
- var sampleStyle = sampleCell.Style;
+ var sampleStyle = styleCatalog.GetHighlightStyle(rowNumber, day);
targetCell.Style = sampleStyle;
targetCell.Style.Border.LeftBorder = baseStyle.Border.LeftBorder;
@@ -152,7 +175,7 @@ public sealed class MonthlyTimesheetExcelExporter : IMonthlyTimesheetExcelExport
return day.IsWeekend || day.IsHoliday;
}
- private static int GetBaseStyleRowAddress(int rowNumber)
+ private static int GetStyleRow(int rowNumber)
{
return rowNumber switch
{
@@ -164,67 +187,42 @@ public sealed class MonthlyTimesheetExcelExporter : IMonthlyTimesheetExcelExport
};
}
- private static int GetSampleStyleRow(int rowNumber)
+ private sealed class DayStyleCatalog(
+ IReadOnlyDictionary firstColumnStyles,
+ IReadOnlyDictionary middleColumnStyles,
+ IReadOnlyDictionary lastColumnStyles,
+ IReadOnlyDictionary saturdayStyles,
+ IReadOnlyDictionary sundayHolidayStyles)
{
- return rowNumber switch
+ public IXLStyle GetBaseStyle(int rowNumber, bool isFirstColumn, bool isLastColumn)
{
- 1 => 1,
- 2 => 2,
- 4 or 5 or 6 or 7 or 8 or 9 or 10 or 11 => 4,
- 12 => 12,
- _ => rowNumber
- };
- }
+ var styleRow = GetStyleRow(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)
+ if (isFirstColumn)
{
- return 7;
+ return firstColumnStyles[styleRow];
}
- if (day.Date.DayOfWeek == DayOfWeek.Saturday)
+ if (isLastColumn)
{
- return 6;
+ return lastColumnStyles[styleRow];
}
- return 7;
+ return middleColumnStyles[styleRow];
}
- if (rowNumber == 2)
+ public IXLStyle GetHighlightStyle(int rowNumber, MonthlyTimesheetDayModel day)
{
- if (day.IsHoliday || day.Date.DayOfWeek == DayOfWeek.Sunday)
+ var styleRow = GetStyleRow(rowNumber);
+
+ if (rowNumber is 1 or 2)
{
- return 7;
+ return day.IsHoliday || day.Date.DayOfWeek == DayOfWeek.Sunday
+ ? sundayHolidayStyles[styleRow]
+ : saturdayStyles[styleRow];
}
- if (day.Date.DayOfWeek == DayOfWeek.Saturday)
- {
- return 6;
- }
-
- return 7;
+ return saturdayStyles[styleRow];
}
-
- return 6;
}
}
\ No newline at end of file
diff --git a/tests/WorkTracker.Tests/MonthlyTimesheetExcelExporterTests.cs b/tests/WorkTracker.Tests/MonthlyTimesheetExcelExporterTests.cs
index bc55c78..ba8bedc 100644
--- a/tests/WorkTracker.Tests/MonthlyTimesheetExcelExporterTests.cs
+++ b/tests/WorkTracker.Tests/MonthlyTimesheetExcelExporterTests.cs
@@ -42,6 +42,43 @@ public sealed class MonthlyTimesheetExcelExporterTests
Assert.Equal("TOTALE", worksheet.Cell("AH1").GetString());
}
+ [Fact]
+ public void Export_ForMonthWhereSecondDayIsWeekend_OnlyHighlightsWeekendAndHolidayDays()
+ {
+ var exporter = new MonthlyTimesheetExcelExporter();
+ var timesheet = CreateTimesheet(new DateOnly(2026, 5, 1), new HashSet
+ {
+ new(2026, 5, 4)
+ });
+ 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);
+
+ var headerRegularFill = GetFillSignature(worksheet.Cell("C1"));
+ var headerSaturdayFill = GetFillSignature(worksheet.Cell("D1"));
+ var headerSundayFill = GetFillSignature(worksheet.Cell("E1"));
+ var headerHolidayFill = GetFillSignature(worksheet.Cell("F1"));
+ var headerNextWeekdayFill = GetFillSignature(worksheet.Cell("G1"));
+
+ Assert.NotEqual(headerRegularFill, headerSaturdayFill);
+ Assert.NotEqual(headerRegularFill, headerSundayFill);
+ Assert.Equal(headerSundayFill, headerHolidayFill);
+ Assert.Equal(headerRegularFill, headerNextWeekdayFill);
+
+ var bodyRegularFill = GetFillSignature(worksheet.Cell("C4"));
+ var bodySaturdayFill = GetFillSignature(worksheet.Cell("D4"));
+ var bodyHolidayFill = GetFillSignature(worksheet.Cell("F4"));
+ var bodyNextWeekdayFill = GetFillSignature(worksheet.Cell("G4"));
+
+ Assert.NotEqual(bodyRegularFill, bodySaturdayFill);
+ Assert.Equal(bodySaturdayFill, bodyHolidayFill);
+ Assert.Equal(bodyRegularFill, bodyNextWeekdayFill);
+ }
+
private static MonthlyTimesheetModel CreateTimesheet(DateOnly monthStart, ISet holidays)
{
var lastDay = monthStart.AddMonths(1).AddDays(-1);
@@ -116,4 +153,26 @@ public sealed class MonthlyTimesheetExcelExporterTests
throw new DirectoryNotFoundException("Unable to locate the WorkTracker repository root.");
}
+
+ private static string GetFillSignature(IXLCell cell)
+ {
+ var fill = cell.Style.Fill;
+
+ return string.Join(
+ "|",
+ fill.PatternType,
+ DescribeColor(fill.BackgroundColor),
+ DescribeColor(fill.PatternColor));
+ }
+
+ 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()
+ };
+ }
}