2026-03-17 22:10:19 +01:00
|
|
|
using Couchbase.Lite;
|
2026-04-22 11:07:30 +02:00
|
|
|
using Couchbase.Lite.Query;
|
2026-03-17 22:10:19 +01:00
|
|
|
using WorkTracker.Domain;
|
2026-04-22 11:07:30 +02:00
|
|
|
using WorkTracker.Formatting;
|
2026-03-17 22:10:19 +01:00
|
|
|
using WorkTracker.Services.Festivities;
|
|
|
|
|
using WorkTracker.Services.Settings;
|
|
|
|
|
using WorkTracker.Services.Storage;
|
|
|
|
|
|
|
|
|
|
namespace WorkTracker.Services.WorkDays;
|
|
|
|
|
|
|
|
|
|
public sealed class CouchbaseLiteWorkDayService : IWorkDayService
|
|
|
|
|
{
|
|
|
|
|
private readonly Collection workDaysCollection;
|
|
|
|
|
private readonly IAppSettingsService appSettingsService;
|
|
|
|
|
private readonly IItalianFestivitySource festivitySource;
|
|
|
|
|
|
|
|
|
|
public CouchbaseLiteWorkDayService(
|
|
|
|
|
CouchbaseLiteDatabaseProvider databaseProvider,
|
|
|
|
|
IAppSettingsService appSettingsService,
|
|
|
|
|
IItalianFestivitySource festivitySource)
|
|
|
|
|
{
|
|
|
|
|
workDaysCollection = databaseProvider.WorkDays;
|
|
|
|
|
this.appSettingsService = appSettingsService;
|
|
|
|
|
this.festivitySource = festivitySource;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public Task<WorkDayDocument?> GetAsync(DateOnly date, CancellationToken cancellationToken = default)
|
|
|
|
|
{
|
|
|
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
|
|
|
|
|
|
var id = date.ToString("yyyy-MM-dd");
|
|
|
|
|
var doc = workDaysCollection.GetDocument(id);
|
|
|
|
|
return Task.FromResult(doc is not null ? Map(doc) : null);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 16:11:27 +02:00
|
|
|
public async Task<WorkUnitDocument?> GetWorkUnitAsync(DateOnly date, string workUnitId, CancellationToken cancellationToken = default)
|
2026-03-17 22:10:19 +01:00
|
|
|
{
|
|
|
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
|
|
2026-04-20 16:11:27 +02:00
|
|
|
var day = await GetAsync(date, cancellationToken);
|
|
|
|
|
return day?.WorkUnits.FirstOrDefault(unit => string.Equals(unit.Id, workUnitId, StringComparison.Ordinal));
|
|
|
|
|
}
|
2026-03-17 22:10:19 +01:00
|
|
|
|
2026-04-20 16:11:27 +02:00
|
|
|
public async Task<CalendarEventDocument?> GetCalendarEventAsync(DateOnly date, string calendarEventId, CancellationToken cancellationToken = default)
|
|
|
|
|
{
|
|
|
|
|
cancellationToken.ThrowIfCancellationRequested();
|
2026-03-17 22:10:19 +01:00
|
|
|
|
2026-04-20 16:11:27 +02:00
|
|
|
var day = await GetAsync(date, cancellationToken);
|
2026-04-22 11:07:30 +02:00
|
|
|
var calendarEvent = day?.CalendarEvents.FirstOrDefault(entry => string.Equals(entry.Id, calendarEventId, StringComparison.Ordinal));
|
|
|
|
|
if (calendarEvent is not null)
|
|
|
|
|
{
|
|
|
|
|
return calendarEvent;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return FindCalendarEventLocation(calendarEventId, cancellationToken)?.CalendarEvent;
|
2026-04-20 16:11:27 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<WorkUnitDocument> SaveWorkUnitAsync(DateOnly date, WorkUnitDocument workUnit, CancellationToken cancellationToken = default)
|
|
|
|
|
{
|
|
|
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
|
|
|
|
|
|
var settings = await appSettingsService.GetAsync(cancellationToken);
|
|
|
|
|
var day = await GetOrCreateDayAsync(date, cancellationToken);
|
|
|
|
|
var now = DateTimeOffset.UtcNow;
|
|
|
|
|
var existingIndex = day.WorkUnits.FindIndex(unit => string.Equals(unit.Id, workUnit.Id, StringComparison.Ordinal));
|
|
|
|
|
var existingCreatedAt = existingIndex >= 0 ? day.WorkUnits[existingIndex].CreatedAtUtc : now;
|
|
|
|
|
|
|
|
|
|
workUnit.Id = string.IsNullOrWhiteSpace(workUnit.Id) ? Guid.NewGuid().ToString("N") : workUnit.Id;
|
|
|
|
|
workUnit.Label = string.IsNullOrWhiteSpace(workUnit.Label) ? "Work unit" : workUnit.Label.Trim();
|
|
|
|
|
workUnit.ManualWorkedHours = Math.Max(0m, workUnit.ManualWorkedHours);
|
|
|
|
|
workUnit.CoeffSnapshot = new CoeffSnapshotDocument
|
2026-03-17 22:10:19 +01:00
|
|
|
{
|
|
|
|
|
StandardWorkHoursPerDay = settings.StandardWorkHoursPerDay,
|
|
|
|
|
HourlyGrossRate = settings.HourlyGrossRate,
|
|
|
|
|
ProfitabilityCoefficient = settings.ProfitabilityCoefficient,
|
|
|
|
|
InpsRate = settings.InpsRate,
|
|
|
|
|
SubstituteTaxRate = settings.SubstituteTaxRate
|
|
|
|
|
};
|
2026-04-20 16:11:27 +02:00
|
|
|
workUnit.CreatedAtUtc = existingCreatedAt;
|
|
|
|
|
workUnit.UpdatedAtUtc = now;
|
2026-03-17 22:10:19 +01:00
|
|
|
|
2026-04-20 16:11:27 +02:00
|
|
|
Compute(workUnit);
|
2026-03-17 22:10:19 +01:00
|
|
|
|
2026-04-20 16:11:27 +02:00
|
|
|
if (existingIndex >= 0)
|
|
|
|
|
{
|
|
|
|
|
day.WorkUnits[existingIndex] = workUnit;
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
day.WorkUnits.Add(workUnit);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
day.UpdatedAtUtc = now;
|
|
|
|
|
SortEntries(day);
|
|
|
|
|
SaveDocument(day);
|
|
|
|
|
return workUnit;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<CalendarEventDocument> SaveCalendarEventAsync(DateOnly date, CalendarEventDocument calendarEvent, CancellationToken cancellationToken = default)
|
|
|
|
|
{
|
|
|
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
|
|
|
|
|
|
var now = DateTimeOffset.UtcNow;
|
2026-04-22 11:07:30 +02:00
|
|
|
var existingLocation = string.IsNullOrWhiteSpace(calendarEvent.Id)
|
|
|
|
|
? null
|
|
|
|
|
: FindCalendarEventLocation(calendarEvent.Id, cancellationToken);
|
|
|
|
|
var targetDate = calendarEvent.StartDate == default ? date : calendarEvent.StartDate;
|
|
|
|
|
var endDate = calendarEvent.EndDate;
|
|
|
|
|
|
|
|
|
|
if (endDate.HasValue && endDate.Value < targetDate)
|
|
|
|
|
{
|
|
|
|
|
(targetDate, endDate) = (endDate.Value, targetDate);
|
|
|
|
|
}
|
2026-04-20 16:11:27 +02:00
|
|
|
|
|
|
|
|
calendarEvent.Id = string.IsNullOrWhiteSpace(calendarEvent.Id) ? Guid.NewGuid().ToString("N") : calendarEvent.Id;
|
2026-04-22 11:07:30 +02:00
|
|
|
calendarEvent.StartDate = targetDate;
|
|
|
|
|
calendarEvent.EndDate = endDate.HasValue && endDate.Value > targetDate
|
|
|
|
|
? endDate.Value
|
|
|
|
|
: null;
|
|
|
|
|
calendarEvent.Description = calendarEvent.Description?.Trim() ?? string.Empty;
|
|
|
|
|
calendarEvent.CreatedAtUtc = existingLocation?.CalendarEvent.CreatedAtUtc ?? now;
|
2026-04-20 16:11:27 +02:00
|
|
|
calendarEvent.UpdatedAtUtc = now;
|
|
|
|
|
|
|
|
|
|
Compute(calendarEvent);
|
|
|
|
|
|
2026-04-22 11:07:30 +02:00
|
|
|
if (existingLocation is not null)
|
2026-03-17 22:10:19 +01:00
|
|
|
{
|
2026-04-22 11:07:30 +02:00
|
|
|
var ownerDay = existingLocation.Day;
|
|
|
|
|
if (ownerDay.Date == targetDate)
|
|
|
|
|
{
|
|
|
|
|
ownerDay.CalendarEvents[existingLocation.Index] = calendarEvent;
|
|
|
|
|
ownerDay.UpdatedAtUtc = now;
|
|
|
|
|
SortEntries(ownerDay);
|
|
|
|
|
SaveDocument(ownerDay);
|
|
|
|
|
return calendarEvent;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ownerDay.CalendarEvents.RemoveAt(existingLocation.Index);
|
|
|
|
|
DeleteOrSaveDay(ownerDay);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var targetDay = await GetOrCreateDayAsync(targetDate, cancellationToken);
|
|
|
|
|
var targetIndex = targetDay.CalendarEvents.FindIndex(entry => string.Equals(entry.Id, calendarEvent.Id, StringComparison.Ordinal));
|
|
|
|
|
if (targetIndex >= 0)
|
|
|
|
|
{
|
|
|
|
|
targetDay.CalendarEvents[targetIndex] = calendarEvent;
|
2026-03-17 22:10:19 +01:00
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
2026-04-22 11:07:30 +02:00
|
|
|
targetDay.CalendarEvents.Add(calendarEvent);
|
2026-03-17 22:10:19 +01:00
|
|
|
}
|
|
|
|
|
|
2026-04-22 11:07:30 +02:00
|
|
|
targetDay.UpdatedAtUtc = now;
|
|
|
|
|
SortEntries(targetDay);
|
|
|
|
|
SaveDocument(targetDay);
|
2026-04-20 16:11:27 +02:00
|
|
|
return calendarEvent;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<bool> DeleteWorkUnitAsync(DateOnly date, string workUnitId, CancellationToken cancellationToken = default)
|
|
|
|
|
{
|
|
|
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
|
|
|
|
|
|
var day = await GetAsync(date, cancellationToken);
|
|
|
|
|
if (day is null)
|
|
|
|
|
{
|
|
|
|
|
return false;
|
|
|
|
|
}
|
2026-03-17 22:10:19 +01:00
|
|
|
|
2026-04-20 16:11:27 +02:00
|
|
|
var removed = day.WorkUnits.RemoveAll(unit => string.Equals(unit.Id, workUnitId, StringComparison.Ordinal));
|
|
|
|
|
if (removed == 0)
|
|
|
|
|
{
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return DeleteOrSaveDay(day);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<bool> DeleteCalendarEventAsync(DateOnly date, string calendarEventId, CancellationToken cancellationToken = default)
|
|
|
|
|
{
|
|
|
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
|
|
|
|
|
|
var day = await GetAsync(date, cancellationToken);
|
2026-04-22 11:07:30 +02:00
|
|
|
if (day is not null)
|
2026-04-20 16:11:27 +02:00
|
|
|
{
|
2026-04-22 11:07:30 +02:00
|
|
|
var removed = day.CalendarEvents.RemoveAll(calendarEvent => string.Equals(calendarEvent.Id, calendarEventId, StringComparison.Ordinal));
|
|
|
|
|
if (removed > 0)
|
|
|
|
|
{
|
|
|
|
|
return DeleteOrSaveDay(day);
|
|
|
|
|
}
|
2026-04-20 16:11:27 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-22 11:07:30 +02:00
|
|
|
var existingLocation = FindCalendarEventLocation(calendarEventId, cancellationToken);
|
|
|
|
|
if (existingLocation is null)
|
2026-04-20 16:11:27 +02:00
|
|
|
{
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 11:07:30 +02:00
|
|
|
existingLocation.Day.CalendarEvents.RemoveAt(existingLocation.Index);
|
|
|
|
|
return DeleteOrSaveDay(existingLocation.Day);
|
2026-03-17 22:10:19 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public Task<IReadOnlyList<WorkDayDocument>> GetRangeAsync(DateOnly from, DateOnly to, CancellationToken cancellationToken = default)
|
|
|
|
|
{
|
|
|
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
|
|
2026-04-22 11:07:30 +02:00
|
|
|
var storedDays = GetAllDays(cancellationToken);
|
|
|
|
|
var results = new Dictionary<DateOnly, WorkDayDocument>();
|
|
|
|
|
|
|
|
|
|
foreach (var day in storedDays)
|
2026-03-17 22:10:19 +01:00
|
|
|
{
|
2026-04-22 11:07:30 +02:00
|
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
|
|
|
|
|
|
if (day.Date >= from && day.Date <= to)
|
2026-03-17 22:10:19 +01:00
|
|
|
{
|
2026-04-22 11:07:30 +02:00
|
|
|
if (results.TryGetValue(day.Date, out var existingRangeDay))
|
|
|
|
|
{
|
|
|
|
|
MergeStoredDayIntoRangeDay(existingRangeDay, day);
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
results[day.Date] = CloneDayForRange(day.Date, day);
|
|
|
|
|
}
|
2026-03-17 22:10:19 +01:00
|
|
|
}
|
2026-04-22 11:07:30 +02:00
|
|
|
|
|
|
|
|
foreach (var calendarEvent in day.CalendarEvents)
|
|
|
|
|
{
|
|
|
|
|
var startDate = calendarEvent.StartDate == default ? day.Date : calendarEvent.StartDate;
|
|
|
|
|
var endDate = calendarEvent.EndDate ?? startDate;
|
|
|
|
|
if (endDate < from || startDate > to)
|
|
|
|
|
{
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var overlapStart = startDate < from ? from : startDate;
|
|
|
|
|
var overlapEnd = endDate > to ? to : endDate;
|
|
|
|
|
for (var date = overlapStart; date <= overlapEnd; date = date.AddDays(1))
|
|
|
|
|
{
|
|
|
|
|
if (!results.TryGetValue(date, out var rangeDay))
|
|
|
|
|
{
|
|
|
|
|
rangeDay = CloneDayForRange(date);
|
|
|
|
|
results.Add(date, rangeDay);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
AddProjectedCalendarEvent(rangeDay, calendarEvent);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var orderedResults = results.Values.OrderBy(day => day.Date).ToList();
|
|
|
|
|
foreach (var day in orderedResults)
|
|
|
|
|
{
|
|
|
|
|
SortEntries(day);
|
2026-03-17 22:10:19 +01:00
|
|
|
}
|
|
|
|
|
|
2026-04-22 11:07:30 +02:00
|
|
|
return Task.FromResult<IReadOnlyList<WorkDayDocument>>(orderedResults);
|
2026-03-17 22:10:19 +01:00
|
|
|
}
|
|
|
|
|
|
2026-04-20 16:11:27 +02:00
|
|
|
public async Task<MonthlySummaryModel> GetMonthlySummaryAsync(int year, int month, bool includePreview, CancellationToken cancellationToken = default)
|
2026-03-17 22:10:19 +01:00
|
|
|
{
|
|
|
|
|
var from = new DateOnly(year, month, 1);
|
2026-04-20 23:56:23 +02:00
|
|
|
return await BuildMonthlySummaryAsync(from, includePreview, cancellationToken);
|
|
|
|
|
}
|
2026-04-20 16:11:27 +02:00
|
|
|
|
2026-04-20 23:56:23 +02:00
|
|
|
public async Task<IReadOnlyList<MonthlySummaryModel>> GetYearlySummaryAsync(int year, bool includePreview, CancellationToken cancellationToken = default)
|
|
|
|
|
{
|
|
|
|
|
var summaries = new List<MonthlySummaryModel>(12);
|
2026-04-20 16:11:27 +02:00
|
|
|
|
2026-04-20 23:56:23 +02:00
|
|
|
for (var month = 1; month <= 12; month++)
|
2026-03-17 22:10:19 +01:00
|
|
|
{
|
2026-04-20 23:56:23 +02:00
|
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
|
summaries.Add(await BuildMonthlySummaryAsync(new DateOnly(year, month, 1), includePreview, cancellationToken));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return summaries;
|
2026-03-17 22:10:19 +01:00
|
|
|
}
|
|
|
|
|
|
2026-04-20 17:23:54 +02:00
|
|
|
public async Task<MonthlyTimesheetModel> GetMonthlyTimesheetAsync(int year, int month, bool includePreview, CancellationToken cancellationToken = default)
|
|
|
|
|
{
|
|
|
|
|
var from = new DateOnly(year, month, 1);
|
|
|
|
|
var to = from.AddMonths(1).AddDays(-1);
|
|
|
|
|
var days = await GetRangeAsync(from, to, cancellationToken);
|
|
|
|
|
var dayLookup = days.ToDictionary(day => day.Date);
|
|
|
|
|
var settings = await appSettingsService.GetAsync(cancellationToken);
|
|
|
|
|
|
|
|
|
|
var daySummaries = new List<MonthlyTimesheetDaySummary>();
|
|
|
|
|
for (var date = from; date <= to; date = date.AddDays(1))
|
|
|
|
|
{
|
|
|
|
|
dayLookup.TryGetValue(date, out var day);
|
|
|
|
|
daySummaries.Add(CreateTimesheetDaySummary(day, date, includePreview, settings.StandardWorkHoursPerDay));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return new MonthlyTimesheetModel
|
|
|
|
|
{
|
|
|
|
|
Year = year,
|
|
|
|
|
Month = month,
|
|
|
|
|
Days = daySummaries.Select(summary => new MonthlyTimesheetDayModel
|
|
|
|
|
{
|
|
|
|
|
Date = summary.Date,
|
|
|
|
|
IsWeekend = summary.Date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday,
|
|
|
|
|
IsHoliday = summary.HolidayDays > 0m || dayLookup.GetValueOrDefault(summary.Date)?.IsItalianFestivity == true,
|
|
|
|
|
IsClosure = summary.VacationDays > 0m && HasEventType(dayLookup.GetValueOrDefault(summary.Date), CalendarEventType.Closure),
|
|
|
|
|
WorkUnitSummaries = dayLookup.GetValueOrDefault(summary.Date)?.WorkUnits
|
|
|
|
|
.Where(unit => includePreview || !unit.IsPreview)
|
|
|
|
|
.Select(FormatTimesheetWorkUnitSummary)
|
|
|
|
|
.ToList() ?? [],
|
|
|
|
|
EventSummaries = dayLookup.GetValueOrDefault(summary.Date)?.CalendarEvents
|
|
|
|
|
.Select(FormatTimesheetEventSummary)
|
|
|
|
|
.ToList() ?? []
|
|
|
|
|
}).ToList(),
|
|
|
|
|
Rows =
|
|
|
|
|
[
|
|
|
|
|
CreateTimesheetRow("office", "Ore lavorative in presenza", MonthlyTimesheetValueFormat.Hours, daySummaries.Select(summary => summary.OfficeHours)),
|
|
|
|
|
CreateTimesheetRow("home", "Ore lavorative in smart working", MonthlyTimesheetValueFormat.Hours, daySummaries.Select(summary => summary.HomeHours)),
|
|
|
|
|
CreateTimesheetRow("overtime", "Straordinari", MonthlyTimesheetValueFormat.Hours, daySummaries.Select(summary => summary.OvertimeHours)),
|
|
|
|
|
CreateTimesheetRow("weekend", "Weekend", MonthlyTimesheetValueFormat.Hours, daySummaries.Select(summary => summary.WeekendHours)),
|
|
|
|
|
CreateTimesheetRow("night", "Notturni (22-06)", MonthlyTimesheetValueFormat.Hours, daySummaries.Select(summary => summary.NightHours)),
|
|
|
|
|
CreateTimesheetRow("vacation", "Giorni di ferie", MonthlyTimesheetValueFormat.Days, daySummaries.Select(summary => summary.VacationDays)),
|
|
|
|
|
CreateTimesheetRow("permit", "Ore di permesso", MonthlyTimesheetValueFormat.Hours, daySummaries.Select(summary => summary.PermitHours)),
|
|
|
|
|
CreateTimesheetRow("compensatory-rest", "Riposo compensativo", MonthlyTimesheetValueFormat.Days, daySummaries.Select(summary => summary.CompensatoryRestDays), includeZeroTotal: false),
|
|
|
|
|
CreateTimesheetRow("sick", "Giorni di malattia", MonthlyTimesheetValueFormat.Days, daySummaries.Select(summary => summary.SickDays)),
|
|
|
|
|
CreateTimesheetRow("holiday", "Festività", MonthlyTimesheetValueFormat.Days, daySummaries.Select(summary => summary.HolidayDays))
|
|
|
|
|
]
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 16:11:27 +02:00
|
|
|
public async Task<int> GenerateMonthlyPreviewWorkUnitsAsync(int year, int month, CancellationToken cancellationToken = default)
|
2026-03-17 22:10:19 +01:00
|
|
|
{
|
2026-04-20 16:11:27 +02:00
|
|
|
cancellationToken.ThrowIfCancellationRequested();
|
2026-03-17 22:10:19 +01:00
|
|
|
|
2026-04-20 16:11:27 +02:00
|
|
|
var settings = await appSettingsService.GetAsync(cancellationToken);
|
|
|
|
|
var festivities = festivitySource.GetFestivities(year);
|
|
|
|
|
var from = new DateOnly(year, month, 1);
|
|
|
|
|
var to = from.AddMonths(1).AddDays(-1);
|
|
|
|
|
var createdDays = 0;
|
2026-04-22 11:07:30 +02:00
|
|
|
var projectedDays = await GetRangeAsync(from, to, cancellationToken);
|
|
|
|
|
var projectedLookup = projectedDays.ToDictionary(day => day.Date);
|
2026-04-20 16:11:27 +02:00
|
|
|
|
|
|
|
|
for (var date = from; date <= to; date = date.AddDays(1))
|
2026-03-17 22:10:19 +01:00
|
|
|
{
|
2026-04-20 16:11:27 +02:00
|
|
|
cancellationToken.ThrowIfCancellationRequested();
|
2026-03-17 22:10:19 +01:00
|
|
|
|
2026-04-20 16:11:27 +02:00
|
|
|
if (date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday || festivities.Contains(date))
|
|
|
|
|
{
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-03-17 22:10:19 +01:00
|
|
|
|
2026-04-20 16:11:27 +02:00
|
|
|
var day = await GetOrCreateDayAsync(date, cancellationToken);
|
2026-04-22 11:07:30 +02:00
|
|
|
var projectedDay = projectedLookup.GetValueOrDefault(date);
|
|
|
|
|
if (day.WorkUnits.Count > 0 || projectedDay?.CalendarEvents.Any(entry => IsNonWorkingEvent(entry.EventType)) == true)
|
2026-04-20 16:11:27 +02:00
|
|
|
{
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-03-17 22:10:19 +01:00
|
|
|
|
2026-04-20 16:11:27 +02:00
|
|
|
day.WorkUnits.Add(CreatePreviewWorkUnit("Morning", new TimeOnly(8, 30), new TimeOnly(13, 0), settings));
|
|
|
|
|
day.WorkUnits.Add(CreatePreviewWorkUnit("Afternoon", new TimeOnly(14, 0), new TimeOnly(17, 30), settings));
|
|
|
|
|
day.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
2026-03-17 22:10:19 +01:00
|
|
|
|
2026-04-20 16:11:27 +02:00
|
|
|
SortEntries(day);
|
|
|
|
|
SaveDocument(day);
|
|
|
|
|
createdDays++;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return createdDays;
|
2026-03-17 22:10:19 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void SaveDocument(WorkDayDocument day)
|
|
|
|
|
{
|
|
|
|
|
var doc = new MutableDocument(day.Id);
|
|
|
|
|
doc.SetString("date", day.Date.ToString("yyyy-MM-dd"));
|
|
|
|
|
doc.SetBoolean("isWeekend", day.IsWeekend);
|
|
|
|
|
doc.SetBoolean("isItalianFestivity", day.IsItalianFestivity);
|
|
|
|
|
|
2026-04-20 16:11:27 +02:00
|
|
|
var workUnits = new MutableArrayObject();
|
|
|
|
|
foreach (var unit in day.WorkUnits)
|
|
|
|
|
{
|
|
|
|
|
var entry = new MutableDictionaryObject();
|
|
|
|
|
entry.SetString("id", unit.Id);
|
|
|
|
|
entry.SetString("label", unit.Label);
|
|
|
|
|
entry.SetInt("location", (int)unit.Location);
|
|
|
|
|
entry.SetString("startTime", unit.StartTime?.ToString("HH:mm"));
|
|
|
|
|
entry.SetString("endTime", unit.EndTime?.ToString("HH:mm"));
|
|
|
|
|
entry.SetBoolean("isPreview", unit.IsPreview);
|
|
|
|
|
entry.SetDouble("manualWorkedHours", decimal.ToDouble(unit.ManualWorkedHours));
|
|
|
|
|
entry.SetDouble("calculatedWorkedHours", decimal.ToDouble(unit.CalculatedWorkedHours));
|
|
|
|
|
entry.SetDouble("workedHoursDelta", decimal.ToDouble(unit.WorkedHoursDelta));
|
|
|
|
|
entry.SetDouble("grossIncome", decimal.ToDouble(unit.GrossIncome));
|
|
|
|
|
entry.SetDouble("netIncome", decimal.ToDouble(unit.NetIncome));
|
|
|
|
|
entry.SetString("notes", unit.Notes);
|
|
|
|
|
entry.SetDouble("coeff_standardWorkHoursPerDay", decimal.ToDouble(unit.CoeffSnapshot.StandardWorkHoursPerDay));
|
|
|
|
|
entry.SetDouble("coeff_hourlyGrossRate", decimal.ToDouble(unit.CoeffSnapshot.HourlyGrossRate));
|
|
|
|
|
entry.SetDouble("coeff_profitabilityCoefficient", decimal.ToDouble(unit.CoeffSnapshot.ProfitabilityCoefficient));
|
|
|
|
|
entry.SetDouble("coeff_inpsRate", decimal.ToDouble(unit.CoeffSnapshot.InpsRate));
|
|
|
|
|
entry.SetDouble("coeff_substituteTaxRate", decimal.ToDouble(unit.CoeffSnapshot.SubstituteTaxRate));
|
|
|
|
|
entry.SetString("createdAtUtc", unit.CreatedAtUtc.ToString("O"));
|
|
|
|
|
entry.SetString("updatedAtUtc", unit.UpdatedAtUtc.ToString("O"));
|
|
|
|
|
workUnits.AddDictionary(entry);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var calendarEvents = new MutableArrayObject();
|
|
|
|
|
foreach (var calendarEvent in day.CalendarEvents)
|
|
|
|
|
{
|
|
|
|
|
var entry = new MutableDictionaryObject();
|
|
|
|
|
entry.SetString("id", calendarEvent.Id);
|
2026-04-22 11:07:30 +02:00
|
|
|
entry.SetString("startDate", (calendarEvent.StartDate == default ? day.Date : calendarEvent.StartDate).ToString("yyyy-MM-dd"));
|
2026-04-20 16:11:27 +02:00
|
|
|
entry.SetInt("eventType", (int)calendarEvent.EventType);
|
|
|
|
|
entry.SetString("description", calendarEvent.Description);
|
|
|
|
|
entry.SetString("startTime", calendarEvent.StartTime?.ToString("HH:mm"));
|
|
|
|
|
entry.SetString("endTime", calendarEvent.EndTime?.ToString("HH:mm"));
|
2026-04-22 11:07:30 +02:00
|
|
|
if (calendarEvent.EndDate.HasValue)
|
|
|
|
|
{
|
|
|
|
|
entry.SetString("endDate", calendarEvent.EndDate.Value.ToString("yyyy-MM-dd"));
|
|
|
|
|
}
|
2026-04-20 16:11:27 +02:00
|
|
|
if (calendarEvent.DurationHours.HasValue)
|
|
|
|
|
{
|
|
|
|
|
entry.SetDouble("durationHours", decimal.ToDouble(calendarEvent.DurationHours.Value));
|
|
|
|
|
}
|
|
|
|
|
entry.SetString("createdAtUtc", calendarEvent.CreatedAtUtc.ToString("O"));
|
|
|
|
|
entry.SetString("updatedAtUtc", calendarEvent.UpdatedAtUtc.ToString("O"));
|
|
|
|
|
calendarEvents.AddDictionary(entry);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
doc.SetArray("workUnits", workUnits);
|
|
|
|
|
doc.SetArray("calendarEvents", calendarEvents);
|
2026-03-17 22:10:19 +01:00
|
|
|
|
|
|
|
|
doc.SetString("createdAtUtc", day.CreatedAtUtc.ToString("O"));
|
|
|
|
|
doc.SetString("updatedAtUtc", day.UpdatedAtUtc.ToString("O"));
|
|
|
|
|
|
|
|
|
|
workDaysCollection.Save(doc);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 16:11:27 +02:00
|
|
|
private bool DeleteOrSaveDay(WorkDayDocument day)
|
|
|
|
|
{
|
|
|
|
|
if (day.WorkUnits.Count == 0 && day.CalendarEvents.Count == 0)
|
|
|
|
|
{
|
|
|
|
|
var existing = workDaysCollection.GetDocument(day.Id);
|
|
|
|
|
if (existing is null)
|
|
|
|
|
{
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
workDaysCollection.Delete(existing);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
day.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
|
|
|
|
SortEntries(day);
|
|
|
|
|
SaveDocument(day);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-17 22:10:19 +01:00
|
|
|
private static WorkDayDocument Map(Document doc)
|
|
|
|
|
{
|
2026-04-22 11:07:30 +02:00
|
|
|
var date = DateOnly.ParseExact(doc.GetString("date") ?? doc.Id, "yyyy-MM-dd");
|
|
|
|
|
|
2026-04-20 16:11:27 +02:00
|
|
|
if (!doc.Contains("workUnits") && !doc.Contains("calendarEvents"))
|
|
|
|
|
{
|
|
|
|
|
return MapLegacy(doc);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var workUnits = new List<WorkUnitDocument>();
|
|
|
|
|
var workUnitsArray = doc.GetArray("workUnits");
|
|
|
|
|
if (workUnitsArray is not null)
|
|
|
|
|
{
|
|
|
|
|
for (var i = 0; i < workUnitsArray.Count; i++)
|
|
|
|
|
{
|
|
|
|
|
var unit = workUnitsArray.GetDictionary(i);
|
|
|
|
|
if (unit is not null)
|
|
|
|
|
{
|
|
|
|
|
workUnits.Add(MapWorkUnit(unit));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var calendarEvents = new List<CalendarEventDocument>();
|
|
|
|
|
var calendarEventsArray = doc.GetArray("calendarEvents");
|
|
|
|
|
if (calendarEventsArray is not null)
|
|
|
|
|
{
|
|
|
|
|
for (var i = 0; i < calendarEventsArray.Count; i++)
|
|
|
|
|
{
|
|
|
|
|
var calendarEvent = calendarEventsArray.GetDictionary(i);
|
|
|
|
|
if (calendarEvent is not null)
|
|
|
|
|
{
|
2026-04-22 11:07:30 +02:00
|
|
|
calendarEvents.Add(MapCalendarEvent(calendarEvent, date));
|
2026-04-20 16:11:27 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-17 22:10:19 +01:00
|
|
|
return new WorkDayDocument
|
|
|
|
|
{
|
|
|
|
|
Id = doc.Id,
|
2026-04-22 11:07:30 +02:00
|
|
|
Date = date,
|
2026-03-17 22:10:19 +01:00
|
|
|
IsWeekend = doc.GetBoolean("isWeekend"),
|
|
|
|
|
IsItalianFestivity = doc.GetBoolean("isItalianFestivity"),
|
2026-04-20 16:11:27 +02:00
|
|
|
WorkUnits = workUnits,
|
|
|
|
|
CalendarEvents = calendarEvents,
|
|
|
|
|
CreatedAtUtc = ReadDateTimeOffset(doc, "createdAtUtc"),
|
|
|
|
|
UpdatedAtUtc = ReadDateTimeOffset(doc, "updatedAtUtc")
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task<WorkDayDocument> GetOrCreateDayAsync(DateOnly date, CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
var existing = await GetAsync(date, cancellationToken);
|
|
|
|
|
if (existing is not null)
|
|
|
|
|
{
|
|
|
|
|
existing.IsWeekend = date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday;
|
|
|
|
|
existing.IsItalianFestivity = festivitySource.GetFestivities(date.Year).Contains(date);
|
|
|
|
|
existing.Id = date.ToString("yyyy-MM-dd");
|
|
|
|
|
existing.Date = date;
|
|
|
|
|
return existing;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return new WorkDayDocument
|
|
|
|
|
{
|
|
|
|
|
Id = date.ToString("yyyy-MM-dd"),
|
|
|
|
|
Date = date,
|
|
|
|
|
IsWeekend = date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday,
|
|
|
|
|
IsItalianFestivity = festivitySource.GetFestivities(date.Year).Contains(date),
|
|
|
|
|
CreatedAtUtc = DateTimeOffset.UtcNow,
|
|
|
|
|
UpdatedAtUtc = DateTimeOffset.UtcNow
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static void Compute(WorkUnitDocument unit)
|
|
|
|
|
{
|
|
|
|
|
unit.CalculatedWorkedHours = CalculateDuration(unit.StartTime, unit.EndTime) ?? 0m;
|
|
|
|
|
unit.WorkedHoursDelta = unit.ManualWorkedHours - unit.CalculatedWorkedHours;
|
|
|
|
|
|
|
|
|
|
var coeff = unit.CoeffSnapshot;
|
|
|
|
|
unit.GrossIncome = unit.ManualWorkedHours * coeff.HourlyGrossRate;
|
|
|
|
|
var taxableBase = unit.GrossIncome * coeff.ProfitabilityCoefficient;
|
|
|
|
|
unit.NetIncome = unit.GrossIncome - (taxableBase * coeff.InpsRate) - (taxableBase * coeff.SubstituteTaxRate);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static void Compute(CalendarEventDocument calendarEvent)
|
|
|
|
|
{
|
|
|
|
|
calendarEvent.DurationHours = CalculateDuration(calendarEvent.StartTime, calendarEvent.EndTime);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static decimal? CalculateDuration(TimeOnly? startTime, TimeOnly? endTime)
|
|
|
|
|
{
|
|
|
|
|
if (!startTime.HasValue || !endTime.HasValue || endTime <= startTime)
|
|
|
|
|
{
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Math.Round((decimal)(endTime.Value - startTime.Value).TotalHours, 2, MidpointRounding.AwayFromZero);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static int CountDaysWithEvent(IEnumerable<WorkDayDocument> days, CalendarEventType eventType)
|
|
|
|
|
{
|
|
|
|
|
return days.Count(day => day.CalendarEvents.Any(calendarEvent => calendarEvent.EventType == eventType));
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 23:56:23 +02:00
|
|
|
private async Task<MonthlySummaryModel> BuildMonthlySummaryAsync(DateOnly from, bool includePreview, CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
var to = from.AddMonths(1).AddDays(-1);
|
|
|
|
|
var days = await GetRangeAsync(from, to, cancellationToken);
|
|
|
|
|
|
|
|
|
|
var includedUnits = days
|
|
|
|
|
.SelectMany(day => day.WorkUnits.Where(unit => includePreview || !unit.IsPreview).Select(unit => new { day.Date, Unit = unit }))
|
|
|
|
|
.ToList();
|
|
|
|
|
|
|
|
|
|
var previewUnits = days
|
|
|
|
|
.SelectMany(day => day.WorkUnits.Where(unit => unit.IsPreview).Select(unit => new { day.Date, Unit = unit }))
|
|
|
|
|
.ToList();
|
|
|
|
|
|
|
|
|
|
return new MonthlySummaryModel
|
|
|
|
|
{
|
|
|
|
|
Year = from.Year,
|
|
|
|
|
Month = from.Month,
|
|
|
|
|
TotalWorkedHours = includedUnits.Sum(item => item.Unit.ManualWorkedHours),
|
|
|
|
|
TotalPreviewWorkedHours = previewUnits.Sum(item => item.Unit.ManualWorkedHours),
|
|
|
|
|
CountedWorkUnits = includedUnits.Count,
|
|
|
|
|
PreviewWorkUnits = previewUnits.Count,
|
|
|
|
|
OfficeDays = includedUnits.Where(item => item.Unit.Location == WorkUnitLocation.Office).Select(item => item.Date).Distinct().Count(),
|
|
|
|
|
HomeDays = includedUnits.Where(item => item.Unit.Location == WorkUnitLocation.Home).Select(item => item.Date).Distinct().Count(),
|
|
|
|
|
HolidayDays = CountDaysWithEvent(days, CalendarEventType.Holiday),
|
|
|
|
|
SickDays = CountDaysWithEvent(days, CalendarEventType.Illness),
|
|
|
|
|
DaysOff = CountDaysWithEvent(days, CalendarEventType.DayOff),
|
|
|
|
|
ClosureDays = CountDaysWithEvent(days, CalendarEventType.Closure),
|
|
|
|
|
TotalHoursOff = days.Sum(day => GetHoursOff(day, includePreview)),
|
|
|
|
|
TotalGrossIncome = includedUnits.Sum(item => item.Unit.GrossIncome),
|
|
|
|
|
TotalNetIncome = includedUnits.Sum(item => item.Unit.NetIncome),
|
|
|
|
|
TotalWorkingDays = includedUnits.Select(item => item.Date).Distinct().Count()
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 17:23:54 +02:00
|
|
|
private static MonthlyTimesheetDaySummary CreateTimesheetDaySummary(WorkDayDocument? day, DateOnly date, bool includePreview, decimal defaultStandardHours)
|
|
|
|
|
{
|
|
|
|
|
var includedUnits = day?.WorkUnits.Where(unit => includePreview || !unit.IsPreview).ToList() ?? [];
|
|
|
|
|
var totalHours = includedUnits.Sum(unit => unit.ManualWorkedHours);
|
|
|
|
|
var explicitHoliday = HasEventType(day, CalendarEventType.Holiday);
|
|
|
|
|
var illness = HasEventType(day, CalendarEventType.Illness);
|
|
|
|
|
var dayOff = HasEventType(day, CalendarEventType.DayOff);
|
|
|
|
|
var closure = HasEventType(day, CalendarEventType.Closure);
|
|
|
|
|
var isWeekend = date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday;
|
|
|
|
|
var isAutomaticHoliday = day?.IsItalianFestivity ?? false;
|
|
|
|
|
var standardHours = includedUnits.FirstOrDefault()?.CoeffSnapshot.StandardWorkHoursPerDay ?? defaultStandardHours;
|
|
|
|
|
var nightHours = includedUnits.Sum(GetNightHours);
|
|
|
|
|
var weekdayDaytimeHours = isWeekend ? 0m : Math.Max(0m, totalHours - nightHours);
|
|
|
|
|
var suppressVacation = isWeekend || explicitHoliday || isAutomaticHoliday || illness;
|
|
|
|
|
var hasNonWorkingEvent = explicitHoliday || illness || dayOff || closure;
|
2026-04-23 00:11:00 +02:00
|
|
|
var isFutureEmptyDay = date > DateOnly.FromDateTime(DateTime.Today) && includedUnits.Count == 0;
|
|
|
|
|
var permitHours = !isWeekend && !isAutomaticHoliday && !hasNonWorkingEvent && !isFutureEmptyDay && totalHours < standardHours
|
2026-04-20 17:23:54 +02:00
|
|
|
? standardHours - totalHours
|
|
|
|
|
: 0m;
|
|
|
|
|
|
|
|
|
|
return new MonthlyTimesheetDaySummary
|
|
|
|
|
{
|
|
|
|
|
Date = date,
|
|
|
|
|
OfficeHours = includedUnits.Where(unit => unit.Location == WorkUnitLocation.Office).Sum(unit => unit.ManualWorkedHours),
|
|
|
|
|
HomeHours = includedUnits.Where(unit => unit.Location == WorkUnitLocation.Home).Sum(unit => unit.ManualWorkedHours),
|
|
|
|
|
OvertimeHours = Math.Max(0m, weekdayDaytimeHours - standardHours),
|
|
|
|
|
WeekendHours = isWeekend ? totalHours : 0m,
|
|
|
|
|
NightHours = nightHours,
|
|
|
|
|
VacationDays = (dayOff || closure) && !suppressVacation ? 1m : 0m,
|
|
|
|
|
PermitHours = Math.Max(0m, permitHours),
|
|
|
|
|
CompensatoryRestDays = 0m,
|
|
|
|
|
SickDays = illness ? 1m : 0m,
|
|
|
|
|
HolidayDays = explicitHoliday && !isWeekend ? 1m : 0m
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static MonthlyTimesheetRowModel CreateTimesheetRow(
|
|
|
|
|
string key,
|
|
|
|
|
string label,
|
|
|
|
|
MonthlyTimesheetValueFormat valueFormat,
|
|
|
|
|
IEnumerable<decimal> values,
|
|
|
|
|
bool includeZeroTotal = true)
|
|
|
|
|
{
|
|
|
|
|
var dailyValues = values
|
|
|
|
|
.Select(value => value > 0m ? value : (decimal?)null)
|
|
|
|
|
.ToList();
|
|
|
|
|
|
|
|
|
|
var total = dailyValues.Where(value => value.HasValue).Sum(value => value ?? 0m);
|
|
|
|
|
|
|
|
|
|
return new MonthlyTimesheetRowModel
|
|
|
|
|
{
|
|
|
|
|
Key = key,
|
|
|
|
|
Label = label,
|
|
|
|
|
ValueFormat = valueFormat,
|
|
|
|
|
DailyValues = dailyValues,
|
|
|
|
|
Total = includeZeroTotal || total > 0m ? total : null
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 16:11:27 +02:00
|
|
|
private static decimal GetHoursOff(WorkDayDocument day, bool includePreview)
|
|
|
|
|
{
|
|
|
|
|
var includedUnits = day.WorkUnits.Where(unit => includePreview || !unit.IsPreview).ToList();
|
|
|
|
|
if (includedUnits.Count == 0)
|
|
|
|
|
{
|
|
|
|
|
return 0m;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var standardHours = includedUnits[0].CoeffSnapshot.StandardWorkHoursPerDay;
|
|
|
|
|
var countedHours = includedUnits.Sum(unit => unit.ManualWorkedHours);
|
|
|
|
|
return Math.Max(0m, standardHours - countedHours);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static bool IsNonWorkingEvent(CalendarEventType eventType)
|
|
|
|
|
{
|
|
|
|
|
return eventType is CalendarEventType.DayOff or CalendarEventType.Closure or CalendarEventType.Holiday or CalendarEventType.Illness;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 17:23:54 +02:00
|
|
|
private static bool HasEventType(WorkDayDocument? day, CalendarEventType eventType)
|
|
|
|
|
{
|
|
|
|
|
return day?.CalendarEvents.Any(calendarEvent => calendarEvent.EventType == eventType) == true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static string FormatTimesheetWorkUnitSummary(WorkUnitDocument unit)
|
|
|
|
|
{
|
|
|
|
|
var prefix = unit.Location == WorkUnitLocation.Home ? "SW" : "Pres";
|
|
|
|
|
var hours = FormatCompactHours(unit.ManualWorkedHours);
|
|
|
|
|
if (unit.StartTime.HasValue && unit.EndTime.HasValue)
|
|
|
|
|
{
|
|
|
|
|
return $"{prefix}: {unit.Label} ({unit.StartTime:HH:mm}-{unit.EndTime:HH:mm}, {hours}h{(unit.IsPreview ? ", preview" : string.Empty)})";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $"{prefix}: {unit.Label} ({hours}h{(unit.IsPreview ? ", preview" : string.Empty)})";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static string FormatTimesheetEventSummary(CalendarEventDocument calendarEvent)
|
|
|
|
|
{
|
2026-04-22 11:07:30 +02:00
|
|
|
var description = CalendarEventFormatter.GetDisplayDescription(calendarEvent);
|
2026-04-20 17:23:54 +02:00
|
|
|
if (calendarEvent.StartTime.HasValue)
|
|
|
|
|
{
|
2026-04-22 11:07:30 +02:00
|
|
|
return $"{CalendarEventFormatter.GetEventTypeName(calendarEvent.EventType)}: {description} ({calendarEvent.StartTime:HH:mm})";
|
2026-04-20 17:23:54 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-22 11:07:30 +02:00
|
|
|
return $"{CalendarEventFormatter.GetEventTypeName(calendarEvent.EventType)}: {description}";
|
2026-04-20 17:23:54 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static string FormatCompactHours(decimal value)
|
|
|
|
|
{
|
2026-04-20 23:56:23 +02:00
|
|
|
return Formatting.DurationFormatter.FormatHours(value);
|
2026-04-20 17:23:54 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static decimal GetNightHours(WorkUnitDocument unit)
|
|
|
|
|
{
|
|
|
|
|
if (!unit.StartTime.HasValue || !unit.EndTime.HasValue || unit.EndTime <= unit.StartTime)
|
|
|
|
|
{
|
|
|
|
|
return 0m;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return GetOverlapHours(unit.StartTime.Value, unit.EndTime.Value, new TimeOnly(0, 0), new TimeOnly(6, 0))
|
|
|
|
|
+ GetOverlapHours(unit.StartTime.Value, unit.EndTime.Value, new TimeOnly(22, 0), new TimeOnly(23, 59, 59));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static decimal GetOverlapHours(TimeOnly rangeStart, TimeOnly rangeEnd, TimeOnly windowStart, TimeOnly windowEnd)
|
|
|
|
|
{
|
|
|
|
|
var overlapStart = rangeStart > windowStart ? rangeStart : windowStart;
|
|
|
|
|
var overlapEnd = rangeEnd < windowEnd ? rangeEnd : windowEnd;
|
|
|
|
|
if (overlapEnd <= overlapStart)
|
|
|
|
|
{
|
|
|
|
|
return 0m;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Math.Round((decimal)(overlapEnd - overlapStart).TotalHours, 2, MidpointRounding.AwayFromZero);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 16:11:27 +02:00
|
|
|
private static WorkUnitDocument CreatePreviewWorkUnit(string label, TimeOnly startTime, TimeOnly endTime, AppSettingsDocument settings)
|
|
|
|
|
{
|
|
|
|
|
var workUnit = new WorkUnitDocument
|
|
|
|
|
{
|
|
|
|
|
Id = Guid.NewGuid().ToString("N"),
|
|
|
|
|
Label = label,
|
|
|
|
|
Location = WorkUnitLocation.Office,
|
|
|
|
|
StartTime = startTime,
|
|
|
|
|
EndTime = endTime,
|
|
|
|
|
IsPreview = true,
|
|
|
|
|
ManualWorkedHours = Math.Round((decimal)(endTime - startTime).TotalHours, 2, MidpointRounding.AwayFromZero),
|
2026-03-17 22:10:19 +01:00
|
|
|
CoeffSnapshot = new CoeffSnapshotDocument
|
|
|
|
|
{
|
2026-04-20 16:11:27 +02:00
|
|
|
StandardWorkHoursPerDay = settings.StandardWorkHoursPerDay,
|
|
|
|
|
HourlyGrossRate = settings.HourlyGrossRate,
|
|
|
|
|
ProfitabilityCoefficient = settings.ProfitabilityCoefficient,
|
|
|
|
|
InpsRate = settings.InpsRate,
|
|
|
|
|
SubstituteTaxRate = settings.SubstituteTaxRate
|
2026-03-17 22:10:19 +01:00
|
|
|
},
|
2026-04-20 16:11:27 +02:00
|
|
|
CreatedAtUtc = DateTimeOffset.UtcNow,
|
|
|
|
|
UpdatedAtUtc = DateTimeOffset.UtcNow
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
Compute(workUnit);
|
|
|
|
|
return workUnit;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static void SortEntries(WorkDayDocument day)
|
|
|
|
|
{
|
|
|
|
|
day.WorkUnits = day.WorkUnits
|
|
|
|
|
.OrderBy(unit => unit.StartTime ?? TimeOnly.MaxValue)
|
|
|
|
|
.ThenBy(unit => unit.Label, StringComparer.CurrentCultureIgnoreCase)
|
|
|
|
|
.ToList();
|
|
|
|
|
|
|
|
|
|
day.CalendarEvents = day.CalendarEvents
|
|
|
|
|
.OrderBy(calendarEvent => calendarEvent.StartTime ?? TimeOnly.MaxValue)
|
|
|
|
|
.ThenBy(calendarEvent => calendarEvent.Description, StringComparer.CurrentCultureIgnoreCase)
|
|
|
|
|
.ToList();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static WorkUnitDocument MapWorkUnit(DictionaryObject unit)
|
|
|
|
|
{
|
|
|
|
|
var workUnit = new WorkUnitDocument
|
|
|
|
|
{
|
|
|
|
|
Id = unit.GetString("id") ?? Guid.NewGuid().ToString("N"),
|
|
|
|
|
Label = unit.GetString("label") ?? "Work unit",
|
|
|
|
|
Location = unit.Contains("location") ? (WorkUnitLocation)unit.GetInt("location") : WorkUnitLocation.Office,
|
|
|
|
|
StartTime = ReadTimeOnly(unit, "startTime"),
|
|
|
|
|
EndTime = ReadTimeOnly(unit, "endTime"),
|
|
|
|
|
IsPreview = unit.GetBoolean("isPreview"),
|
|
|
|
|
ManualWorkedHours = ReadDecimal(unit, "manualWorkedHours", 0m),
|
|
|
|
|
CalculatedWorkedHours = ReadDecimal(unit, "calculatedWorkedHours", 0m),
|
|
|
|
|
WorkedHoursDelta = ReadDecimal(unit, "workedHoursDelta", 0m),
|
|
|
|
|
GrossIncome = ReadDecimal(unit, "grossIncome", 0m),
|
|
|
|
|
NetIncome = ReadDecimal(unit, "netIncome", 0m),
|
|
|
|
|
Notes = unit.GetString("notes"),
|
|
|
|
|
CoeffSnapshot = new CoeffSnapshotDocument
|
|
|
|
|
{
|
|
|
|
|
StandardWorkHoursPerDay = ReadDecimal(unit, "coeff_standardWorkHoursPerDay", 8m),
|
|
|
|
|
HourlyGrossRate = ReadDecimal(unit, "coeff_hourlyGrossRate", 17.5m),
|
|
|
|
|
ProfitabilityCoefficient = ReadDecimal(unit, "coeff_profitabilityCoefficient", 0.67m),
|
|
|
|
|
InpsRate = ReadDecimal(unit, "coeff_inpsRate", 0.2607m),
|
|
|
|
|
SubstituteTaxRate = ReadDecimal(unit, "coeff_substituteTaxRate", 0.15m)
|
|
|
|
|
},
|
|
|
|
|
CreatedAtUtc = ReadDateTimeOffset(unit, "createdAtUtc"),
|
|
|
|
|
UpdatedAtUtc = ReadDateTimeOffset(unit, "updatedAtUtc")
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
Compute(workUnit);
|
|
|
|
|
return workUnit;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 11:07:30 +02:00
|
|
|
private static CalendarEventDocument MapCalendarEvent(DictionaryObject calendarEvent, DateOnly owningDate)
|
2026-04-20 16:11:27 +02:00
|
|
|
{
|
|
|
|
|
var entry = new CalendarEventDocument
|
|
|
|
|
{
|
|
|
|
|
Id = calendarEvent.GetString("id") ?? Guid.NewGuid().ToString("N"),
|
2026-04-22 11:07:30 +02:00
|
|
|
StartDate = ReadDateOnly(calendarEvent, "startDate") ?? owningDate,
|
|
|
|
|
EndDate = ReadDateOnly(calendarEvent, "endDate"),
|
2026-04-20 16:11:27 +02:00
|
|
|
EventType = calendarEvent.Contains("eventType") ? (CalendarEventType)calendarEvent.GetInt("eventType") : CalendarEventType.Generic,
|
2026-04-22 11:07:30 +02:00
|
|
|
Description = calendarEvent.GetString("description") ?? string.Empty,
|
2026-04-20 16:11:27 +02:00
|
|
|
StartTime = ReadTimeOnly(calendarEvent, "startTime"),
|
|
|
|
|
EndTime = ReadTimeOnly(calendarEvent, "endTime"),
|
|
|
|
|
DurationHours = calendarEvent.Contains("durationHours") ? ReadDecimal(calendarEvent, "durationHours", 0m) : null,
|
|
|
|
|
CreatedAtUtc = ReadDateTimeOffset(calendarEvent, "createdAtUtc"),
|
|
|
|
|
UpdatedAtUtc = ReadDateTimeOffset(calendarEvent, "updatedAtUtc")
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
Compute(entry);
|
|
|
|
|
return entry;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static WorkDayDocument MapLegacy(Document doc)
|
|
|
|
|
{
|
|
|
|
|
var date = DateOnly.ParseExact(doc.GetString("date") ?? doc.Id, "yyyy-MM-dd");
|
|
|
|
|
var dayType = doc.Contains("dayType") ? (DayType)doc.GetInt("dayType") : DayType.None;
|
|
|
|
|
var day = new WorkDayDocument
|
|
|
|
|
{
|
|
|
|
|
Id = doc.Id,
|
|
|
|
|
Date = date,
|
|
|
|
|
IsWeekend = doc.GetBoolean("isWeekend"),
|
|
|
|
|
IsItalianFestivity = doc.GetBoolean("isItalianFestivity"),
|
2026-03-17 22:10:19 +01:00
|
|
|
CreatedAtUtc = ReadDateTimeOffset(doc, "createdAtUtc"),
|
|
|
|
|
UpdatedAtUtc = ReadDateTimeOffset(doc, "updatedAtUtc")
|
|
|
|
|
};
|
2026-04-20 16:11:27 +02:00
|
|
|
|
|
|
|
|
var coeffSnapshot = new CoeffSnapshotDocument
|
|
|
|
|
{
|
|
|
|
|
StandardWorkHoursPerDay = ReadDecimal(doc, "coeff_standardWorkHoursPerDay", 8m),
|
|
|
|
|
HourlyGrossRate = ReadDecimal(doc, "coeff_hourlyGrossRate", 17.5m),
|
|
|
|
|
ProfitabilityCoefficient = ReadDecimal(doc, "coeff_profitabilityCoefficient", 0.67m),
|
|
|
|
|
InpsRate = ReadDecimal(doc, "coeff_inpsRate", 0.2607m),
|
|
|
|
|
SubstituteTaxRate = ReadDecimal(doc, "coeff_substituteTaxRate", 0.15m)
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (dayType is DayType.Work or DayType.Home)
|
|
|
|
|
{
|
|
|
|
|
var workUnit = new WorkUnitDocument
|
|
|
|
|
{
|
|
|
|
|
Id = "legacy",
|
|
|
|
|
Label = "Legacy entry",
|
|
|
|
|
Location = dayType == DayType.Home ? WorkUnitLocation.Home : WorkUnitLocation.Office,
|
|
|
|
|
StartTime = ReadTimeOnly(doc, "startTime"),
|
|
|
|
|
EndTime = ReadTimeOnly(doc, "actualExitTime") ?? ReadTimeOnly(doc, "projectedExitTime"),
|
|
|
|
|
IsPreview = false,
|
|
|
|
|
ManualWorkedHours = ReadDecimal(doc, "workedHoursFinal", ReadDecimal(doc, "workedHoursBase", 0m)),
|
|
|
|
|
GrossIncome = ReadDecimal(doc, "grossIncome", 0m),
|
|
|
|
|
NetIncome = ReadDecimal(doc, "netIncome", 0m),
|
|
|
|
|
Notes = doc.GetString("notes"),
|
|
|
|
|
CoeffSnapshot = coeffSnapshot,
|
|
|
|
|
CreatedAtUtc = day.CreatedAtUtc,
|
|
|
|
|
UpdatedAtUtc = day.UpdatedAtUtc
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
Compute(workUnit);
|
|
|
|
|
day.WorkUnits.Add(workUnit);
|
|
|
|
|
}
|
|
|
|
|
else if (dayType != DayType.None)
|
|
|
|
|
{
|
|
|
|
|
var calendarEvent = new CalendarEventDocument
|
|
|
|
|
{
|
|
|
|
|
Id = "legacy",
|
2026-04-22 11:07:30 +02:00
|
|
|
StartDate = date,
|
2026-04-20 16:11:27 +02:00
|
|
|
EventType = MapLegacyEventType(dayType),
|
|
|
|
|
Description = string.IsNullOrWhiteSpace(doc.GetString("notes")) ? $"Legacy {dayType}" : doc.GetString("notes")!,
|
|
|
|
|
CreatedAtUtc = day.CreatedAtUtc,
|
|
|
|
|
UpdatedAtUtc = day.UpdatedAtUtc
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
Compute(calendarEvent);
|
|
|
|
|
day.CalendarEvents.Add(calendarEvent);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return day;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static CalendarEventType MapLegacyEventType(DayType dayType)
|
|
|
|
|
{
|
|
|
|
|
return dayType switch
|
|
|
|
|
{
|
|
|
|
|
DayType.DayOff => CalendarEventType.DayOff,
|
|
|
|
|
DayType.Closure => CalendarEventType.Closure,
|
|
|
|
|
DayType.Holiday => CalendarEventType.Holiday,
|
|
|
|
|
DayType.Illness => CalendarEventType.Illness,
|
|
|
|
|
_ => CalendarEventType.Generic
|
|
|
|
|
};
|
2026-03-17 22:10:19 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static TimeOnly? ReadTimeOnly(Document doc, string key)
|
|
|
|
|
{
|
|
|
|
|
var value = doc.GetString(key);
|
|
|
|
|
return !string.IsNullOrEmpty(value) && TimeOnly.TryParseExact(value, "HH:mm", out var time)
|
|
|
|
|
? time
|
|
|
|
|
: null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 16:11:27 +02:00
|
|
|
private static TimeOnly? ReadTimeOnly(DictionaryObject doc, string key)
|
|
|
|
|
{
|
|
|
|
|
var value = doc.GetString(key);
|
|
|
|
|
return !string.IsNullOrEmpty(value) && TimeOnly.TryParseExact(value, "HH:mm", out var time)
|
|
|
|
|
? time
|
|
|
|
|
: null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 11:07:30 +02:00
|
|
|
private static DateOnly? ReadDateOnly(DictionaryObject doc, string key)
|
|
|
|
|
{
|
|
|
|
|
var value = doc.GetString(key);
|
|
|
|
|
return !string.IsNullOrEmpty(value) && DateOnly.TryParseExact(value, "yyyy-MM-dd", out var date)
|
|
|
|
|
? date
|
|
|
|
|
: null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-17 22:10:19 +01:00
|
|
|
private static decimal ReadDecimal(Document doc, string key, decimal defaultValue)
|
|
|
|
|
{
|
|
|
|
|
return doc.Contains(key)
|
|
|
|
|
? Convert.ToDecimal(doc.GetDouble(key))
|
|
|
|
|
: defaultValue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 16:11:27 +02:00
|
|
|
private static decimal ReadDecimal(DictionaryObject doc, string key, decimal defaultValue)
|
|
|
|
|
{
|
|
|
|
|
return doc.Contains(key)
|
|
|
|
|
? Convert.ToDecimal(doc.GetDouble(key))
|
|
|
|
|
: defaultValue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 11:07:30 +02:00
|
|
|
private IReadOnlyList<WorkDayDocument> GetAllDays(CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
var query = QueryBuilder
|
|
|
|
|
.Select(SelectResult.Expression(Meta.ID))
|
|
|
|
|
.From(DataSource.Collection(workDaysCollection))
|
|
|
|
|
.OrderBy(Ordering.Expression(Meta.ID));
|
|
|
|
|
|
|
|
|
|
var results = new List<WorkDayDocument>();
|
|
|
|
|
foreach (var result in query.Execute())
|
|
|
|
|
{
|
|
|
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
|
|
|
|
|
|
var id = result.GetString(0);
|
|
|
|
|
if (string.IsNullOrWhiteSpace(id))
|
|
|
|
|
{
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var doc = workDaysCollection.GetDocument(id);
|
|
|
|
|
if (doc is not null)
|
|
|
|
|
{
|
|
|
|
|
results.Add(Map(doc));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return results;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private WorkDayDocument CloneDayForRange(DateOnly date, WorkDayDocument? sourceDay = null)
|
|
|
|
|
{
|
|
|
|
|
return new WorkDayDocument
|
|
|
|
|
{
|
|
|
|
|
Id = date.ToString("yyyy-MM-dd"),
|
|
|
|
|
Date = date,
|
|
|
|
|
IsWeekend = date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday,
|
|
|
|
|
IsItalianFestivity = festivitySource.GetFestivities(date.Year).Contains(date),
|
|
|
|
|
WorkUnits = sourceDay?.WorkUnits.Select(CloneWorkUnit).ToList() ?? [],
|
|
|
|
|
CalendarEvents = [],
|
|
|
|
|
CreatedAtUtc = sourceDay?.CreatedAtUtc ?? DateTimeOffset.UtcNow,
|
|
|
|
|
UpdatedAtUtc = sourceDay?.UpdatedAtUtc ?? DateTimeOffset.UtcNow
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static WorkUnitDocument CloneWorkUnit(WorkUnitDocument workUnit)
|
|
|
|
|
{
|
|
|
|
|
return new WorkUnitDocument
|
|
|
|
|
{
|
|
|
|
|
Id = workUnit.Id,
|
|
|
|
|
Label = workUnit.Label,
|
|
|
|
|
Location = workUnit.Location,
|
|
|
|
|
StartTime = workUnit.StartTime,
|
|
|
|
|
EndTime = workUnit.EndTime,
|
|
|
|
|
IsPreview = workUnit.IsPreview,
|
|
|
|
|
ManualWorkedHours = workUnit.ManualWorkedHours,
|
|
|
|
|
CalculatedWorkedHours = workUnit.CalculatedWorkedHours,
|
|
|
|
|
WorkedHoursDelta = workUnit.WorkedHoursDelta,
|
|
|
|
|
GrossIncome = workUnit.GrossIncome,
|
|
|
|
|
NetIncome = workUnit.NetIncome,
|
|
|
|
|
Notes = workUnit.Notes,
|
|
|
|
|
CoeffSnapshot = new CoeffSnapshotDocument
|
|
|
|
|
{
|
|
|
|
|
StandardWorkHoursPerDay = workUnit.CoeffSnapshot.StandardWorkHoursPerDay,
|
|
|
|
|
HourlyGrossRate = workUnit.CoeffSnapshot.HourlyGrossRate,
|
|
|
|
|
ProfitabilityCoefficient = workUnit.CoeffSnapshot.ProfitabilityCoefficient,
|
|
|
|
|
InpsRate = workUnit.CoeffSnapshot.InpsRate,
|
|
|
|
|
SubstituteTaxRate = workUnit.CoeffSnapshot.SubstituteTaxRate
|
|
|
|
|
},
|
|
|
|
|
CreatedAtUtc = workUnit.CreatedAtUtc,
|
|
|
|
|
UpdatedAtUtc = workUnit.UpdatedAtUtc
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static CalendarEventDocument CloneCalendarEvent(CalendarEventDocument calendarEvent)
|
|
|
|
|
{
|
|
|
|
|
return new CalendarEventDocument
|
|
|
|
|
{
|
|
|
|
|
Id = calendarEvent.Id,
|
|
|
|
|
StartDate = calendarEvent.StartDate,
|
|
|
|
|
EndDate = calendarEvent.EndDate,
|
|
|
|
|
EventType = calendarEvent.EventType,
|
|
|
|
|
Description = calendarEvent.Description,
|
|
|
|
|
StartTime = calendarEvent.StartTime,
|
|
|
|
|
EndTime = calendarEvent.EndTime,
|
|
|
|
|
DurationHours = calendarEvent.DurationHours,
|
|
|
|
|
CreatedAtUtc = calendarEvent.CreatedAtUtc,
|
|
|
|
|
UpdatedAtUtc = calendarEvent.UpdatedAtUtc
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static void AddProjectedCalendarEvent(WorkDayDocument rangeDay, CalendarEventDocument calendarEvent)
|
|
|
|
|
{
|
|
|
|
|
if (rangeDay.CalendarEvents.Any(existing => string.Equals(existing.Id, calendarEvent.Id, StringComparison.Ordinal)))
|
|
|
|
|
{
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
rangeDay.CalendarEvents.Add(CloneCalendarEvent(calendarEvent));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static void MergeStoredDayIntoRangeDay(WorkDayDocument rangeDay, WorkDayDocument storedDay)
|
|
|
|
|
{
|
|
|
|
|
rangeDay.IsWeekend = storedDay.IsWeekend;
|
|
|
|
|
rangeDay.IsItalianFestivity = storedDay.IsItalianFestivity;
|
|
|
|
|
rangeDay.CreatedAtUtc = storedDay.CreatedAtUtc;
|
|
|
|
|
rangeDay.UpdatedAtUtc = storedDay.UpdatedAtUtc;
|
|
|
|
|
rangeDay.WorkUnits = storedDay.WorkUnits.Select(CloneWorkUnit).ToList();
|
|
|
|
|
|
|
|
|
|
foreach (var calendarEvent in storedDay.CalendarEvents)
|
|
|
|
|
{
|
|
|
|
|
var existingIndex = rangeDay.CalendarEvents.FindIndex(existing => string.Equals(existing.Id, calendarEvent.Id, StringComparison.Ordinal));
|
|
|
|
|
if (existingIndex >= 0)
|
|
|
|
|
{
|
|
|
|
|
rangeDay.CalendarEvents[existingIndex] = CloneCalendarEvent(calendarEvent);
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
rangeDay.CalendarEvents.Add(CloneCalendarEvent(calendarEvent));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private CalendarEventLocation? FindCalendarEventLocation(string calendarEventId, CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
if (string.IsNullOrWhiteSpace(calendarEventId))
|
|
|
|
|
{
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
foreach (var day in GetAllDays(cancellationToken))
|
|
|
|
|
{
|
|
|
|
|
var index = day.CalendarEvents.FindIndex(calendarEvent => string.Equals(calendarEvent.Id, calendarEventId, StringComparison.Ordinal));
|
|
|
|
|
if (index >= 0)
|
|
|
|
|
{
|
|
|
|
|
return new CalendarEventLocation(day, index, day.CalendarEvents[index]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private sealed record CalendarEventLocation(WorkDayDocument Day, int Index, CalendarEventDocument CalendarEvent);
|
|
|
|
|
|
2026-03-17 22:10:19 +01:00
|
|
|
private static DateTimeOffset ReadDateTimeOffset(Document doc, string key)
|
|
|
|
|
{
|
|
|
|
|
var value = doc.GetString(key);
|
|
|
|
|
return !string.IsNullOrEmpty(value) && DateTimeOffset.TryParse(value, out var dt)
|
|
|
|
|
? dt
|
|
|
|
|
: DateTimeOffset.UtcNow;
|
|
|
|
|
}
|
2026-04-20 16:11:27 +02:00
|
|
|
|
|
|
|
|
private static DateTimeOffset ReadDateTimeOffset(DictionaryObject doc, string key)
|
|
|
|
|
{
|
|
|
|
|
var value = doc.GetString(key);
|
|
|
|
|
return !string.IsNullOrEmpty(value) && DateTimeOffset.TryParse(value, out var dt)
|
|
|
|
|
? dt
|
|
|
|
|
: DateTimeOffset.UtcNow;
|
|
|
|
|
}
|
2026-03-17 22:10:19 +01:00
|
|
|
}
|