diff --git a/AGENTS.md b/AGENTS.md
index bc28277..b40f599 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -160,6 +160,11 @@ Syncs the whole H2 file. OAuth client credentials live in
only when all its own-reason members are reminders. `StudyPlannerPanel` only
renders it. The calendar's "To plan" list excludes planned work,
applying the same parent rule through `CalendarAgenda.withParents`.
+- The planner's "Scheduled" strip under the date is `PlannerAgenda.scheduled`:
+ the recurring tasks whose schedule lands on the selected day (never deadlines
+ or lateness), each judged only by its own task's goals dated that day, like
+ `StudyGoal.hasHandledGoalForTaskOccurrence` (a subtask's goal does not handle
+ its parent's occurrence).
### Subtasks and task notes
diff --git a/README.md b/README.md
index cdf265f..d23d6c7 100644
--- a/README.md
+++ b/README.md
@@ -30,7 +30,7 @@ StudySync provides comprehensive academic management with three main modules:
* **Study Goals**: Set and manage daily study objectives with future date planning, each with an optional checklist of what "done" means — tick the last item and the goal is achieved
* **Goal outcomes**: Use a goal's **More** menu to mark an attempt as missed and retry it, or **Drop goal** when it is no longer achievable or relevant. Dropping adds no failure penalty or completion credit, preserves earlier missed attempts and study time, and accepts an optional reason. Dropped goals stay in history and can be reopened for a new date; the planner groups them under **Dropped**.
* **Future Goal Planning**: Navigate to any future date and plan goals ahead via DatePicker
-* **Recurring Tasks**: Define repeating task schedules (e.g. every week on Mon/Wed/Fri)
+* **Recurring Tasks**: Define repeating task schedules (e.g. every week on Mon/Wed/Fri). Under the date, the planner shows which recurring tasks are scheduled on the selected day and whether each is not planned, planned, done or missed; double-click or drag one to plan it
* **Daily Reflections**: A diary tab holding every entry you have ever written — search them, write a day at a time with autosave, or read the whole diary back as a thread of dated entries
* **Markdown Diary Entries**: Reflections are markdown — headings, lists and tables (there is a button for those) — written as source and read rendered, and exportable as one `YYYY-MM-DD.md` file per day into any folder or Obsidian vault
* **Progress Tracking**: Visual progress indicators and session statistics
diff --git a/src/main/java/com/studysync/presentation/ui/components/PlannerAgenda.java b/src/main/java/com/studysync/presentation/ui/components/PlannerAgenda.java
index 7999fde..e850d81 100644
--- a/src/main/java/com/studysync/presentation/ui/components/PlannerAgenda.java
+++ b/src/main/java/com/studysync/presentation/ui/components/PlannerAgenda.java
@@ -40,6 +40,10 @@
* member, then one block of standalone tasks. A block goes under "Coming up" only when every
* member listed for its own reason is a {@link Kind#REMINDER}; standalone reminders go there
* too. Missed goals without a task stay apart in {@link Plan#taskless()}.
+ *
+ * Separately, {@link #scheduled} answers "which recurring tasks are on this day", on any
+ * day: the schedule decides, never deadlines, lateness or carry-forward, and each occurrence
+ * is judged only by its own task's goals dated that day.
*/
final class PlannerAgenda {
@@ -90,6 +94,32 @@ List tiles() {
record Plan(List entries, List taskless, List needed, List comingUp) {
}
+ /** Where a scheduled occurrence stands on its day, declared in display order. */
+ enum Occurrence {
+ /** No goal for it yet, today or later. */
+ NOT_PLANNED,
+ /** A past day with no goal for it, or only missed goals. */
+ MISSED,
+ /** At least one goal still pending. */
+ PLANNED,
+ /** No goal pending and at least one achieved. */
+ DONE,
+ /** Its only goals that day were dropped. */
+ DROPPED
+ }
+
+ /**
+ * A recurring task whose schedule lands on the selected day, judged by its own goals dated
+ * that day (as {@code StudyGoal.hasHandledGoalForTaskOccurrence} judges an occurrence).
+ *
+ * @param detail what that day's goals amount to, such as "planned" or "1 of 2 done"
+ */
+ record Scheduled(Task task, String title, Occurrence state, String detail) {
+ String key() {
+ return "task:" + task.getId();
+ }
+ }
+
private static final DateTimeFormatter DAY = DateTimeFormatter.ofPattern("EEE");
private static final DateTimeFormatter MONTH_DAY = DateTimeFormatter.ofPattern("MMM d");
private static final int WEEK = 7;
@@ -216,6 +246,64 @@ private static void addUnplannedSubtasks(LocalDate date, List entries, Ma
}
}
+ /**
+ * The recurring tasks scheduled on {@code date}: every active one whose schedule lands there,
+ * plus any other with its own goal dated that day, so a past day keeps work done on a task
+ * since completed. Ordered by {@link Occurrence}, then title.
+ */
+ static List scheduled(LocalDate date, LocalDate today, List tasks,
+ List attempts, BiPredicate isOccurrence) {
+ Map tasksById = new LinkedHashMap<>();
+ tasks.forEach(task -> tasksById.putIfAbsent(task.getId(), task));
+ Map> goalsByTask = attempts.stream()
+ .filter(goal -> date.equals(goal.getDate()) && goal.getTaskId() != null)
+ .collect(Collectors.groupingBy(StudyGoal::getTaskId));
+ List scheduled = new ArrayList<>();
+ for (Task task : tasksById.values()) {
+ List goals = goalsByTask.getOrDefault(task.getId(), List.of());
+ if (!task.isRecurring() || !isOccurrence.test(task, date)
+ || !CalendarAgenda.isActive(task.getStatus()) && goals.isEmpty()) {
+ continue;
+ }
+ scheduled.add(occurrence(task, CalendarAgenda.taskTitle(task, tasksById), goals, date.isBefore(today)));
+ }
+ scheduled.sort(Comparator.comparing(Scheduled::state)
+ .thenComparing(Scheduled::title, String.CASE_INSENSITIVE_ORDER)
+ .thenComparing(Scheduled::key));
+ return List.copyOf(scheduled);
+ }
+
+ private static Scheduled occurrence(Task task, String title, List goals, boolean past) {
+ // One entry per goal: a goal missed and retried on the same day has two attempts that day,
+ // and it counts as its best outcome there (achieved, else pending, else missed).
+ Map byGoal = new LinkedHashMap<>();
+ goals.stream().filter(goal -> !goal.isDroppedAttempt()).forEach(goal ->
+ byGoal.merge(goal.getId(), goal, (first, second) ->
+ outcomeRank(second) < outcomeRank(first) ? second : first));
+ List kept = List.copyOf(byGoal.values());
+ if (kept.isEmpty()) {
+ return !goals.isEmpty() ? new Scheduled(task, title, Occurrence.DROPPED, "dropped")
+ : past ? new Scheduled(task, title, Occurrence.MISSED, "not done")
+ : new Scheduled(task, title, Occurrence.NOT_PLANNED, "not planned");
+ }
+ long done = kept.stream().filter(goal -> goal.getAttemptOutcome() == StudyGoal.AttemptOutcome.ACHIEVED).count();
+ long pending = kept.stream().filter(goal -> goal.getAttemptOutcome() == StudyGoal.AttemptOutcome.PENDING).count();
+ String progress = done + " of " + kept.size() + " done";
+ if (pending > 0) {
+ return new Scheduled(task, title, Occurrence.PLANNED,
+ done > 0 ? progress : kept.size() == 1 ? "planned" : kept.size() + " goals planned");
+ }
+ if (done > 0) {
+ return new Scheduled(task, title, Occurrence.DONE, done == kept.size() ? "done" : progress);
+ }
+ return new Scheduled(task, title, Occurrence.MISSED, kept.size() == 1 ? "goal missed" : kept.size() + " goals missed");
+ }
+
+ private static int outcomeRank(StudyGoal attempt) {
+ StudyGoal.AttemptOutcome outcome = attempt.getAttemptOutcome();
+ return outcome == StudyGoal.AttemptOutcome.ACHIEVED ? 0 : outcome == StudyGoal.AttemptOutcome.PENDING ? 1 : 2;
+ }
+
private static String dueText(LocalDate deadline, LocalDate date) {
if (deadline == null) {
return "no deadline";
diff --git a/src/main/java/com/studysync/presentation/ui/components/PlanningRow.java b/src/main/java/com/studysync/presentation/ui/components/PlanningRow.java
index 52bc52e..4e4654a 100644
--- a/src/main/java/com/studysync/presentation/ui/components/PlanningRow.java
+++ b/src/main/java/com/studysync/presentation/ui/components/PlanningRow.java
@@ -19,6 +19,8 @@ final class PlanningRow {
/** Node property holding the item's planning key, so a closed dialog can return focus to it. */
static final String KEY = "studysync.planning-key";
+ /** The widest a scheduled chip's title is shown before it is cut short. */
+ static final double CHIP_TITLE_WIDTH = 240;
private PlanningRow() {
}
@@ -83,6 +85,38 @@ static HBox header(String title, String reason, String color, String hint,
return row;
}
+ /**
+ * A compact pill for a day's scheduled occurrence: a status mark, the title and how it
+ * stands. With a {@code plan} action it is plannable like a tile; without one it only reports.
+ */
+ static HBox chip(String mark, String title, String state, String color, String hint,
+ DataFormat format, String id, Runnable plan) {
+ Label icon = TaskStyleUtils.iconLabel(mark, 12);
+ TaskStyleUtils.textColor(icon, color);
+ Label name = new Label(title);
+ TaskStyleUtils.fontSemiBold(name, 12);
+ name.setMinWidth(0);
+ // A long title is cut short with an ellipsis (the tooltip keeps it whole), so one chip
+ // cannot make its row, and with it the planner, wider than the window.
+ name.setMaxWidth(CHIP_TITLE_WIDTH);
+ Label detail = new Label(state);
+ TaskStyleUtils.fontNormal(detail, 11);
+ TaskStyleUtils.textColor(detail, color);
+ detail.setMinWidth(Region.USE_PREF_SIZE);
+ HBox chip = new HBox(6, icon, name, detail);
+ chip.setAlignment(Pos.CENTER_LEFT);
+ chip.setMinWidth(0);
+ chip.getStyleClass().add("plan-scheduled-chip");
+ if (plan != null) {
+ install(chip, title, state, hint, format, id, plan);
+ } else {
+ chip.getProperties().put(KEY, id);
+ chip.setAccessibleText(title + ". " + state + ".");
+ Tooltip.install(chip, new Tooltip(title + "\n" + state));
+ }
+ return chip;
+ }
+
private static Label reasonLabel(String reason, String color) {
Label detail = new Label(reason);
TaskStyleUtils.fontNormal(detail, 11);
diff --git a/src/main/java/com/studysync/presentation/ui/components/StudyPlannerPanel.java b/src/main/java/com/studysync/presentation/ui/components/StudyPlannerPanel.java
index 4c1dc93..4607294 100644
--- a/src/main/java/com/studysync/presentation/ui/components/StudyPlannerPanel.java
+++ b/src/main/java/com/studysync/presentation/ui/components/StudyPlannerPanel.java
@@ -77,6 +77,7 @@ private enum PlannerTaskView {
// UI containers that get rebuilt on navigation
private VBox tasksContainer;
private FlowPane attemptOverviewContainer;
+ private FlowPane scheduledContainer;
private VBox goalsContainer;
private VBox needsPlanContainer;
private Label needsPlanHint;
@@ -236,6 +237,10 @@ private void createHeader(VBox mainContent) {
navRow.getChildren().addAll(prevBtn, dateNavLabel, nextBtn, todayBtn);
+ scheduledContainer = new FlowPane(8, 6);
+ scheduledContainer.setAlignment(Pos.CENTER);
+ scheduledContainer.getStyleClass().add("plan-scheduled");
+
dailyProgressBar = new ProgressBar(0);
dailyProgressBar.setPrefWidth(300);
dailyProgressBar.getStyleClass().add("planner-progress");
@@ -250,7 +255,7 @@ private void createHeader(VBox mainContent) {
TaskStyleUtils.textColor(plannerError, TaskStyleUtils.COLOR_DANGER);
plannerError.setVisible(false);
plannerError.setManaged(false);
- header.getChildren().addAll(navRow, dailyProgressBar, progressLabel, attemptOverviewContainer, plannerError);
+ header.getChildren().addAll(navRow, scheduledContainer, dailyProgressBar, progressLabel, attemptOverviewContainer, plannerError);
mainContent.getChildren().add(header);
}
@@ -528,10 +533,59 @@ private void updateTasksDisplay() {
if (needsPlanContainer.getChildren().isEmpty()) {
needsPlanContainer.getChildren().add(mutedLabel("No tasks to plan for this day."));
}
+ updateScheduled(today, taskHint);
updateTaskBrowser();
syncSessionControls();
}
+ /**
+ * The recurring tasks scheduled on the selected day, under the date: each one's chip says
+ * whether that day's occurrence is planned, done or still open; the rules are in
+ * {@link PlannerAgenda#scheduled}. An active task's chip plans it like its Needs a plan tile.
+ */
+ private void updateScheduled(LocalDate today, String hint) {
+ List scheduled = PlannerAgenda.scheduled(displayDate, today,
+ new ArrayList<>(tasksById.values()), displayDateAttempts, taskService::isRecurringOccurrence);
+ Label heading = new Label((displayDate.equals(today) ? "Scheduled today" : "Scheduled") + ":");
+ heading.setGraphic(TaskStyleUtils.iconLabel("\uD83D\uDD01", 13));
+ TaskStyleUtils.fontBold(heading, 13);
+ scheduledContainer.getChildren().setAll(heading);
+ if (scheduled.isEmpty()) {
+ Label none = mutedLabel("no recurring tasks");
+ scheduledContainer.getChildren().add(none);
+ return;
+ }
+ for (PlannerAgenda.Scheduled item : scheduled) {
+ Task task = item.task();
+ PlannerAgenda.Entry entry = planningEntries.get(item.key());
+ Runnable plan = !CalendarAgenda.isActive(task.getStatus()) ? null
+ : entry != null ? () -> planEntry(entry) : () -> showAddGoalDialog(task);
+ scheduledContainer.getChildren().add(PlanningRow.chip(scheduledMark(item.state()), item.title(),
+ item.detail(), scheduledColor(item.state()), hint, entry != null ? PLAN_DRAG : null,
+ item.key(), plan));
+ }
+ }
+
+ private static String scheduledMark(PlannerAgenda.Occurrence state) {
+ return switch (state) {
+ case NOT_PLANNED -> "\u25CB";
+ case PLANNED -> "\u25D0";
+ case DONE -> "\u2713";
+ case MISSED -> "\u2717";
+ case DROPPED -> "\u2013";
+ };
+ }
+
+ private static String scheduledColor(PlannerAgenda.Occurrence state) {
+ return switch (state) {
+ case NOT_PLANNED -> TaskStyleUtils.DUE_TODAY_COLOR;
+ case PLANNED -> TaskStyleUtils.COLOR_PRIMARY;
+ case DONE -> TaskStyleUtils.COLOR_SUCCESS;
+ case MISSED -> TaskStyleUtils.COLOR_DANGER;
+ case DROPPED -> TaskStyleUtils.COLOR_MUTED;
+ };
+ }
+
/** A grid of same-size planning tiles; it wraps to the column's width. */
private static TilePane planTiles() {
TilePane tiles = new TilePane(8, 8);
diff --git a/src/main/resources/styles.css b/src/main/resources/styles.css
index 2e36a09..7132458 100644
--- a/src/main/resources/styles.css
+++ b/src/main/resources/styles.css
@@ -809,6 +809,26 @@
-fx-padding: 8 10 8 10;
}
+/* The day's scheduled recurring tasks, under the planner's date */
+.plan-scheduled {
+ -fx-padding: 2 0 2 0;
+}
+
+.plan-scheduled-chip {
+ -fx-background-color: white;
+ -fx-background-radius: 14;
+ -fx-border-color: #dfe4ea;
+ -fx-border-radius: 14;
+ -fx-padding: 4 10 4 10;
+}
+
+.cal-plan-item.plan-scheduled-chip,
+.cal-plan-item.plan-scheduled-chip:focused {
+ -fx-background-radius: 14;
+ -fx-border-radius: 14;
+ -fx-padding: 4 10 4 10;
+}
+
/* Planning tiles (Needs a plan): size comes from their TilePane */
.plan-tile {
-fx-padding: 8 10 8 10;
diff --git a/src/test/java/com/studysync/presentation/ui/components/PlannerAgendaTest.java b/src/test/java/com/studysync/presentation/ui/components/PlannerAgendaTest.java
index 7f66927..5300d8f 100644
--- a/src/test/java/com/studysync/presentation/ui/components/PlannerAgendaTest.java
+++ b/src/test/java/com/studysync/presentation/ui/components/PlannerAgendaTest.java
@@ -9,7 +9,9 @@
import com.studysync.presentation.ui.components.PlannerAgenda.Block;
import com.studysync.presentation.ui.components.PlannerAgenda.Entry;
import com.studysync.presentation.ui.components.PlannerAgenda.Kind;
+import com.studysync.presentation.ui.components.PlannerAgenda.Occurrence;
import com.studysync.presentation.ui.components.PlannerAgenda.Plan;
+import com.studysync.presentation.ui.components.PlannerAgenda.Scheduled;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
@@ -495,6 +497,126 @@ void standaloneTasksKeepTheUrgencyOrderAndTasklessRetriesStayApart() {
assertEquals(1, plan.taskless().size());
}
+ @Test
+ void scheduledListsTheRecurringTasksWhoseScheduleLandsOnTheDay() {
+ Task tuesday = recurring("Gym", "1:2");
+ Task wednesday = recurring("Spanish", "1:3");
+ Task due = task("Essay", TODAY);
+ List tasks = List.of(tuesday, wednesday, due);
+ assertEquals(List.of(tuesday), scheduledTasks(TODAY, tasks, List.of()));
+ assertEquals(List.of(wednesday), scheduledTasks(TODAY.plusDays(1), tasks, List.of()));
+ assertEquals(List.of(tuesday), scheduledTasks(TODAY.plusWeeks(1), tasks, List.of()));
+ Scheduled gym = scheduled(TODAY, tasks, List.of()).getFirst();
+ assertEquals(Occurrence.NOT_PLANNED, gym.state());
+ assertEquals("not planned", gym.detail());
+ assertEquals("task:" + tuesday.getId(), gym.key());
+ }
+
+ @Test
+ void scheduledFollowsTheScheduleRatherThanDeadlinesOrLateness() {
+ Task late = recurring("Gym", "1:2");
+ late.setDeadline(TODAY.minusDays(3));
+ late.setStatus(TaskStatus.DELAYED);
+ assertEquals(List.of(late), scheduledTasks(TODAY, List.of(late), List.of()));
+ assertTrue(scheduled(TODAY.plusDays(1), List.of(late), List.of()).isEmpty(),
+ "a late task is in Needs a plan every day, but scheduled only on its schedule");
+ late.setRecurrenceEndDate(TODAY.minusDays(1));
+ assertTrue(scheduled(TODAY, List.of(late), List.of()).isEmpty());
+ }
+
+ @Test
+ void anOccurrenceIsJudgedByItsOwnGoalsDatedThatDay() {
+ Task task = recurring("Gym", "1:2");
+ StudyGoal pending = goal("Legs", task, TODAY);
+ StudyGoal achieved = goal("Arms", task, TODAY);
+ achieved.setAchieved(true);
+ StudyGoal missed = missed("Run", task, TODAY);
+ assertState(task, List.of(pending), Occurrence.PLANNED, "planned");
+ assertState(task, List.of(pending, goal("Core", task, TODAY)), Occurrence.PLANNED, "2 goals planned");
+ assertState(task, List.of(pending, achieved), Occurrence.PLANNED, "1 of 2 done");
+ assertState(task, List.of(achieved), Occurrence.DONE, "done");
+ assertState(task, List.of(achieved, missed), Occurrence.DONE, "1 of 2 done");
+ assertState(task, List.of(missed), Occurrence.MISSED, "goal missed");
+ assertState(task, List.of(goal("Earlier", task, TODAY.minusWeeks(1))), Occurrence.NOT_PLANNED, "not planned");
+
+ Task child = child("Warm-up", task, null);
+ assertEquals(Occurrence.NOT_PLANNED, scheduled(TODAY, List.of(task, child),
+ List.of(goal("Stretch", child, TODAY))).getFirst().state(),
+ "a subtask's goal is not the parent's occurrence");
+
+ StudyGoal dropped = mock(StudyGoal.class);
+ when(dropped.getTaskId()).thenReturn(task.getId());
+ when(dropped.getDate()).thenReturn(TODAY);
+ when(dropped.isDroppedAttempt()).thenReturn(true);
+ when(dropped.getAttemptOutcome()).thenReturn(StudyGoal.AttemptOutcome.DROPPED);
+ assertState(task, List.of(dropped), Occurrence.DROPPED, "dropped");
+ assertState(task, List.of(dropped, pending), Occurrence.PLANNED, "planned");
+ }
+
+ @Test
+ void aGoalMissedAndRetriedTheSameDayCountsOnceByItsBestAttempt() {
+ Task task = recurring("Gym", "1:2");
+ StudyGoal missed = new StudyGoal("legs", TODAY, "Legs", false, null, 0, false, 0, task.getId());
+ missed.setFailed(true);
+ StudyGoal retry = new StudyGoal("legs", TODAY, "Legs", false, null, 0, false, 0, task.getId());
+ assertState(task, List.of(retry, missed), Occurrence.PLANNED, "planned");
+ retry.setAchieved(true);
+ assertState(task, List.of(retry, missed), Occurrence.DONE, "done");
+ assertState(task, List.of(missed, retry), Occurrence.DONE, "done");
+ }
+
+ @Test
+ void aPastOccurrenceWithoutAGoalIsNotDone() {
+ Task task = recurring("Gym", "1:2");
+ Scheduled past = scheduled(TODAY.minusWeeks(1), List.of(task), List.of()).getFirst();
+ assertEquals(Occurrence.MISSED, past.state());
+ assertEquals("not done", past.detail());
+ }
+
+ @Test
+ void anInactiveTaskIsScheduledOnlyWhereItHasAGoalThatDay() {
+ Task task = recurring("Gym", "1:2");
+ for (TaskStatus status : List.of(TaskStatus.COMPLETED, TaskStatus.CANCELLED, TaskStatus.POSTPONED)) {
+ task.setStatus(status);
+ assertTrue(scheduled(TODAY, List.of(task), List.of()).isEmpty());
+ }
+ task.setStatus(TaskStatus.COMPLETED);
+ StudyGoal achieved = goal("Legs", task, TODAY.minusWeeks(1));
+ achieved.setAchieved(true);
+ Scheduled past = scheduled(TODAY.minusWeeks(1), List.of(task), List.of(achieved)).getFirst();
+ assertEquals(Occurrence.DONE, past.state());
+ }
+
+ @Test
+ void scheduledPutsOpenWorkFirstThenOrdersByTitleAndShowsTheParent() {
+ Task parent = task("Course", null);
+ Task done = recurring("Anki", "1:2");
+ Task planned = recurring("Reading", "1:2");
+ Task open = recurring("Zumba", "1:2");
+ Task alsoOpen = recurring("Drills", "1:2");
+ alsoOpen.setParentId(parent.getId());
+ StudyGoal achieved = goal("Deck", done, TODAY);
+ achieved.setAchieved(true);
+ List scheduled = scheduled(TODAY, List.of(parent, done, planned, open, alsoOpen),
+ List.of(achieved, goal("Chapter", planned, TODAY)));
+ assertEquals(List.of("Course / Drills", "Zumba", "Reading", "Anki"),
+ scheduled.stream().map(Scheduled::title).toList());
+ }
+
+ private void assertState(Task task, List attempts, Occurrence state, String detail) {
+ Scheduled scheduled = scheduled(TODAY, List.of(task), attempts).getFirst();
+ assertEquals(state, scheduled.state());
+ assertEquals(detail, scheduled.detail());
+ }
+
+ private List scheduled(LocalDate date, List tasks, List attempts) {
+ return PlannerAgenda.scheduled(date, TODAY, tasks, attempts, recurrence::isRecurringOccurrence);
+ }
+
+ private List scheduledTasks(LocalDate date, List tasks, List attempts) {
+ return scheduled(date, tasks, attempts).stream().map(Scheduled::task).toList();
+ }
+
private List build(LocalDate date, List tasks, List attempts,
List retries, List missed) {
return PlannerAgenda.build(date, TODAY, tasks, attempts, retries, missed, recurrence::isRecurringOccurrence)
diff --git a/src/test/java/com/studysync/presentation/ui/components/StudyPlannerPanelTest.java b/src/test/java/com/studysync/presentation/ui/components/StudyPlannerPanelTest.java
index 79a622f..635ff1d 100644
--- a/src/test/java/com/studysync/presentation/ui/components/StudyPlannerPanelTest.java
+++ b/src/test/java/com/studysync/presentation/ui/components/StudyPlannerPanelTest.java
@@ -938,6 +938,79 @@ void droppedGoalsAreCollapsedAndCanReopenWithoutOfferingCompletionOrASession() t
});
}
+ @Test
+ void theDaysScheduledRecurringTasksShowUnderTheDateAndPlanFromTheirChip() throws Exception {
+ onFx(() -> {
+ try (Fixture fixture = new Fixture()) {
+ fixture.attempts.clear();
+ Task gym = Fixture.task("gym", "Gym", null);
+ gym.setRecurringPattern("1:2");
+ Task anki = Fixture.task("anki", "Anki", null);
+ anki.setRecurringPattern("1:2");
+ StudyGoal deck = fixture.goal("Review deck", anki, TODAY);
+ deck.setAchieved(true);
+ fixture.attempts.add(deck);
+ when(fixture.tasks.getTasks()).thenReturn(List.of(fixture.parent, fixture.child, gym, anki));
+ when(fixture.tasks.isRecurringOccurrence(any(), eq(TODAY)))
+ .thenAnswer(invocation -> ((Task) invocation.getArgument(0)).isRecurring());
+ StudyPlannerPanel panel = fixture.panel();
+ Scene scene = new Scene(panel, 1100, 800);
+ scene.getStylesheets().add(getClass().getResource("/styles.css").toExternalForm());
+ panel.applyCss();
+ panel.layout();
+
+ Node strip = nodes(panel).stream().filter(node -> node.getStyleClass().contains("plan-scheduled"))
+ .findFirst().orElseThrow();
+ GoalActionsTest.snapshot(panel, "planner-scheduled.png");
+ List texts = labelTexts(strip);
+ assertEquals(List.of("Scheduled today:", "\u25CB", "Gym", "not planned", "\u2713", "Anki", "done"),
+ texts.stream().filter(text -> !text.equals("\uD83D\uDD01")).toList());
+ Node chip = planningTile(strip, "Gym");
+ assertNotNull(chip.getOnDragDetected());
+ chip.fireEvent(new KeyEvent(KeyEvent.KEY_PRESSED, "", "", KeyCode.ENTER,
+ false, false, false, false));
+ assertTrue(labelTexts(fixture.modal).contains("Add goal for: Gym"));
+ }
+ });
+ }
+
+ @Test
+ void aLongScheduledTitleIsCutShortSoTheChipCannotWidenThePlanner() throws Exception {
+ onFx(() -> {
+ try (Fixture fixture = new Fixture()) {
+ String title = "Revise every lecture of the advanced distributed systems course ".repeat(3).trim();
+ Task task = Fixture.task("long", title, null);
+ task.setRecurringPattern("1:2");
+ when(fixture.tasks.getTasks()).thenReturn(List.of(fixture.parent, fixture.child, task));
+ when(fixture.tasks.isRecurringOccurrence(any(), eq(TODAY)))
+ .thenAnswer(invocation -> ((Task) invocation.getArgument(0)).isRecurring());
+ StudyPlannerPanel panel = fixture.panel();
+ Scene scene = new Scene(panel, 900, 700);
+ scene.getStylesheets().add(getClass().getResource("/styles.css").toExternalForm());
+ panel.applyCss();
+ panel.layout();
+ Node strip = nodes(panel).stream().filter(node -> node.getStyleClass().contains("plan-scheduled"))
+ .findFirst().orElseThrow();
+ Node chip = planningTile(strip, title);
+ assertTrue(chip.prefWidth(-1) < 400, "chip width " + chip.prefWidth(-1));
+ assertTrue(strip.minWidth(-1) < 600, "row min width " + strip.minWidth(-1));
+ assertTrue(chip.getAccessibleText().startsWith(title));
+ }
+ });
+ }
+
+ @Test
+ void aDayWithNothingScheduledSaysSo() throws Exception {
+ onFx(() -> {
+ try (Fixture fixture = new Fixture()) {
+ StudyPlannerPanel panel = fixture.panel();
+ Node strip = nodes(panel).stream().filter(node -> node.getStyleClass().contains("plan-scheduled"))
+ .findFirst().orElseThrow();
+ assertTrue(labelTexts(strip).contains("no recurring tasks"));
+ }
+ });
+ }
+
private static void onFx(Runnable action) throws Exception {
FutureTask task = new FutureTask<>(() -> {
action.run();