Skip to content

Commit 805dfe5

Browse files
renemadsenclaude
andcommitted
feat(calendar): persist + expand multi-day weekly recurrence
Layer 2 of the calendar custom-repeat reconstruction feature (spec docs/superpowers/specs/2026-04-30-calendar-edit-mode-meta-reconstruction-design.md). Backend changes: - Bump Microting.EformBackendConfigurationBase 10.0.29 → 10.0.30 to pick up the new RepeatWeekdaysCsv column on AreaRulePlanning + version. - Extend CalendarTaskResponseModel with RepeatEndMode, RepeatOccurrences, RepeatUntilDate, DayOfWeek, DayOfMonth, RepeatWeekdaysCsv. Frontend reconstruction in Layer 3 will derive `kind` from these. - CalendarTaskCreateRequestModel accepts RepeatWeekdaysCsv (update model inherits). - CreateTask + UpdateTask write RepeatWeekdaysCsv unconditionally so switching from a custom multi-day rule to a single-day option clears the stale list. UpdateTask now also writes RepeatEndMode / RepeatOccurrences / RepeatUntilDate unconditionally so 'never' clears a stale 'after N' or 'until <date>' cap. Recurrence iterator: - ParseWeekdaysCsv helper (Sunday-based JS getDay() numbering, sorted + deduped + range-validated). - GetOccurrencesInWeek and EnumerateOccurrences both accept the CSV. Weekly branch fans out to multiple weekdays per matching RepeatEvery cycle when CSV non-empty; falls back to single-day startDate-anchored behaviour when null/empty. - GetTasksForWeek's after-cap counter now uses EnumerateOccurrences (week-loop iterator) instead of the week-scoped GetOccurrencesInWeek which only emits inside one matching week — without the swap, the after-cap never fires for CSV rules and the series repeats forever. Tests (BackendConfiguration.Pn.Integration.Test): - Round-trip multi-day weekly + single-day weekly through the DTO (CreatesAndReadsBackMultiDayWeekly, CreatesAndReadsBackSingleDayWeekly). - Multi-day expansion across week 0/1/2 with RepeatEvery=2 (MultiDayWeeklyExpandsToMultipleOccurrences). - After-cap cutoff with multi-day weekly + RepeatOccurrences=10 across weeks 0-8 (MultiDayWeeklyAfterCapStopsAtTotalOccurrences) — locks the GetTasksForWeek bug fix. - SQL test bootstrap (420_eform-backend-configuration-plugin.sql) updated to add the column to both AreaRulePlannings and the version table so MapVersion's reflection writes succeed. Existing CalendarOccurrenceExceptionTests (8) and CalendarResizeTests (12) regression suites still pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6cf39ef commit 805dfe5

6 files changed

Lines changed: 536 additions & 27 deletions

File tree

Lines changed: 361 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,361 @@
1+
using BackendConfiguration.Pn.Infrastructure.Models.Calendar;
2+
using BackendConfiguration.Pn.Services.BackendConfigurationCalendarService;
3+
using BackendConfiguration.Pn.Services.BackendConfigurationLocalizationService;
4+
using BackendConfiguration.Pn.Services.BackendConfigurationTaskWizardService;
5+
using Microsoft.Extensions.Logging.Abstractions;
6+
using Microting.eForm.Infrastructure.Constants;
7+
using Microting.eForm.Infrastructure.Data.Entities;
8+
using Microting.EformBackendConfigurationBase.Infrastructure.Data.Entities;
9+
using Microting.EformBackendConfigurationBase.Infrastructure.Enum;
10+
using Microting.eFormApi.BasePn.Abstractions;
11+
using Microting.eFormApi.BasePn.Infrastructure.Models.API;
12+
using Microting.ItemsPlanningBase.Infrastructure.Data.Entities;
13+
using Microting.ItemsPlanningBase.Infrastructure.Enums;
14+
using NSubstitute;
15+
16+
namespace BackendConfiguration.Pn.Integration.Test;
17+
18+
[Parallelizable(ParallelScope.Fixtures)]
19+
[TestFixture]
20+
public class CalendarRepeatPersistenceTests : TestBaseSetup
21+
{
22+
private IUserService _userService = null!;
23+
private IBackendConfigurationTaskWizardService _taskWizardService = null!;
24+
private BackendConfigurationCalendarService _calendarService = null!;
25+
26+
[SetUp]
27+
public async Task SetupCalendarService()
28+
{
29+
// Mirror CalendarOccurrenceExceptionTests teardown order — clean up
30+
// FK-safe so each test starts fresh. The base [SetUp] starts the
31+
// Testcontainers MariaDB container before this runs.
32+
BackendConfigurationPnDbContext!.CalendarOccurrenceExceptions.RemoveRange(
33+
BackendConfigurationPnDbContext.CalendarOccurrenceExceptions);
34+
await BackendConfigurationPnDbContext.SaveChangesAsync();
35+
36+
BackendConfigurationPnDbContext.CalendarConfigurations.RemoveRange(
37+
BackendConfigurationPnDbContext.CalendarConfigurations);
38+
await BackendConfigurationPnDbContext.SaveChangesAsync();
39+
40+
BackendConfigurationPnDbContext.AreaRulePlannings.RemoveRange(
41+
BackendConfigurationPnDbContext.AreaRulePlannings);
42+
await BackendConfigurationPnDbContext.SaveChangesAsync();
43+
44+
BackendConfigurationPnDbContext.AreaRules.RemoveRange(
45+
BackendConfigurationPnDbContext.AreaRules);
46+
await BackendConfigurationPnDbContext.SaveChangesAsync();
47+
48+
BackendConfigurationPnDbContext.Areas.RemoveRange(
49+
BackendConfigurationPnDbContext.Areas);
50+
BackendConfigurationPnDbContext.Properties.RemoveRange(
51+
BackendConfigurationPnDbContext.Properties);
52+
await BackendConfigurationPnDbContext.SaveChangesAsync();
53+
54+
ItemsPlanningPnDbContext!.Plannings.RemoveRange(
55+
ItemsPlanningPnDbContext.Plannings);
56+
await ItemsPlanningPnDbContext.SaveChangesAsync();
57+
58+
_userService = Substitute.For<IUserService>();
59+
_userService.UserId.Returns(1);
60+
var mockLanguage = new Language { Id = 1, Name = "English", LanguageCode = "en-US" };
61+
_userService.GetCurrentUserLanguage().Returns(Task.FromResult(mockLanguage));
62+
63+
_taskWizardService = Substitute.For<IBackendConfigurationTaskWizardService>();
64+
_taskWizardService.DeleteTask(Arg.Any<int>())
65+
.Returns(Task.FromResult(new OperationResult(true)));
66+
67+
_calendarService = new BackendConfigurationCalendarService(
68+
new BackendConfigurationLocalizationService(),
69+
_userService,
70+
BackendConfigurationPnDbContext!,
71+
null,
72+
ItemsPlanningPnDbContext!,
73+
_taskWizardService,
74+
NullLogger<BackendConfigurationCalendarService>.Instance
75+
);
76+
}
77+
78+
/// <summary>
79+
/// Returns the next future Monday (UTC), guaranteeing a fully-future
80+
/// startDate so create-task validation does not reject it.
81+
/// </summary>
82+
private static DateTime GetNextMonday()
83+
{
84+
var today = DateTime.UtcNow.Date;
85+
var daysUntilMonday = ((int)DayOfWeek.Monday - (int)today.DayOfWeek + 7) % 7;
86+
if (daysUntilMonday == 0) daysUntilMonday = 7;
87+
return DateTime.SpecifyKind(today.AddDays(daysUntilMonday), DateTimeKind.Utc);
88+
}
89+
90+
private record SeededTask(int ArpId, int PropertyId, DateTime StartDate);
91+
92+
/// <summary>
93+
/// Seeds an AreaRulePlanning + Planning + CalendarConfiguration row with
94+
/// the supplied repeat metadata. Mirrors the persistence shape that
95+
/// <see cref="BackendConfigurationCalendarService.CreateTask"/> ends up
96+
/// writing — but bypasses the TaskWizard dependency so the test focuses
97+
/// purely on the response-mapper round-trip and the iterator behaviour.
98+
/// </summary>
99+
private async Task<SeededTask> SeedTask(
100+
DateTime startDate,
101+
int arpRepeatType,
102+
int arpRepeatEvery,
103+
string? repeatWeekdaysCsv,
104+
int? repeatEndMode = null,
105+
int? repeatOccurrences = null,
106+
DateTime? repeatUntilDate = null,
107+
int? dayOfWeek = null,
108+
int? dayOfMonth = null)
109+
{
110+
var area = new Area
111+
{
112+
Type = AreaTypesEnum.Type1,
113+
ItemPlanningTagId = 0,
114+
WorkflowState = Constants.WorkflowStates.Created,
115+
CreatedByUserId = 1,
116+
UpdatedByUserId = 1
117+
};
118+
await BackendConfigurationPnDbContext!.Areas.AddAsync(area);
119+
await BackendConfigurationPnDbContext.SaveChangesAsync();
120+
121+
var property = new Property
122+
{
123+
Name = $"TestProp-{Guid.NewGuid()}",
124+
ItemPlanningTagId = 0,
125+
WorkflowState = Constants.WorkflowStates.Created,
126+
CreatedByUserId = 1,
127+
UpdatedByUserId = 1
128+
};
129+
await BackendConfigurationPnDbContext.Properties.AddAsync(property);
130+
await BackendConfigurationPnDbContext.SaveChangesAsync();
131+
132+
var areaRule = new AreaRule
133+
{
134+
AreaId = area.Id,
135+
PropertyId = property.Id,
136+
WorkflowState = Constants.WorkflowStates.Created,
137+
CreatedByUserId = 1,
138+
UpdatedByUserId = 1
139+
};
140+
await BackendConfigurationPnDbContext.AreaRules.AddAsync(areaRule);
141+
await BackendConfigurationPnDbContext.SaveChangesAsync();
142+
143+
// Map the ARP repeatType (1=daily, 2=weekly, 3=monthly) onto the
144+
// ItemsPlanning RepeatType enum the iterator switches on.
145+
var planningRepeatType = arpRepeatType switch
146+
{
147+
1 => RepeatType.Day,
148+
2 => RepeatType.Week,
149+
3 => RepeatType.Month,
150+
_ => RepeatType.Week
151+
};
152+
153+
var planning = new Planning
154+
{
155+
Enabled = true,
156+
RepeatEvery = arpRepeatEvery,
157+
RepeatType = planningRepeatType,
158+
StartDate = startDate,
159+
RelatedEFormId = 0,
160+
WorkflowState = Constants.WorkflowStates.Created,
161+
CreatedByUserId = 1,
162+
UpdatedByUserId = 1
163+
};
164+
await ItemsPlanningPnDbContext!.Plannings.AddAsync(planning);
165+
await ItemsPlanningPnDbContext.SaveChangesAsync();
166+
167+
var arp = new AreaRulePlanning
168+
{
169+
AreaRuleId = areaRule.Id,
170+
PropertyId = property.Id,
171+
AreaId = area.Id,
172+
ItemPlanningId = planning.Id,
173+
StartDate = startDate,
174+
Status = true,
175+
RepeatType = arpRepeatType,
176+
RepeatEvery = arpRepeatEvery,
177+
RepeatEndMode = repeatEndMode,
178+
RepeatOccurrences = repeatOccurrences,
179+
RepeatUntilDate = repeatUntilDate,
180+
DayOfWeek = dayOfWeek ?? 0,
181+
DayOfMonth = dayOfMonth ?? 0,
182+
RepeatWeekdaysCsv = repeatWeekdaysCsv,
183+
WorkflowState = Constants.WorkflowStates.Created,
184+
CreatedByUserId = 1,
185+
UpdatedByUserId = 1
186+
};
187+
await BackendConfigurationPnDbContext.AreaRulePlannings.AddAsync(arp);
188+
await BackendConfigurationPnDbContext.SaveChangesAsync();
189+
190+
var calConfig = new CalendarConfiguration
191+
{
192+
AreaRulePlanningId = arp.Id,
193+
StartHour = 9.0,
194+
Duration = 1.0,
195+
WorkflowState = Constants.WorkflowStates.Created,
196+
CreatedByUserId = 1,
197+
UpdatedByUserId = 1
198+
};
199+
await BackendConfigurationPnDbContext.CalendarConfigurations.AddAsync(calConfig);
200+
await BackendConfigurationPnDbContext.SaveChangesAsync();
201+
202+
return new SeededTask(arp.Id, property.Id, startDate);
203+
}
204+
205+
private static string IsoUtc(DateTime d) =>
206+
DateTime.SpecifyKind(d, DateTimeKind.Utc).ToString("yyyy-MM-ddTHH:mm:ssZ");
207+
208+
private async Task<List<CalendarTaskResponseModel>> FetchWeek(int propertyId, DateTime weekStart)
209+
{
210+
var ws = DateTime.SpecifyKind(weekStart.Date, DateTimeKind.Utc);
211+
var we = ws.AddDays(7).AddSeconds(-1);
212+
var result = await _calendarService.GetTasksForWeek(new CalendarTaskRequestModel
213+
{
214+
PropertyId = propertyId,
215+
WeekStart = IsoUtc(ws),
216+
WeekEnd = IsoUtc(we)
217+
});
218+
Assert.That(result.Success, Is.True, result.Message);
219+
return result.Model;
220+
}
221+
222+
[Test]
223+
public async Task CreatesAndReadsBackMultiDayWeekly()
224+
{
225+
// Persist a weeklyMulti rule (every 2 weeks, Mon/Wed/Fri, after 10
226+
// occurrences) and verify every repeat field round-trips through
227+
// the response DTO unchanged.
228+
var monday = GetNextMonday();
229+
var seeded = await SeedTask(
230+
startDate: monday,
231+
arpRepeatType: 2,
232+
arpRepeatEvery: 2,
233+
repeatWeekdaysCsv: "1,3,5",
234+
repeatEndMode: 1,
235+
repeatOccurrences: 10,
236+
dayOfWeek: (int)monday.DayOfWeek);
237+
238+
var tasks = await FetchWeek(seeded.PropertyId, monday);
239+
240+
Assert.That(tasks, Is.Not.Empty);
241+
var task = tasks.First();
242+
Assert.Multiple(() =>
243+
{
244+
Assert.That(task.RepeatType, Is.EqualTo(2));
245+
Assert.That(task.RepeatEvery, Is.EqualTo(2));
246+
Assert.That(task.RepeatEndMode, Is.EqualTo(1));
247+
Assert.That(task.RepeatOccurrences, Is.EqualTo(10));
248+
Assert.That(task.RepeatUntilDate, Is.Null);
249+
Assert.That(task.RepeatWeekdaysCsv, Is.EqualTo("1,3,5"));
250+
Assert.That(task.DayOfWeek, Is.EqualTo((int)monday.DayOfWeek));
251+
});
252+
}
253+
254+
[Test]
255+
public async Task CreatesAndReadsBackSingleDayWeekly()
256+
{
257+
// Single-day weekly (legacy weeklyOne) — RepeatWeekdaysCsv must
258+
// round-trip as null and the iterator must keep its single-day
259+
// behavior (one occurrence on the start-of-week anchor day).
260+
var monday = GetNextMonday();
261+
var seeded = await SeedTask(
262+
startDate: monday,
263+
arpRepeatType: 2,
264+
arpRepeatEvery: 1,
265+
repeatWeekdaysCsv: null,
266+
dayOfWeek: (int)monday.DayOfWeek);
267+
268+
var tasks = await FetchWeek(seeded.PropertyId, monday);
269+
270+
Assert.That(tasks, Has.Count.EqualTo(1));
271+
var task = tasks.First();
272+
Assert.Multiple(() =>
273+
{
274+
Assert.That(task.RepeatType, Is.EqualTo(2));
275+
Assert.That(task.RepeatEvery, Is.EqualTo(1));
276+
Assert.That(task.RepeatWeekdaysCsv, Is.Null);
277+
Assert.That(task.RepeatEndMode, Is.Null);
278+
Assert.That(task.RepeatOccurrences, Is.Null);
279+
Assert.That(task.RepeatUntilDate, Is.Null);
280+
});
281+
}
282+
283+
[Test]
284+
public async Task MultiDayWeeklyExpandsToMultipleOccurrences()
285+
{
286+
// [1,3,5] = Mon/Wed/Fri every 2 weeks starting from `monday`. Week 0
287+
// emits 3 occurrences, week 1 emits 0 (off-cycle), week 2 emits 3
288+
// again. Locks in the iterator-fix contract.
289+
var monday = GetNextMonday();
290+
var seeded = await SeedTask(
291+
startDate: monday,
292+
arpRepeatType: 2,
293+
arpRepeatEvery: 2,
294+
repeatWeekdaysCsv: "1,3,5",
295+
dayOfWeek: (int)monday.DayOfWeek);
296+
297+
var week0 = await FetchWeek(seeded.PropertyId, monday);
298+
Assert.That(week0, Has.Count.EqualTo(3),
299+
"week of startDate should emit Mon+Wed+Fri (3 occurrences)");
300+
var week0Dates = week0.Select(t => t.TaskDate).OrderBy(s => s).ToList();
301+
Assert.That(week0Dates, Is.EqualTo(new[]
302+
{
303+
monday.ToString("yyyy-MM-dd"),
304+
monday.AddDays(2).ToString("yyyy-MM-dd"),
305+
monday.AddDays(4).ToString("yyyy-MM-dd")
306+
}));
307+
308+
var week1 = await FetchWeek(seeded.PropertyId, monday.AddDays(7));
309+
Assert.That(week1, Is.Empty,
310+
"week 1 is off-cycle for repeatEvery=2 — must be empty");
311+
312+
var week2 = await FetchWeek(seeded.PropertyId, monday.AddDays(14));
313+
Assert.That(week2, Has.Count.EqualTo(3),
314+
"week 2 is on-cycle again — must emit Mon+Wed+Fri");
315+
var week2Dates = week2.Select(t => t.TaskDate).OrderBy(s => s).ToList();
316+
Assert.That(week2Dates, Is.EqualTo(new[]
317+
{
318+
monday.AddDays(14).ToString("yyyy-MM-dd"),
319+
monday.AddDays(16).ToString("yyyy-MM-dd"),
320+
monday.AddDays(18).ToString("yyyy-MM-dd")
321+
}));
322+
}
323+
324+
[Test]
325+
public async Task MultiDayWeeklyAfterCapStopsAtTotalOccurrences()
326+
{
327+
// Mon+Wed+Fri every 2 weeks, capped at 10 occurrences. The cap counts
328+
// total occurrences across all matched weekdays (not weeks). Locks the
329+
// GetTasksForWeek bug fix where the previous code used the week-scoped
330+
// iterator for the cumulative count and never reached the cap.
331+
//
332+
// Expected emit pattern:
333+
// week 0: Mon, Wed, Fri (occ 1..3)
334+
// week 1: empty (off-cycle)
335+
// week 2: Mon, Wed, Fri (occ 4..6)
336+
// week 3: empty
337+
// week 4: Mon, Wed, Fri (occ 7..9)
338+
// week 5: empty
339+
// week 6: Mon (occ 10 — cap reached, Wed/Fri trimmed)
340+
// week 7+: empty
341+
var monday = GetNextMonday();
342+
var seeded = await SeedTask(
343+
startDate: monday,
344+
arpRepeatType: 2,
345+
arpRepeatEvery: 2,
346+
repeatWeekdaysCsv: "1,3,5",
347+
repeatEndMode: 1,
348+
repeatOccurrences: 10,
349+
dayOfWeek: (int)monday.DayOfWeek);
350+
351+
var week6 = await FetchWeek(seeded.PropertyId, monday.AddDays(42));
352+
Assert.That(week6, Has.Count.EqualTo(1),
353+
"week 6 is on-cycle but cap=10 leaves only Monday");
354+
Assert.That(week6.Select(t => t.TaskDate).Single(),
355+
Is.EqualTo(monday.AddDays(42).ToString("yyyy-MM-dd")));
356+
357+
var week8 = await FetchWeek(seeded.PropertyId, monday.AddDays(56));
358+
Assert.That(week8, Is.Empty,
359+
"after cap is reached at occ 10, no further occurrences emit");
360+
}
361+
}

eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn.Integration.Test/SQL/420_eform-backend-configuration-plugin.sql

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,7 @@ CREATE TABLE `AreaRulePlannings` (
287287
`RepeatEndMode` int(11) DEFAULT NULL,
288288
`RepeatOccurrences` int(11) DEFAULT NULL,
289289
`RepeatUntilDate` datetime(6) DEFAULT NULL,
290+
`RepeatWeekdaysCsv` varchar(13) DEFAULT NULL,
290291
`Status` tinyint(1) NOT NULL,
291292
`SendNotifications` tinyint(1) NOT NULL,
292293
`Alarm` int(11) NOT NULL,
@@ -510,6 +511,7 @@ CREATE TABLE `AreaRulesPlanningVersions` (
510511
`RepeatEndMode` int(11) DEFAULT NULL,
511512
`RepeatOccurrences` int(11) DEFAULT NULL,
512513
`RepeatUntilDate` datetime(6) DEFAULT NULL,
514+
`RepeatWeekdaysCsv` varchar(13) DEFAULT NULL,
513515
`Status` tinyint(1) NOT NULL,
514516
`SendNotifications` tinyint(1) NOT NULL,
515517
`Alarm` int(11) NOT NULL,

eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/BackendConfiguration.Pn.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,7 @@
225225
<PackageReference Include="Microting.EformAngularFrontendBase" Version="10.0.30" />
226226
<PackageReference Include="Microting.eFormApi.BasePn" Version="10.0.21" />
227227
<PackageReference Include="McMaster.NETCore.Plugins" Version="2.0.0" />
228-
<PackageReference Include="Microting.EformBackendConfigurationBase" Version="10.0.29" />
228+
<PackageReference Include="Microting.EformBackendConfigurationBase" Version="10.0.30" />
229229
<PackageReference Include="Microting.eFormCaseTemplateBase" Version="10.0.25" />
230230
<PackageReference Include="Microting.ItemsPlanningBase" Version="10.0.26" />
231231
<PackageReference Include="Microting.TimePlanningBase" Version="10.0.43" />

eFormAPI/Plugins/BackendConfiguration.Pn/BackendConfiguration.Pn/Infrastructure/Models/Calendar/CalendarTaskCreateRequestModel.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,5 +25,6 @@ public class CalendarTaskCreateRequestModel
2525
public int? RepeatEndMode { get; set; }
2626
public int? RepeatOccurrences { get; set; }
2727
public DateTime? RepeatUntilDate { get; set; }
28+
public string? RepeatWeekdaysCsv { get; set; }
2829
public string? DescriptionHtml { get; set; }
2930
}

0 commit comments

Comments
 (0)