Compare commits
2 commits
0d5b48b891
...
e8bbae0496
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8bbae0496 | ||
|
|
e872fe200b |
20 changed files with 1241 additions and 0 deletions
|
|
@ -16,6 +16,7 @@
|
|||
<button class="btn btn-outline-secondary btn-sm" @onclick="PreviousMonth">« Prev</button>
|
||||
<h2 class="h5 mb-0">@currentMonth.ToString("MMMM yyyy")</h2>
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="NextMonth">Next »</button>
|
||||
<a class="btn btn-success btn-sm ms-auto" href="@GetExcelDownloadUrl()">Download Excel</a>
|
||||
<a class="btn btn-outline-primary btn-sm ms-auto" href="yearly-summary/@currentMonth.Year">Yearly Summary</a>
|
||||
</div>
|
||||
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -1,8 +1,13 @@
|
|||
@page "/settings"
|
||||
@attribute [Authorize]
|
||||
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using WorkTracker.Services.Storage
|
||||
|
||||
@inject IAppSettingsService AppSettingsService
|
||||
@inject IDatabaseBackupService DatabaseBackupService
|
||||
@inject AppThemeState ThemeState
|
||||
@inject IJSRuntime JS
|
||||
|
||||
<PageTitle>Settings</PageTitle>
|
||||
|
||||
|
|
@ -65,11 +70,60 @@ else
|
|||
}
|
||||
</div>
|
||||
</EditForm>
|
||||
|
||||
<section class="mt-5">
|
||||
<h2 class="h4">Database backup</h2>
|
||||
<p class="text-muted mb-3">Export the full database as JSON or restore a previously exported JSON backup. Restore replaces the current database only when the backup format version and database schema version are supported.</p>
|
||||
|
||||
<div class="d-flex flex-wrap gap-2 align-items-center mb-4">
|
||||
<a class="btn btn-outline-secondary" href="/api/database-backup/export">Export JSON backup</a>
|
||||
<span class="small text-muted">Current database schema version: @DatabaseSchemaVersion</span>
|
||||
</div>
|
||||
|
||||
<div class="card border-danger-subtle">
|
||||
<div class="card-body">
|
||||
<h3 class="h6 card-title">Restore from JSON</h3>
|
||||
<p class="card-text text-muted">This overwrites the existing database with the selected backup file.</p>
|
||||
|
||||
<div class="d-flex flex-column gap-3 align-items-start">
|
||||
<InputFile OnChange="OnRestoreFileSelected" accept="application/json,.json" />
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(selectedBackupFileName))
|
||||
{
|
||||
<div class="small text-muted">Selected file: @selectedBackupFileName</div>
|
||||
}
|
||||
|
||||
<button class="btn btn-outline-danger" type="button" @onclick="RestoreAsync" disabled="@(selectedBackupFile is null || isRestoring)">
|
||||
@(isRestoring ? "Restoring..." : "Restore JSON backup")
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(backupStatusMessage))
|
||||
{
|
||||
<div class="alert alert-success py-2 mt-3 mb-0">@backupStatusMessage</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(backupErrorMessage))
|
||||
{
|
||||
<div class="alert alert-danger py-2 mt-3 mb-0">@backupErrorMessage</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
private const long MaxBackupFileSize = 20 * 1024 * 1024;
|
||||
|
||||
private AppSettingsDocument? settings;
|
||||
private string? statusMessage;
|
||||
private string? backupStatusMessage;
|
||||
private string? backupErrorMessage;
|
||||
private IBrowserFile? selectedBackupFile;
|
||||
private string? selectedBackupFileName;
|
||||
private bool isRestoring;
|
||||
|
||||
private int DatabaseSchemaVersion => CouchbaseLiteDatabaseProvider.CurrentDatabaseSchemaVersion;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
|
|
@ -87,4 +141,56 @@ else
|
|||
ThemeState.SetThemeMode(settings.ThemeMode);
|
||||
statusMessage = $"Saved at {DateTime.Now:t}";
|
||||
}
|
||||
|
||||
private void OnRestoreFileSelected(InputFileChangeEventArgs args)
|
||||
{
|
||||
selectedBackupFile = args.File;
|
||||
selectedBackupFileName = args.File.Name;
|
||||
backupStatusMessage = null;
|
||||
backupErrorMessage = null;
|
||||
}
|
||||
|
||||
private async Task RestoreAsync()
|
||||
{
|
||||
if (selectedBackupFile is null)
|
||||
{
|
||||
backupErrorMessage = "Select a JSON backup file first.";
|
||||
backupStatusMessage = null;
|
||||
return;
|
||||
}
|
||||
|
||||
var confirmed = await JS.InvokeAsync<bool>("confirm", "Restore this backup and overwrite the current database?\nThis cannot be undone.");
|
||||
if (!confirmed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
isRestoring = true;
|
||||
backupStatusMessage = null;
|
||||
backupErrorMessage = null;
|
||||
|
||||
try
|
||||
{
|
||||
await using var stream = selectedBackupFile.OpenReadStream(MaxBackupFileSize);
|
||||
await DatabaseBackupService.ImportAsync(stream);
|
||||
|
||||
settings = await AppSettingsService.GetAsync();
|
||||
ThemeState.SetThemeMode(settings.ThemeMode);
|
||||
selectedBackupFile = null;
|
||||
selectedBackupFileName = null;
|
||||
backupStatusMessage = $"Backup restored at {DateTime.Now:t}.";
|
||||
}
|
||||
catch (DatabaseBackupException exception)
|
||||
{
|
||||
backupErrorMessage = exception.Message;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
backupErrorMessage = "The selected backup file is too large or could not be read.";
|
||||
}
|
||||
finally
|
||||
{
|
||||
isRestoring = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
28
Program.cs
28
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;
|
||||
|
|
@ -70,10 +71,13 @@ builder.Services.Configure<CouchbaseLiteOptions>(builder.Configuration.GetSectio
|
|||
builder.Services.Configure<SingleUserOptions>(builder.Configuration.GetSection(SingleUserOptions.SectionName));
|
||||
|
||||
builder.Services.AddSingleton<CouchbaseLiteDatabaseProvider>();
|
||||
builder.Services.AddScoped<IDatabaseBackupService, JsonDatabaseBackupService>();
|
||||
builder.Services.AddScoped<IAppSettingsService, CouchbaseLiteAppSettingsService>();
|
||||
builder.Services.AddScoped<AppThemeState>();
|
||||
builder.Services.AddSingleton<IAuthService, CouchbaseLiteAuthService>();
|
||||
builder.Services.AddSingleton<IItalianFestivitySource, ItalianFestivitySource>();
|
||||
builder.Services.AddSingleton<IMonthlyTimesheetExcelExporter, MonthlyTimesheetExcelExporter>();
|
||||
builder.Services.AddScoped<IMonthlyTimesheetExcelExportService, MonthlyTimesheetExcelExportService>();
|
||||
builder.Services.AddScoped<IWorkDayService, CouchbaseLiteWorkDayService>();
|
||||
builder.Services.AddHostedService<SingleUserSeedService>();
|
||||
|
||||
|
|
@ -245,6 +249,30 @@ app.MapGet("/healthz", [AllowAnonymous] (HttpContext context, IOptions<AppAuthOp
|
|||
});
|
||||
});
|
||||
|
||||
app.MapGet("/api/monthly-timesheet/{year:int}/{month:int}/excel", async (
|
||||
int year,
|
||||
int month,
|
||||
bool includePreview,
|
||||
IMonthlyTimesheetExcelExportService exportService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
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();
|
||||
|
||||
app.MapGet("/api/database-backup/export", async (
|
||||
IDatabaseBackupService backupService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var file = await backupService.ExportAsync(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())
|
||||
{
|
||||
|
|
|
|||
15
Services/Exports/IMonthlyTimesheetExcelExportService.cs
Normal file
15
Services/Exports/IMonthlyTimesheetExcelExportService.cs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
namespace WorkTracker.Services.Exports;
|
||||
|
||||
public interface IMonthlyTimesheetExcelExportService
|
||||
{
|
||||
Task<MonthlyTimesheetExcelFile> 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";
|
||||
}
|
||||
8
Services/Exports/IMonthlyTimesheetExcelExporter.cs
Normal file
8
Services/Exports/IMonthlyTimesheetExcelExporter.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
using WorkTracker.Domain;
|
||||
|
||||
namespace WorkTracker.Services.Exports;
|
||||
|
||||
public interface IMonthlyTimesheetExcelExporter
|
||||
{
|
||||
byte[] Export(MonthlyTimesheetModel timesheet, Stream templateStream);
|
||||
}
|
||||
33
Services/Exports/MonthlyTimesheetExcelExportService.cs
Normal file
33
Services/Exports/MonthlyTimesheetExcelExportService.cs
Normal file
|
|
@ -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<MonthlyTimesheetExcelFile> 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
|
||||
};
|
||||
}
|
||||
}
|
||||
230
Services/Exports/MonthlyTimesheetExcelExporter.cs
Normal file
230
Services/Exports/MonthlyTimesheetExcelExporter.cs
Normal file
|
|
@ -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<string, int> TimesheetRowMap = new Dictionary<string, int>(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<MonthlyTimesheetDayModel> 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<MonthlyTimesheetRowModel> 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -7,10 +7,15 @@ namespace WorkTracker.Services.Storage;
|
|||
public sealed class CouchbaseLiteDatabaseProvider : IDisposable
|
||||
{
|
||||
private const string AppSettingsCollectionName = "app_settings";
|
||||
private const string SystemCollectionName = "system";
|
||||
private const string UsersCollectionName = "users";
|
||||
private const string WorkDaysCollectionName = "workdays";
|
||||
private const string SchemaVersionDocumentId = "database_schema";
|
||||
|
||||
public const int CurrentDatabaseSchemaVersion = 1;
|
||||
|
||||
private readonly Database database;
|
||||
private readonly IReadOnlyDictionary<string, Collection> backupCollections;
|
||||
|
||||
public CouchbaseLiteDatabaseProvider(IOptions<CouchbaseLiteOptions> options, IHostEnvironment environment)
|
||||
{
|
||||
|
|
@ -25,17 +30,39 @@ public sealed class CouchbaseLiteDatabaseProvider : IDisposable
|
|||
Directory = databaseDirectory
|
||||
});
|
||||
|
||||
System = database.GetCollection(SystemCollectionName) ?? database.CreateCollection(SystemCollectionName);
|
||||
AppSettings = database.GetCollection(AppSettingsCollectionName) ?? database.CreateCollection(AppSettingsCollectionName);
|
||||
Users = database.GetCollection(UsersCollectionName) ?? database.CreateCollection(UsersCollectionName);
|
||||
WorkDays = database.GetCollection(WorkDaysCollectionName) ?? database.CreateCollection(WorkDaysCollectionName);
|
||||
|
||||
backupCollections = new Dictionary<string, Collection>(StringComparer.Ordinal)
|
||||
{
|
||||
[AppSettingsCollectionName] = AppSettings,
|
||||
[UsersCollectionName] = Users,
|
||||
[WorkDaysCollectionName] = WorkDays
|
||||
};
|
||||
|
||||
DatabaseSchemaVersion = EnsureDatabaseSchemaVersion();
|
||||
}
|
||||
|
||||
public Collection System { get; }
|
||||
|
||||
public Collection AppSettings { get; }
|
||||
|
||||
public Collection Users { get; }
|
||||
|
||||
public Collection WorkDays { get; }
|
||||
|
||||
public int DatabaseSchemaVersion { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, Collection> BackupCollections => backupCollections;
|
||||
|
||||
public void ExecuteInBatch(Action action)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(action);
|
||||
database.InBatch(action);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
database.Close();
|
||||
|
|
@ -53,4 +80,21 @@ public sealed class CouchbaseLiteDatabaseProvider : IDisposable
|
|||
? configuredDirectory
|
||||
: Path.GetFullPath(Path.Combine(contentRootPath, configuredDirectory));
|
||||
}
|
||||
|
||||
private int EnsureDatabaseSchemaVersion()
|
||||
{
|
||||
var document = System.GetDocument(SchemaVersionDocumentId);
|
||||
if (document is not null && document.Contains("schemaVersion"))
|
||||
{
|
||||
return document.GetInt("schemaVersion");
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var mutableDocument = new MutableDocument(SchemaVersionDocumentId);
|
||||
mutableDocument.SetInt("schemaVersion", CurrentDatabaseSchemaVersion);
|
||||
mutableDocument.SetString("createdAtUtc", now.ToString("O"));
|
||||
mutableDocument.SetString("updatedAtUtc", now.ToString("O"));
|
||||
System.Save(mutableDocument);
|
||||
return CurrentDatabaseSchemaVersion;
|
||||
}
|
||||
}
|
||||
38
Services/Storage/DatabaseBackupModels.cs
Normal file
38
Services/Storage/DatabaseBackupModels.cs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
using System.Text.Json;
|
||||
|
||||
namespace WorkTracker.Services.Storage;
|
||||
|
||||
public sealed class DatabaseBackupFile
|
||||
{
|
||||
public required byte[] Content { get; init; }
|
||||
|
||||
public required string ContentType { get; init; }
|
||||
|
||||
public required string FileName { get; init; }
|
||||
}
|
||||
|
||||
public sealed class DatabaseBackupPayload
|
||||
{
|
||||
public int BackupFormatVersion { get; init; }
|
||||
|
||||
public int DatabaseSchemaVersion { get; init; }
|
||||
|
||||
public DateTimeOffset ExportedAtUtc { get; init; }
|
||||
|
||||
public Dictionary<string, List<DatabaseBackupDocumentPayload>> Collections { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class DatabaseBackupDocumentPayload
|
||||
{
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
public JsonElement Data { get; init; }
|
||||
}
|
||||
|
||||
public sealed class DatabaseBackupException : Exception
|
||||
{
|
||||
public DatabaseBackupException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
8
Services/Storage/IDatabaseBackupService.cs
Normal file
8
Services/Storage/IDatabaseBackupService.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
namespace WorkTracker.Services.Storage;
|
||||
|
||||
public interface IDatabaseBackupService
|
||||
{
|
||||
Task<DatabaseBackupFile> ExportAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task ImportAsync(Stream jsonStream, CancellationToken cancellationToken = default);
|
||||
}
|
||||
204
Services/Storage/JsonDatabaseBackupService.cs
Normal file
204
Services/Storage/JsonDatabaseBackupService.cs
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Couchbase.Lite;
|
||||
using Couchbase.Lite.Query;
|
||||
|
||||
namespace WorkTracker.Services.Storage;
|
||||
|
||||
public sealed class JsonDatabaseBackupService(CouchbaseLiteDatabaseProvider databaseProvider) : IDatabaseBackupService
|
||||
{
|
||||
private const int CurrentBackupFormatVersion = 1;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonSerializerOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
public Task<DatabaseBackupFile> ExportAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
EnsureSupportedDatabaseSchemaVersion(databaseProvider.DatabaseSchemaVersion);
|
||||
|
||||
var payload = new DatabaseBackupPayload
|
||||
{
|
||||
BackupFormatVersion = CurrentBackupFormatVersion,
|
||||
DatabaseSchemaVersion = databaseProvider.DatabaseSchemaVersion,
|
||||
ExportedAtUtc = DateTimeOffset.UtcNow,
|
||||
Collections = ExportCollections(cancellationToken)
|
||||
};
|
||||
|
||||
var content = JsonSerializer.SerializeToUtf8Bytes(payload, JsonSerializerOptions);
|
||||
var timestamp = payload.ExportedAtUtc.ToString("yyyyMMdd-HHmmss");
|
||||
|
||||
return Task.FromResult(new DatabaseBackupFile
|
||||
{
|
||||
Content = content,
|
||||
ContentType = "application/json",
|
||||
FileName = $"worktracker-backup-v{payload.DatabaseSchemaVersion}-{timestamp}.json"
|
||||
});
|
||||
}
|
||||
|
||||
public async Task ImportAsync(Stream jsonStream, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(jsonStream);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
DatabaseBackupPayload payload;
|
||||
|
||||
try
|
||||
{
|
||||
payload = await JsonSerializer.DeserializeAsync<DatabaseBackupPayload>(jsonStream, JsonSerializerOptions, cancellationToken)
|
||||
?? throw new DatabaseBackupException("Backup file is empty or invalid.");
|
||||
}
|
||||
catch (JsonException exception)
|
||||
{
|
||||
throw new DatabaseBackupException($"Backup file is not valid JSON: {exception.Message}");
|
||||
}
|
||||
|
||||
ValidatePayload(payload);
|
||||
|
||||
databaseProvider.ExecuteInBatch(() =>
|
||||
{
|
||||
foreach (var collection in databaseProvider.BackupCollections.Values)
|
||||
{
|
||||
ClearCollection(collection);
|
||||
}
|
||||
|
||||
foreach (var (collectionName, documents) in payload.Collections)
|
||||
{
|
||||
var collection = databaseProvider.BackupCollections[collectionName];
|
||||
|
||||
foreach (var document in documents)
|
||||
{
|
||||
var mutableDocument = new MutableDocument(document.Id);
|
||||
mutableDocument.SetJSON(document.Data.GetRawText());
|
||||
collection.Save(mutableDocument);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private Dictionary<string, List<DatabaseBackupDocumentPayload>> ExportCollections(CancellationToken cancellationToken)
|
||||
{
|
||||
var collections = new Dictionary<string, List<DatabaseBackupDocumentPayload>>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var (collectionName, collection) in databaseProvider.BackupCollections)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
collections[collectionName] = GetDocuments(collection, cancellationToken)
|
||||
.Select(document => new DatabaseBackupDocumentPayload
|
||||
{
|
||||
Id = document.Id,
|
||||
Data = ParseJson(document.ToJSON())
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
return collections;
|
||||
}
|
||||
|
||||
private void ValidatePayload(DatabaseBackupPayload payload)
|
||||
{
|
||||
if (payload.BackupFormatVersion <= 0)
|
||||
{
|
||||
throw new DatabaseBackupException("Backup file does not declare a supported format version.");
|
||||
}
|
||||
|
||||
EnsureSupportedBackupFormatVersion(payload.BackupFormatVersion);
|
||||
EnsureSupportedDatabaseSchemaVersion(payload.DatabaseSchemaVersion);
|
||||
|
||||
if (payload.DatabaseSchemaVersion != databaseProvider.DatabaseSchemaVersion)
|
||||
{
|
||||
throw new DatabaseBackupException($"Backup schema version {payload.DatabaseSchemaVersion} does not match database schema version {databaseProvider.DatabaseSchemaVersion}.");
|
||||
}
|
||||
|
||||
var expectedCollectionNames = databaseProvider.BackupCollections.Keys.OrderBy(static value => value, StringComparer.Ordinal).ToArray();
|
||||
var actualCollectionNames = payload.Collections.Keys.OrderBy(static value => value, StringComparer.Ordinal).ToArray();
|
||||
|
||||
if (!expectedCollectionNames.SequenceEqual(actualCollectionNames, StringComparer.Ordinal))
|
||||
{
|
||||
throw new DatabaseBackupException("Backup file does not contain the expected collection set for this application version.");
|
||||
}
|
||||
|
||||
foreach (var (collectionName, documents) in payload.Collections)
|
||||
{
|
||||
foreach (var document in documents)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(document.Id))
|
||||
{
|
||||
throw new DatabaseBackupException($"Backup contains a document without an id in collection '{collectionName}'.");
|
||||
}
|
||||
|
||||
if (document.Data.ValueKind is not JsonValueKind.Object)
|
||||
{
|
||||
throw new DatabaseBackupException($"Backup document '{document.Id}' in collection '{collectionName}' must be a JSON object.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureSupportedBackupFormatVersion(int backupFormatVersion)
|
||||
{
|
||||
if (backupFormatVersion == CurrentBackupFormatVersion)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
throw new DatabaseBackupException($"Backup format version {backupFormatVersion} is not supported by this build. Add a backup format migration before importing it.");
|
||||
}
|
||||
|
||||
private static void EnsureSupportedDatabaseSchemaVersion(int databaseSchemaVersion)
|
||||
{
|
||||
if (databaseSchemaVersion == CouchbaseLiteDatabaseProvider.CurrentDatabaseSchemaVersion)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
throw new DatabaseBackupException($"Database schema version {databaseSchemaVersion} is not supported by this build. Add a schema migration before using this backup.");
|
||||
}
|
||||
|
||||
private static List<Document> GetDocuments(Collection collection, CancellationToken cancellationToken)
|
||||
{
|
||||
var query = QueryBuilder
|
||||
.Select(SelectResult.Expression(Meta.ID))
|
||||
.From(DataSource.Collection(collection))
|
||||
.OrderBy(Ordering.Expression(Meta.ID));
|
||||
|
||||
var documents = new List<Document>();
|
||||
foreach (var result in query.Execute())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var id = result.GetString(0);
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var document = collection.GetDocument(id);
|
||||
if (document is not null)
|
||||
{
|
||||
documents.Add(document);
|
||||
}
|
||||
}
|
||||
|
||||
return documents;
|
||||
}
|
||||
|
||||
private static void ClearCollection(Collection collection)
|
||||
{
|
||||
foreach (var document in GetDocuments(collection, CancellationToken.None))
|
||||
{
|
||||
collection.Delete(document);
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonElement ParseJson(string json)
|
||||
{
|
||||
using var jsonDocument = JsonDocument.Parse(json);
|
||||
return jsonDocument.RootElement.Clone();
|
||||
}
|
||||
}
|
||||
BIN
Templates/monthly-timesheet-template.xlsx
Normal file
BIN
Templates/monthly-timesheet-template.xlsx
Normal file
Binary file not shown.
|
|
@ -8,15 +8,26 @@
|
|||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ClosedXML" Version="0.105.0" />
|
||||
<PackageReference Include="Couchbase.Lite" Version="4.0.3" />
|
||||
<PackageReference Include="NLog" Version="5.3.4" />
|
||||
<PackageReference Include="NLog.Web.AspNetCore" Version="5.3.10" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Remove="tests\**\*.cs" />
|
||||
<EmbeddedResource Remove="tests\**\*" />
|
||||
<None Remove="tests\**\*" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="nlog.config">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="Templates\**\*">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
81
docs/date-range-backup-spec.md
Normal file
81
docs/date-range-backup-spec.md
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
# Date-Range Backup Spec
|
||||
|
||||
## Status
|
||||
|
||||
- Draft only.
|
||||
- Do not implement until full-backup import/export has been exercised in real usage.
|
||||
|
||||
## Goal
|
||||
|
||||
Allow users to export and import only a selected time range without weakening the safety guarantees introduced by full JSON backups.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No implementation in this change.
|
||||
- No automatic migration across JSON backup format versions.
|
||||
- No merging logic for conflicting edits yet.
|
||||
|
||||
## Proposed backup modes
|
||||
|
||||
- `full`: current behavior, exports every managed collection and can fully replace the database.
|
||||
- `dateRange`: future behavior, exports only documents relevant to a user-selected inclusive date interval.
|
||||
|
||||
## Proposed JSON shape changes
|
||||
|
||||
Add a top-level `scope` object while keeping the existing version markers.
|
||||
|
||||
```json
|
||||
{
|
||||
"backupFormatVersion": 1,
|
||||
"databaseSchemaVersion": 1,
|
||||
"exportedAtUtc": "2026-04-24T12:00:00Z",
|
||||
"scope": {
|
||||
"mode": "full"
|
||||
},
|
||||
"collections": {
|
||||
"app_settings": [],
|
||||
"users": [],
|
||||
"workdays": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Future date-range backups would use:
|
||||
|
||||
```json
|
||||
"scope": {
|
||||
"mode": "dateRange",
|
||||
"from": "2026-04-01",
|
||||
"to": "2026-04-30"
|
||||
}
|
||||
```
|
||||
|
||||
## Selection rules for `dateRange`
|
||||
|
||||
- `workdays`: include documents whose logical day falls within `from` and `to` inclusive.
|
||||
- `app_settings`: exclude by default. Settings are global, not time-scoped.
|
||||
- `users`: exclude by default. Authentication data is global, not time-scoped.
|
||||
- Future collections must declare whether they are `global`, `timeScoped`, or `derived` before they participate in range backups.
|
||||
|
||||
## Import semantics for `dateRange`
|
||||
|
||||
- Import must not reuse the current full-overwrite flow.
|
||||
- Import must require a different confirmation message than full restore.
|
||||
- Import must define conflict rules before implementation.
|
||||
- Preferred first version: replace only the selected date range inside time-scoped collections.
|
||||
- Global collections must remain untouched unless explicitly opted in by the user and supported by the backup format version.
|
||||
|
||||
## Compatibility and versioning requirements
|
||||
|
||||
- Keep `backupFormatVersion` and `databaseSchemaVersion` mandatory for every backup mode.
|
||||
- If `scope.mode` is unknown, reject import.
|
||||
- If `backupFormatVersion` is unsupported, reject import and surface that a migration/importer is required.
|
||||
- If `databaseSchemaVersion` is unsupported, reject import and surface that a schema migration is required.
|
||||
- When date-range import is implemented, version-specific import handlers should be introduced behind a dispatch layer such as `ImportV1Full`, `ImportV1DateRange`, and future `ImportV2*` handlers.
|
||||
|
||||
## UI requirements for future work
|
||||
|
||||
- Add date pickers for `from` and `to`.
|
||||
- Validate `from <= to` before export/import is enabled.
|
||||
- Show a summary of what will be affected before confirming import.
|
||||
- Keep full-backup export/import available as the safer recovery path.
|
||||
Binary file not shown.
167
tests/WorkTracker.Tests/JsonDatabaseBackupServiceTests.cs
Normal file
167
tests/WorkTracker.Tests/JsonDatabaseBackupServiceTests.cs
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Couchbase.Lite;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using WorkTracker.Configuration;
|
||||
using WorkTracker.Domain;
|
||||
using WorkTracker.Services.Settings;
|
||||
using WorkTracker.Services.Storage;
|
||||
using Xunit;
|
||||
|
||||
namespace WorkTracker.Tests;
|
||||
|
||||
public sealed class JsonDatabaseBackupServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ExportAndImport_RoundTripsManagedCollectionsAndVersions()
|
||||
{
|
||||
using var sourceDatabase = new TestDatabaseHandle();
|
||||
await SeedAsync(sourceDatabase.Provider);
|
||||
|
||||
var backupService = new JsonDatabaseBackupService(sourceDatabase.Provider);
|
||||
var backupFile = await backupService.ExportAsync();
|
||||
|
||||
using var backupJson = JsonDocument.Parse(backupFile.Content);
|
||||
Assert.Equal(1, backupJson.RootElement.GetProperty("backupFormatVersion").GetInt32());
|
||||
Assert.Equal(CouchbaseLiteDatabaseProvider.CurrentDatabaseSchemaVersion, backupJson.RootElement.GetProperty("databaseSchemaVersion").GetInt32());
|
||||
Assert.True(backupJson.RootElement.GetProperty("collections").TryGetProperty("app_settings", out _));
|
||||
Assert.True(backupJson.RootElement.GetProperty("collections").TryGetProperty("users", out _));
|
||||
Assert.True(backupJson.RootElement.GetProperty("collections").TryGetProperty("workdays", out _));
|
||||
|
||||
using var targetDatabase = new TestDatabaseHandle();
|
||||
var importService = new JsonDatabaseBackupService(targetDatabase.Provider);
|
||||
|
||||
await using (var stream = new MemoryStream(backupFile.Content, writable: false))
|
||||
{
|
||||
await importService.ImportAsync(stream);
|
||||
}
|
||||
|
||||
var appSettingsService = new CouchbaseLiteAppSettingsService(targetDatabase.Provider);
|
||||
var importedSettings = await appSettingsService.GetAsync();
|
||||
|
||||
Assert.Equal(AppThemeMode.Dark, importedSettings.ThemeMode);
|
||||
Assert.Equal("USD", importedSettings.Currency);
|
||||
Assert.Equal("en-US", importedSettings.Locale);
|
||||
Assert.Equal(CouchbaseLiteDatabaseProvider.CurrentDatabaseSchemaVersion, targetDatabase.Provider.DatabaseSchemaVersion);
|
||||
|
||||
var importedUser = targetDatabase.Provider.Users.GetDocument("USER1");
|
||||
Assert.NotNull(importedUser);
|
||||
Assert.Equal("admin", importedUser!.GetString("username"));
|
||||
|
||||
var importedWorkDay = targetDatabase.Provider.WorkDays.GetDocument("2026-04-24");
|
||||
Assert.NotNull(importedWorkDay);
|
||||
Assert.Equal("2026-04-24", importedWorkDay!.GetString("date"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_WhenSchemaVersionDoesNotMatch_RejectsWithoutOverwritingDatabase()
|
||||
{
|
||||
using var sourceDatabase = new TestDatabaseHandle();
|
||||
await SeedAsync(sourceDatabase.Provider);
|
||||
|
||||
var backupService = new JsonDatabaseBackupService(sourceDatabase.Provider);
|
||||
var backupFile = await backupService.ExportAsync();
|
||||
|
||||
var modifiedBackup = JsonNode.Parse(backupFile.Content)!;
|
||||
modifiedBackup["databaseSchemaVersion"] = CouchbaseLiteDatabaseProvider.CurrentDatabaseSchemaVersion + 1;
|
||||
var modifiedContent = Encoding.UTF8.GetBytes(modifiedBackup.ToJsonString(new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }));
|
||||
|
||||
using var targetDatabase = new TestDatabaseHandle();
|
||||
SeedExistingTargetData(targetDatabase.Provider);
|
||||
var importService = new JsonDatabaseBackupService(targetDatabase.Provider);
|
||||
|
||||
await using var stream = new MemoryStream(modifiedContent, writable: false);
|
||||
var exception = await Assert.ThrowsAsync<DatabaseBackupException>(() => importService.ImportAsync(stream));
|
||||
|
||||
Assert.Contains("not supported", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.NotNull(targetDatabase.Provider.Users.GetDocument("KEEP"));
|
||||
Assert.Null(targetDatabase.Provider.Users.GetDocument("USER1"));
|
||||
}
|
||||
|
||||
private static async Task SeedAsync(CouchbaseLiteDatabaseProvider provider)
|
||||
{
|
||||
var appSettingsService = new CouchbaseLiteAppSettingsService(provider);
|
||||
await appSettingsService.SaveAsync(new AppSettingsDocument
|
||||
{
|
||||
ThemeMode = AppThemeMode.Dark,
|
||||
Currency = "USD",
|
||||
Locale = "en-US",
|
||||
StandardWorkHoursPerDay = 7.5m,
|
||||
HourlyGrossRate = 30m,
|
||||
ProfitabilityCoefficient = 0.8m,
|
||||
InpsRate = 0.2m,
|
||||
SubstituteTaxRate = 0.1m
|
||||
});
|
||||
|
||||
var userDocument = new MutableDocument("USER1");
|
||||
userDocument.SetString("username", "admin");
|
||||
userDocument.SetString("usernameNormalized", "ADMIN");
|
||||
userDocument.SetString("passwordHash", "hash");
|
||||
userDocument.SetBoolean("mustChangePassword", false);
|
||||
provider.Users.Save(userDocument);
|
||||
|
||||
var workDayDocument = new MutableDocument("2026-04-24");
|
||||
workDayDocument.SetString("date", "2026-04-24");
|
||||
workDayDocument.SetBoolean("isWeekend", false);
|
||||
workDayDocument.SetBoolean("isItalianFestivity", false);
|
||||
workDayDocument.SetArray("workUnits", new MutableArrayObject());
|
||||
workDayDocument.SetArray("calendarEvents", new MutableArrayObject());
|
||||
workDayDocument.SetString("createdAtUtc", "2026-04-24T08:00:00.0000000+00:00");
|
||||
workDayDocument.SetString("updatedAtUtc", "2026-04-24T08:00:00.0000000+00:00");
|
||||
provider.WorkDays.Save(workDayDocument);
|
||||
}
|
||||
|
||||
private static void SeedExistingTargetData(CouchbaseLiteDatabaseProvider provider)
|
||||
{
|
||||
var existingUser = new MutableDocument("KEEP");
|
||||
existingUser.SetString("username", "keep-me");
|
||||
existingUser.SetString("usernameNormalized", "KEEP-ME");
|
||||
existingUser.SetString("passwordHash", "keep-hash");
|
||||
existingUser.SetBoolean("mustChangePassword", false);
|
||||
provider.Users.Save(existingUser);
|
||||
}
|
||||
|
||||
private sealed class TestDatabaseHandle : IDisposable
|
||||
{
|
||||
private readonly string directoryPath;
|
||||
|
||||
public TestDatabaseHandle()
|
||||
{
|
||||
directoryPath = Path.Combine(Path.GetTempPath(), "WorkTracker.Tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(directoryPath);
|
||||
|
||||
Provider = new CouchbaseLiteDatabaseProvider(
|
||||
Options.Create(new CouchbaseLiteOptions
|
||||
{
|
||||
Directory = directoryPath,
|
||||
DatabaseName = $"worktracker-tests-{Guid.NewGuid():N}"
|
||||
}),
|
||||
new TestHostEnvironment());
|
||||
}
|
||||
|
||||
public CouchbaseLiteDatabaseProvider Provider { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Provider.Dispose();
|
||||
|
||||
if (Directory.Exists(directoryPath))
|
||||
{
|
||||
Directory.Delete(directoryPath, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestHostEnvironment : IHostEnvironment
|
||||
{
|
||||
public string EnvironmentName { get; set; } = "Development";
|
||||
|
||||
public string ApplicationName { get; set; } = "WorkTracker.Tests";
|
||||
|
||||
public string ContentRootPath { get; set; } = Directory.GetCurrentDirectory();
|
||||
|
||||
public Microsoft.Extensions.FileProviders.IFileProvider ContentRootFileProvider { get; set; } = null!;
|
||||
}
|
||||
}
|
||||
119
tests/WorkTracker.Tests/MonthlyTimesheetExcelExporterTests.cs
Normal file
119
tests/WorkTracker.Tests/MonthlyTimesheetExcelExporterTests.cs
Normal file
|
|
@ -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<DateOnly>
|
||||
{
|
||||
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<DateOnly>());
|
||||
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<DateOnly> holidays)
|
||||
{
|
||||
var lastDay = monthStart.AddMonths(1).AddDays(-1);
|
||||
var days = new List<MonthlyTimesheetDayModel>();
|
||||
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<decimal?>(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.");
|
||||
}
|
||||
}
|
||||
21
tests/WorkTracker.Tests/WorkTracker.Tests.csproj
Normal file
21
tests/WorkTracker.Tests/WorkTracker.Tests.csproj
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\WorkTracker.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
90
tests/WorkTracker.Tests/WorkbookAssert.cs
Normal file
90
tests/WorkTracker.Tests/WorkbookAssert.cs
Normal file
|
|
@ -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()
|
||||
};
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue