From e225fa4f62e8cd0730a9871194855003b0e45631 Mon Sep 17 00:00:00 2001 From: Tristan Vermeesch Date: Sat, 14 Mar 2026 17:45:23 +0100 Subject: [PATCH] feat: lock start time to time grid --- build.gradle | 4 +- docs/docs/configuration/schedules.md | 38 +++++ java17-build.gradle | 4 +- java21-build.gradle | 4 +- .../scheduler/EditIntervalStartTimeMenu.java | 138 +++++++++++++++++ .../gui/tasks/scheduler/MainScheduleMenu.java | 14 +- .../playbosswar/com/language/LanguageKey.java | 6 +- .../java/me/playbosswar/com/tasks/Task.java | 13 ++ .../playbosswar/com/tasks/TasksManager.java | 83 ++++++++++ .../tasks/persistors/LocalTimePersistor.java | 41 +++++ src/main/resources/languages/default.json | 6 +- src/main/resources/languages/en.json | 6 +- src/main/resources/plugin.yml | 2 +- .../com/tasks/TasksManagerScheduleTest.java | 146 ++++++++++++++++++ 14 files changed, 494 insertions(+), 11 deletions(-) create mode 100644 src/main/java/me/playbosswar/com/gui/tasks/scheduler/EditIntervalStartTimeMenu.java create mode 100644 src/main/java/me/playbosswar/com/tasks/persistors/LocalTimePersistor.java diff --git a/build.gradle b/build.gradle index 45232c8b..72945b77 100644 --- a/build.gradle +++ b/build.gradle @@ -9,7 +9,7 @@ java { } group = 'me.playbosswar.com' -version = '8.16.5' +version = '8.17.0' description = 'CommandTimer' repositories { @@ -84,7 +84,7 @@ publishing { maven(MavenPublication) { groupId = 'me.playbosswar.com' artifactId = 'commandtimer' - version = '8.16.5' + version = '8.17.0' from components.java } diff --git a/docs/docs/configuration/schedules.md b/docs/docs/configuration/schedules.md index 8b661954..a0d0082a 100644 --- a/docs/docs/configuration/schedules.md +++ b/docs/docs/configuration/schedules.md @@ -31,6 +31,44 @@ When configuring Minecraft time, a world also needs to be selected to use the ti If you want to know the world time, go to the world you want to check and execute `/cmt time` +## Interval Start Time + +By default, interval-based tasks schedule executions relative to when the task was last run. This means the exact execution times can shift depending on when your server started or when the task was activated. + +The **Interval Start Time** feature lets you anchor executions to fixed clock times instead. When a start time is configured, executions will always land on a predictable grid. + +### How it works + +The start time acts as an anchor point. Combined with your interval, it creates a repeating grid of execution times that stays consistent regardless of server restarts. + +For example, if you set: +- **Interval**: 15 minutes +- **Start Time**: 15:00 + +Your task will execute at: ..., 14:00, 14:15, 14:30, 14:45, 15:00, 15:15, 15:30, ... + +The grid extends across the entire day based on the interval. Even if your server restarts at 14:02, the next execution will still be at 14:15 — not at 14:17. + +### Examples + +| Interval | Start Time | Executions | +|----------|-----------|------------| +| 15 min | 15:00 | 00:00, 00:15, 00:30, ..., 14:45, 15:00, 15:15, ... | +| 2 hours | 13:00 | 01:00, 03:00, 05:00, 07:00, 09:00, 11:00, 13:00, ... | +| 30 min | 10:15 | 00:15, 00:45, 01:15, ..., 10:15, 10:45, 11:15, ... | + +### Configuring + +In the task scheduler menu, click the **Interval Start Time** item (compass icon). From there you can set the hours, minutes, and seconds for the anchor time. Use the **Clear** button to remove the start time and go back to the default behavior. + +:::tip +This is especially useful for tasks that need to run at predictable clock-aligned times, such as hourly announcements or scheduled restarts. +::: + +:::note +The start time only affects interval-based scheduling. If your task uses [fixed times](#fixed-times), those already execute at exact clock times and don't need a start time anchor. +::: + ## Days In the days menu, you can select on which days the [task](../jargon#task) will be executed. By default all the days are selected. diff --git a/java17-build.gradle b/java17-build.gradle index 7930f926..e0d2335c 100644 --- a/java17-build.gradle +++ b/java17-build.gradle @@ -10,7 +10,7 @@ java { group = 'me.playbosswar.com' -version = '8.16.5' +version = '8.17.0' description = 'CommandTimer' repositories { @@ -63,7 +63,7 @@ publishing { maven(MavenPublication) { groupId = 'me.playbosswar.com' artifactId = 'commandtimer-java17' - version = '8.16.5' + version = '8.17.0' from components.java } diff --git a/java21-build.gradle b/java21-build.gradle index 4505105b..595d9f0b 100644 --- a/java21-build.gradle +++ b/java21-build.gradle @@ -10,7 +10,7 @@ java { group = 'me.playbosswar.com' -version = '8.16.5' +version = '8.17.0' description = 'CommandTimer' repositories { @@ -67,7 +67,7 @@ publishing { maven(MavenPublication) { groupId = 'me.playbosswar.com' artifactId = 'commandtimer-java21' - version = '8.16.5' + version = '8.17.0' from components.java } } diff --git a/src/main/java/me/playbosswar/com/gui/tasks/scheduler/EditIntervalStartTimeMenu.java b/src/main/java/me/playbosswar/com/gui/tasks/scheduler/EditIntervalStartTimeMenu.java new file mode 100644 index 00000000..ca088975 --- /dev/null +++ b/src/main/java/me/playbosswar/com/gui/tasks/scheduler/EditIntervalStartTimeMenu.java @@ -0,0 +1,138 @@ +package me.playbosswar.com.gui.tasks.scheduler; + +import com.cryptomorin.xseries.XMaterial; +import fr.minuskube.inv.ClickableItem; +import fr.minuskube.inv.SmartInventory; +import fr.minuskube.inv.content.InventoryContents; +import fr.minuskube.inv.content.InventoryProvider; +import me.playbosswar.com.CommandTimerPlugin; +import me.playbosswar.com.gui.tasks.general.ClickableTextInputButton; +import me.playbosswar.com.language.LanguageKey; +import me.playbosswar.com.language.LanguageManager; +import me.playbosswar.com.tasks.Task; +import me.playbosswar.com.utils.Items; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; + +import java.time.LocalTime; + +public class EditIntervalStartTimeMenu implements InventoryProvider { + public final SmartInventory INVENTORY; + private final LanguageManager languageManager = CommandTimerPlugin.getLanguageManager(); + private final Task task; + private final String[] clockLore = {"", languageManager.get(LanguageKey.LEFT_CLICK_EDIT)}; + + public EditIntervalStartTimeMenu(Task task) { + this.task = task; + INVENTORY = SmartInventory.builder() + .id("task-interval-start-time") + .provider(this) + .manager(CommandTimerPlugin.getInstance().getInventoryManager()) + .size(5, 9) + .title(languageManager.get(LanguageKey.INTERVAL_START_TIME_GUI_TITLE)) + .build(); + } + + @Override + public void init(Player player, InventoryContents contents) { + contents.fill(ClickableItem.empty(XMaterial.BLUE_STAINED_GLASS_PANE.parseItem())); + + LocalTime current = task.getIntervalStartTime(); + int hours = current != null ? current.getHour() : 0; + int minutes = current != null ? current.getMinute() : 0; + int seconds = current != null ? current.getSecond() : 0; + + // Hours + contents.set(1, 1, ClickableItem.of(Items.getAddItem(), e -> { + LocalTime t = task.getIntervalStartTime() != null ? task.getIntervalStartTime() : LocalTime.MIDNIGHT; + task.setIntervalStartTime(t.withHour((t.getHour() + 1) % 24)); + refresh(player); + })); + ClickableTextInputButton hoursClock = new ClickableTextInputButton( + Items.generateItem(languageManager.get(LanguageKey.HOURS_LABEL, String.valueOf(hours)), + XMaterial.CLOCK, clockLore), + LanguageKey.TEXT_INPUT_DEFAULT, + data -> { + int h = Integer.parseInt(data); + LocalTime t = task.getIntervalStartTime() != null ? task.getIntervalStartTime() : LocalTime.MIDNIGHT; + task.setIntervalStartTime(t.withHour(h % 24)); + refresh(player); + } + ); + contents.set(2, 1, hoursClock.getItem()); + contents.set(3, 1, ClickableItem.of(Items.getSubstractItem(), e -> { + LocalTime t = task.getIntervalStartTime() != null ? task.getIntervalStartTime() : LocalTime.MIDNIGHT; + task.setIntervalStartTime(t.withHour((t.getHour() + 23) % 24)); + refresh(player); + })); + + // Minutes + contents.set(1, 3, ClickableItem.of(Items.getAddItem(), e -> { + LocalTime t = task.getIntervalStartTime() != null ? task.getIntervalStartTime() : LocalTime.MIDNIGHT; + task.setIntervalStartTime(t.withMinute((t.getMinute() + 1) % 60)); + refresh(player); + })); + ClickableTextInputButton minutesClock = new ClickableTextInputButton( + Items.generateItem(languageManager.get(LanguageKey.MINUTES_LABEL, String.valueOf(minutes)), + XMaterial.CLOCK, clockLore), + LanguageKey.TEXT_INPUT_DEFAULT, + data -> { + int m = Integer.parseInt(data); + LocalTime t = task.getIntervalStartTime() != null ? task.getIntervalStartTime() : LocalTime.MIDNIGHT; + task.setIntervalStartTime(t.withMinute(m % 60)); + refresh(player); + } + ); + contents.set(2, 3, minutesClock.getItem()); + contents.set(3, 3, ClickableItem.of(Items.getSubstractItem(), e -> { + LocalTime t = task.getIntervalStartTime() != null ? task.getIntervalStartTime() : LocalTime.MIDNIGHT; + task.setIntervalStartTime(t.withMinute((t.getMinute() + 59) % 60)); + refresh(player); + })); + + // Seconds + contents.set(1, 5, ClickableItem.of(Items.getAddItem(), e -> { + LocalTime t = task.getIntervalStartTime() != null ? task.getIntervalStartTime() : LocalTime.MIDNIGHT; + task.setIntervalStartTime(t.withSecond((t.getSecond() + 1) % 60)); + refresh(player); + })); + ClickableTextInputButton secondsClock = new ClickableTextInputButton( + Items.generateItem(languageManager.get(LanguageKey.SECONDS_LABEL, String.valueOf(seconds)), + XMaterial.CLOCK, clockLore), + LanguageKey.TEXT_INPUT_DEFAULT, + data -> { + int s = Integer.parseInt(data); + LocalTime t = task.getIntervalStartTime() != null ? task.getIntervalStartTime() : LocalTime.MIDNIGHT; + task.setIntervalStartTime(t.withSecond(s % 60)); + refresh(player); + } + ); + contents.set(2, 5, secondsClock.getItem()); + contents.set(3, 5, ClickableItem.of(Items.getSubstractItem(), e -> { + LocalTime t = task.getIntervalStartTime() != null ? task.getIntervalStartTime() : LocalTime.MIDNIGHT; + task.setIntervalStartTime(t.withSecond((t.getSecond() + 59) % 60)); + refresh(player); + })); + + // Clear button + ItemStack clearItem = Items.generateItem( + languageManager.get(LanguageKey.INTERVAL_START_TIME_CLEAR), + XMaterial.BARRIER); + contents.set(4, 0, ClickableItem.of(clearItem, e -> { + task.setIntervalStartTime(null); + refresh(player); + })); + + // Back button + contents.set(4, 8, ClickableItem.of(Items.getBackItem(), + e -> new MainScheduleMenu(task).INVENTORY.open(player))); + } + + @Override + public void update(Player player, InventoryContents contents) { + } + + private void refresh(Player player) { + this.INVENTORY.open(player); + } +} diff --git a/src/main/java/me/playbosswar/com/gui/tasks/scheduler/MainScheduleMenu.java b/src/main/java/me/playbosswar/com/gui/tasks/scheduler/MainScheduleMenu.java index 88869211..eb07242a 100644 --- a/src/main/java/me/playbosswar/com/gui/tasks/scheduler/MainScheduleMenu.java +++ b/src/main/java/me/playbosswar/com/gui/tasks/scheduler/MainScheduleMenu.java @@ -14,8 +14,9 @@ import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; import java.util.ArrayList; -import java.util.List; public class MainScheduleMenu implements InventoryProvider { public SmartInventory INVENTORY; @@ -63,6 +64,17 @@ public void init(Player player, InventoryContents contents) { e -> new EditDaysMenu(task).INVENTORY.open(player)); contents.set(1, 3, clickableDaysItem); + LocalTime startTime = task.getIntervalStartTime(); + String startTimeDisplay = startTime != null + ? startTime.format(DateTimeFormatter.ofPattern("HH:mm:ss")) + : languageManager.get(LanguageKey.NOT_SET); + ArrayList startTimeLore = languageManager.getList(LanguageKey.INTERVAL_START_TIME_LORE, startTimeDisplay); + ItemStack startTimeItem = Items.generateItem(LanguageKey.INTERVAL_START_TIME_TITLE, XMaterial.COMPASS, + startTimeLore.toArray(new String[]{})); + ClickableItem clickableStartTimeItem = ClickableItem.of(startTimeItem, + e -> new EditIntervalStartTimeMenu(task).INVENTORY.open(player)); + contents.set(1, 4, clickableStartTimeItem); + contents.set(2, 8, ClickableItem.of(Items.getBackItem(), e -> new EditTaskMenu(task).INVENTORY.open(player))); } diff --git a/src/main/java/me/playbosswar/com/language/LanguageKey.java b/src/main/java/me/playbosswar/com/language/LanguageKey.java index bb8d8b70..d7a88b3e 100644 --- a/src/main/java/me/playbosswar/com/language/LanguageKey.java +++ b/src/main/java/me/playbosswar/com/language/LanguageKey.java @@ -165,7 +165,11 @@ public enum LanguageKey { FILTER_BUTTON_CURRENT, FILTER_ALL, FILTER_TASKS_ONLY, - FILTER_ADHOC_ONLY; + FILTER_ADHOC_ONLY, + INTERVAL_START_TIME_TITLE, + INTERVAL_START_TIME_LORE, + INTERVAL_START_TIME_GUI_TITLE, + INTERVAL_START_TIME_CLEAR; public static LanguageKey getByTag(String tag) { return Arrays.stream(values()).filter(value -> value.toString().equals(tag.toUpperCase())).findFirst() diff --git a/src/main/java/me/playbosswar/com/tasks/Task.java b/src/main/java/me/playbosswar/com/tasks/Task.java index f9d3b353..1200ae40 100644 --- a/src/main/java/me/playbosswar/com/tasks/Task.java +++ b/src/main/java/me/playbosswar/com/tasks/Task.java @@ -24,6 +24,7 @@ import java.io.IOException; import java.sql.SQLException; import java.time.DayOfWeek; +import java.time.LocalTime; import java.util.*; import java.util.stream.Collectors; @@ -60,6 +61,8 @@ public class Task { private Condition condition; @DatabaseField(persisterClass = EventConfigurationPersistor.class) private Collection events = new ArrayList<>(); + @DatabaseField(persisterClass = LocalTimePersistor.class) + private LocalTime intervalStartTime = null; Task() { // all persisted classes must define a no-arg constructor with at least package visibility @@ -306,6 +309,16 @@ public void setEvents(List events) { this.events = events; } + public LocalTime getIntervalStartTime() { + return intervalStartTime; + } + + public void setIntervalStartTime(LocalTime intervalStartTime) { + this.intervalStartTime = intervalStartTime; + storeInstance(); + CommandTimerPlugin.getInstance().getTasksManager().resetScheduleForTask(this); + } + public boolean hasCondition() { if(this.condition == null) { return false; diff --git a/src/main/java/me/playbosswar/com/tasks/TasksManager.java b/src/main/java/me/playbosswar/com/tasks/TasksManager.java index 72c6d5c6..28885586 100644 --- a/src/main/java/me/playbosswar/com/tasks/TasksManager.java +++ b/src/main/java/me/playbosswar/com/tasks/TasksManager.java @@ -217,6 +217,11 @@ public void populateScheduleForTask(Task task) { return; } + if (task.getIntervalStartTime() != null) { + populateAlignedIntervalSchedule(task, now); + return; + } + ZonedDateTime lastExecuted = task.getLastExecuted().toInstant().atZone(ZoneId.systemDefault()); populateIntervalOnlySchedule(task, lastExecuted, now); } @@ -473,6 +478,84 @@ private void populateIntervalOnlySchedule(Task task, ZonedDateTime lastExecuted, } } + private void populateAlignedIntervalSchedule(Task task, ZonedDateTime now) { + long alreadyScheduled = scheduledTasks.stream() + .filter(st -> st.getTask().getId().equals(task.getId())).count(); + + if (alreadyScheduled >= 50) { + Messages.sendDebugConsole( + "Task " + task.getName() + " has already been scheduled 50 times, skipping scheduling"); + return; + } + + int maxToSchedule = 50 - (int) alreadyScheduled; + + int executionLimit = task.getExecutionLimit(); + if (executionLimit != -1) { + int remaining = executionLimit - task.getTimesExecuted() - (int) alreadyScheduled; + if (remaining <= 0) { + Messages.sendDebugConsole( + "Task " + task.getName() + " has reached execution limit, skipping scheduling"); + return; + } + maxToSchedule = Math.min(maxToSchedule, remaining); + } + + ZonedDateTime latestScheduledDate = scheduledTasks.stream() + .filter(st -> st.getTask().getId().equals(task.getId())) + .map(ScheduledTask::getDate).max(ZonedDateTime::compareTo).orElse(null); + + long intervalSeconds = task.getInterval().toSeconds(); + if (intervalSeconds <= 0) intervalSeconds = 1; + + LocalTime startTime = task.getIntervalStartTime(); + LocalDate currentDay = now.toLocalDate(); + + while (maxToSchedule > 0) { + if (!task.getDays().contains(currentDay.getDayOfWeek())) { + currentDay = currentDay.plusDays(1); + continue; + } + + ZonedDateTime anchorForDay = ZonedDateTime.of(currentDay, startTime, now.getZone()); + + // Compute the first grid point at or after the effective start for this day + ZonedDateTime effectiveStart = now; + if (latestScheduledDate != null && latestScheduledDate.isAfter(effectiveStart)) { + effectiveStart = latestScheduledDate; + } + + // Use modulo to find first grid point at or after effectiveStart + // Grid: anchor + N * interval for any integer N + long diffSeconds = java.time.Duration.between(anchorForDay, effectiveStart).getSeconds(); + long remainder = ((diffSeconds % intervalSeconds) + intervalSeconds) % intervalSeconds; + ZonedDateTime firstGridPoint; + if (remainder == 0) { + firstGridPoint = effectiveStart; + } else { + firstGridPoint = effectiveStart.plusSeconds(intervalSeconds - remainder); + } + + // Only schedule points that fall on this calendar day + ZonedDateTime endOfDay = ZonedDateTime.of(currentDay.plusDays(1), LocalTime.MIDNIGHT, now.getZone()); + + ZonedDateTime gridPoint = firstGridPoint; + while (maxToSchedule > 0 && gridPoint.isBefore(endOfDay)) { + if (gridPoint.toLocalDate().equals(currentDay)) { + boolean isDuplicate = latestScheduledDate != null && !gridPoint.isAfter(latestScheduledDate); + if (!isDuplicate && !gridPoint.isBefore(now)) { + scheduledTasks.add(new ScheduledTask(task, gridPoint)); + maxToSchedule--; + latestScheduledDate = gridPoint; + } + } + gridPoint = gridPoint.plusSeconds(intervalSeconds); + } + + currentDay = currentDay.plusDays(1); + } + } + public ScheduledTask getNextScheduledTask() { return scheduledTasks.peek(); } diff --git a/src/main/java/me/playbosswar/com/tasks/persistors/LocalTimePersistor.java b/src/main/java/me/playbosswar/com/tasks/persistors/LocalTimePersistor.java new file mode 100644 index 00000000..0c40d4f7 --- /dev/null +++ b/src/main/java/me/playbosswar/com/tasks/persistors/LocalTimePersistor.java @@ -0,0 +1,41 @@ +package me.playbosswar.com.tasks.persistors; + +import com.j256.ormlite.field.FieldType; +import com.j256.ormlite.field.SqlType; +import com.j256.ormlite.field.types.LongStringType; + +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; + +public class LocalTimePersistor extends LongStringType { + private static final LocalTimePersistor singleTon = new LocalTimePersistor(); + private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("HH:mm:ss"); + + private LocalTimePersistor() { + super(SqlType.LONG_STRING, new Class[]{LocalTime.class}); + } + + public static LocalTimePersistor getSingleton() { + return singleTon; + } + + @Override + public Object javaToSqlArg(FieldType fieldType, Object javaObject) { + if (javaObject == null) { + return null; + } + return ((LocalTime) javaObject).format(FORMATTER); + } + + @Override + public Object sqlArgToJava(FieldType fieldType, Object sqlArg, int columnPos) { + if (sqlArg == null) { + return null; + } + String s = (String) sqlArg; + if (s.isEmpty()) { + return null; + } + return LocalTime.parse(s, FORMATTER); + } +} diff --git a/src/main/resources/languages/default.json b/src/main/resources/languages/default.json index 55f33d1a..82ccee8f 100644 --- a/src/main/resources/languages/default.json +++ b/src/main/resources/languages/default.json @@ -161,5 +161,9 @@ "filter_button_current": "&7Current filter: &e$1", "filter_all": "All", "filter_tasks_only": "Tasks Only", - "filter_adhoc_only": "Ad-Hoc Only" + "filter_adhoc_only": "Ad-Hoc Only", + "interval_start_time_title": "&bInterval Start Time", + "interval_start_time_lore": "\n&7Set a start time anchor for interval execution.\n&7This aligns executions to clock boundaries\n&7instead of relative to last execution.\n\n&7Current: &e$1", + "interval_start_time_gui_title": "&9&lInterval Start Time", + "interval_start_time_clear": "&cClear start time" } \ No newline at end of file diff --git a/src/main/resources/languages/en.json b/src/main/resources/languages/en.json index 55f33d1a..82ccee8f 100644 --- a/src/main/resources/languages/en.json +++ b/src/main/resources/languages/en.json @@ -161,5 +161,9 @@ "filter_button_current": "&7Current filter: &e$1", "filter_all": "All", "filter_tasks_only": "Tasks Only", - "filter_adhoc_only": "Ad-Hoc Only" + "filter_adhoc_only": "Ad-Hoc Only", + "interval_start_time_title": "&bInterval Start Time", + "interval_start_time_lore": "\n&7Set a start time anchor for interval execution.\n&7This aligns executions to clock boundaries\n&7instead of relative to last execution.\n\n&7Current: &e$1", + "interval_start_time_gui_title": "&9&lInterval Start Time", + "interval_start_time_clear": "&cClear start time" } \ No newline at end of file diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index f4923b8d..32fed6bd 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -1,6 +1,6 @@ main: me.playbosswar.com.CommandTimerPlugin name: "CommandTimer" -version: "8.16.5" +version: "8.17.0" description: "Schedule commands like you want" author: PlayBossWar api-version: 1.13 diff --git a/src/test/java/me/playbosswar/com/tasks/TasksManagerScheduleTest.java b/src/test/java/me/playbosswar/com/tasks/TasksManagerScheduleTest.java index e3a8defb..3ef1213d 100644 --- a/src/test/java/me/playbosswar/com/tasks/TasksManagerScheduleTest.java +++ b/src/test/java/me/playbosswar/com/tasks/TasksManagerScheduleTest.java @@ -603,6 +603,152 @@ void noTimesNoInterval_withEvents_skipsScheduling() { "Should skip scheduling for event-only tasks with no interval"); } + // ========================= Aligned interval (grid-locked) ========================= + + @Test + void alignedInterval_schedulesAtCorrectGridTimes() { + // startTime=15:00, interval=15min, now=Monday 08:00 + // Grid for today: 08:00, 08:15, 08:30, ... + Task task = createTestTask(); + setField(task, "interval", new TaskInterval(0, 0, 15, 0)); // 15 min + setField(task, "intervalStartTime", LocalTime.of(15, 0)); + + + manager.populateScheduleForTask(task); + + List scheduled = scheduledFor(task); + assertTrue(scheduled.size() > 0, "Should have scheduled entries"); + + // All entries should fall on 15-minute grid aligned to 15:00 + for (ScheduledTask st : scheduled) { + LocalTime t = st.getDate().toLocalTime(); + long minutesSinceMidnight = t.getHour() * 60 + t.getMinute(); + // 15:00 = 900 minutes; grid: any time where (minutes - 900) % 15 == 0 + long offset = ((minutesSinceMidnight - 900) % 15 + 15) % 15; + assertEquals(0, offset, + "Entry at " + t + " should be on 15-min grid anchored at 15:00"); + assertEquals(0, t.getSecond(), "Seconds should be 0"); + } + } + + @Test + void alignedInterval_firstEntryIsNextGridPointAfterNow() { + // startTime=15:00, interval=15min, now=08:00 + // Grid: ...07:45, 08:00, 08:15... → first should be 08:00 + Task task = createTestTask(); + setField(task, "interval", new TaskInterval(0, 0, 15, 0)); + setField(task, "intervalStartTime", LocalTime.of(15, 0)); + + + manager.populateScheduleForTask(task); + + List dates = scheduledFor(task).stream() + .map(ScheduledTask::getDate) + .sorted() + .collect(Collectors.toList()); + + // 08:00 is exactly on the grid, so it should be the first entry + assertEquals(LocalTime.of(8, 0), dates.get(0).toLocalTime(), + "First entry should be at 08:00 (on the grid)"); + } + + @Test + void alignedInterval_rollsOverToNextDay() { + // startTime=23:00, interval=2h, now=08:00 + // Grid today: 09:00, 11:00, 13:00, 15:00, 17:00, 19:00, 21:00, 23:00 + // Should eventually schedule entries on the next day too + Task task = createTestTask(); + setField(task, "interval", new TaskInterval(0, 2, 0, 0)); // 2h + setField(task, "intervalStartTime", LocalTime.of(23, 0)); + + + manager.populateScheduleForTask(task); + + List dates = scheduledFor(task).stream() + .map(ScheduledTask::getDate) + .sorted() + .collect(Collectors.toList()); + + assertTrue(dates.size() >= 2, "Should have multiple entries"); + + // Should have entries on multiple days + long distinctDays = dates.stream() + .map(d -> d.toLocalDate()) + .distinct() + .count(); + assertTrue(distinctDays > 1, "Should schedule across multiple days"); + } + + @Test + void alignedInterval_respectsDayFilter() { + Task task = createTestTask(); + setField(task, "interval", new TaskInterval(0, 1, 0, 0)); // 1h + setField(task, "intervalStartTime", LocalTime.of(12, 0)); + + // Only Wednesday (now is Monday) + Collection wednesdayOnly = new ArrayList<>(); + wednesdayOnly.add(DayOfWeek.WEDNESDAY); + setField(task, "days", wednesdayOnly); + + manager.populateScheduleForTask(task); + + List scheduled = scheduledFor(task); + assertTrue(scheduled.size() > 0); + for (ScheduledTask st : scheduled) { + assertEquals(DayOfWeek.WEDNESDAY, st.getDate().getDayOfWeek(), + "All entries should be on Wednesday"); + } + } + + @Test + void alignedInterval_respectsExecutionLimit() { + Task task = createTestTask(); + setField(task, "interval", new TaskInterval(0, 0, 15, 0)); + setField(task, "intervalStartTime", LocalTime.of(12, 0)); + + setField(task, "executionLimit", 5); + setField(task, "timesExecuted", 0); + + manager.populateScheduleForTask(task); + + assertEquals(5, scheduledFor(task).size(), "Should respect execution limit"); + } + + @Test + void alignedInterval_repopulationDoesNotDuplicate() { + Task task = createTestTask(); + setField(task, "interval", new TaskInterval(0, 0, 15, 0)); + setField(task, "intervalStartTime", LocalTime.of(12, 0)); + + + manager.populateScheduleForTask(task); + int firstCount = scheduledFor(task).size(); + assertEquals(50, firstCount); + + manager.populateScheduleForTask(task); + assertEquals(50, scheduledFor(task).size(), + "Repopulation should NOT add duplicates for aligned interval tasks"); + } + + @Test + void nullStartTime_fallsBackToExistingBehavior() { + // No intervalStartTime set — should behave identically to the original test + Task task = createTestTask(); + setField(task, "interval", new TaskInterval(0, 0, 0, 30)); + setField(task, "lastExecuted", Date.from(FIXED_INSTANT.minusSeconds(300))); + // intervalStartTime is null by default + + manager.populateScheduleForTask(task); + + List scheduled = scheduledFor(task); + assertEquals(50, scheduled.size()); + + // First entry should be catch-up at lastExecuted + 30s + Instant catchUpInstant = FIXED_INSTANT.minusSeconds(270); + assertTrue(scheduled.stream().anyMatch(st -> st.getDate().toInstant().equals(catchUpInstant)), + "Should have catch-up entry at lastExecuted + interval (null startTime = existing behavior)"); + } + @Test void allEntriesHaveCorrectTaskReference() { Task task = createTestTask();