Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ StudySync provides comprehensive academic management with three main modules:
* **Task Management**: Create, edit, and delete tasks with rich attributes (title, description, category, priority, deadline, status, recurring schedule)
* **Postponed work**: Postponed tasks stay paused across restarts, regardless of their deadline. Use **Set resume date** to schedule their return. Clearing the resume date keeps the task paused; change the task’s status to resume it manually. The due date stays separate. Existing postponed tasks have no automatic return date until you explicitly choose one.
* **Recurring Tasks**: Give any task its own repeating schedule and optional "repeat until" date, separate from its deadline. Parents and subtasks can each be one-off or recurring; schedules are never inherited. Completing a recurring task permanently ends its future occurrences; handling one occurrence through a goal does not.
* **Subtasks**: Use **Add subtask** on a task, or choose a parent in the edit form. One level of nesting is supported. Progress counts completed subtasks, ignoring cancelled ones. Parents and subtasks follow their own dates in the planner and calendar.
* **Subtasks**: Use **Add subtask** on a task, or choose a parent in the edit form. One level of nesting is supported. Progress counts completed subtasks, ignoring cancelled ones. Parents and subtasks follow their own dates in the planner and calendar. Planning a goal for a parent lets you choose one of its active subtasks under **For**.
* **Estimates**: Enter optional estimated hours per task. A parent shows its subtasks' total (ignoring cancelled ones) alongside its own budget, for example **Estimate: 12 h from subtasks · 40 h planned**. The two figures stay independent.
* **Completion**: Finishing the last unfinished subtask completes a one-off parent on the same date. Recurring parents show progress but never auto-complete. Marking any parent done also completes its unfinished subtasks after confirming their count and how many recurring subtasks will stop repeating. Cancelled subtasks stay cancelled. Adding or reopening unfinished work reopens a completed parent.
* **Safe deletion**: Deleting a parent also deletes all its subtasks after confirming their count. Notes, links and reschedule history are deleted; goals and study sessions remain with their task links cleared. **Delete matching tasks** applies the current filters and confirms the total and the subtasks deleted with their parents, including children outside those filters.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1771,35 +1771,50 @@ public void updateItem(LocalDate item, boolean empty) {
TextArea criteriaArea = TaskStyleUtils.criteriaArea(null);
Label criteriaLabel = new Label(TaskStyleUtils.CRITERIA_LABEL);

// Task selector (only when no task pre-linked)
// The goal's task: any active task when none is pre-linked; otherwise the linked task,
// or one of its active subtasks, so a parent's planning tile can plan a subtask's work.
ComboBox<Task> taskCombo = new ComboBox<>();
VBox taskSection = new VBox(4);
taskCombo.setMaxWidth(Double.MAX_VALUE);
taskCombo.setCellFactory(lv -> new ListCell<>() {
@Override
protected void updateItem(Task item, boolean empty) {
super.updateItem(item, empty);
setText(empty || item == null ? "None" : taskTitle(item));
}
});
taskCombo.setButtonCell(new ListCell<>() {
@Override
protected void updateItem(Task item, boolean empty) {
super.updateItem(item, empty);
setText(empty || item == null ? "None" : taskTitle(item));
}
});
if (linkedTask == null) {
Label taskLabel = new Label("Link to task (optional):");
TaskStyleUtils.fontBold(taskLabel, 12);
taskCombo.setPromptText("None");
taskCombo.setMaxWidth(Double.MAX_VALUE);
taskCombo.getItems().add(null);
taskCombo.getItems().addAll(taskService.getActiveTasks());
taskCombo.setCellFactory(lv -> new ListCell<>() {
@Override
protected void updateItem(Task item, boolean empty) {
super.updateItem(item, empty);
setText(empty || item == null ? "None" : taskTitle(item));
}
});
taskCombo.setButtonCell(new ListCell<>() {
@Override
protected void updateItem(Task item, boolean empty) {
super.updateItem(item, empty);
setText(empty || item == null ? "None" : taskTitle(item));
}
});
taskSection.getChildren().addAll(taskLabel, taskCombo);
content.getChildren().addAll(headerLabel, dateRow,
new Label("Description:"), descArea, criteriaLabel, criteriaArea, taskSection);
content.getChildren().addAll(headerLabel, dateRow, new Label("Description:"), descArea,
criteriaLabel, criteriaArea, new VBox(4, taskLabel, taskCombo));
} else {
content.getChildren().addAll(headerLabel, dateRow,
List<Task> subtasks = tasksById.values().stream()
.filter(task -> linkedTask.getId().equals(task.getParentId()) && isActivePlannerTask(task))
.sorted(TaskListAgenda.TASK_ORDER).toList();
taskCombo.getItems().add(linkedTask);
taskCombo.getItems().addAll(subtasks);
taskCombo.setValue(linkedTask);
content.getChildren().add(headerLabel);
if (!subtasks.isEmpty()) {
taskCombo.setAccessibleText("Plan this goal for");
taskCombo.valueProperty().addListener((obs, old, task) ->
headerLabel.setText("Add goal for: " + taskTitle(task)));
HBox forRow = new HBox(8, new Label("For:"), taskCombo);
forRow.setAlignment(Pos.CENTER_LEFT);
HBox.setHgrow(taskCombo, Priority.ALWAYS);
content.getChildren().add(forRow);
}
content.getChildren().addAll(dateRow,
new Label("Description:"), descArea, criteriaLabel, criteriaArea);
}

Expand All @@ -1816,8 +1831,7 @@ protected void updateItem(Task item, boolean empty) {
String desc = descArea.getText().trim();
if (desc.isEmpty()) return;
LocalDate date = datePicker.getValue() != null ? datePicker.getValue() : displayDate;
String taskId = linkedTask != null ? linkedTask.getId()
: (taskCombo.getValue() != null ? taskCombo.getValue().getId() : null);
String taskId = taskCombo.getValue() != null ? taskCombo.getValue().getId() : null;
try {
studyService.addStudyGoal(desc, date, taskId, criteriaArea.getText());
} catch (Exception ex) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ List<Task> tasks() {
}
}

private static final Comparator<Task> TASK_ORDER = Comparator
static final Comparator<Task> TASK_ORDER = Comparator
.comparing(Task::getDeadline, Comparator.nullsLast(Comparator.naturalOrder()))
.thenComparingInt(task -> task.getPriority() == null ? 0 : -task.getPriority().stars())
.thenComparing(Task::getTitle, Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,43 @@ void planningAGoalKeepsTheTaskAvailableToPlanASecondGoal() throws Exception {
});
}

@Test
void planningAParentCanPlanTheGoalForOneOfItsActiveSubtasks() throws Exception {
onFx(() -> {
try (Fixture fixture = new Fixture()) {
fixture.attempts.clear();
fixture.parent.setDeadline(TODAY.minusDays(2));
Task later = Fixture.task("later", "Later subtask", TODAY.plusDays(4));
later.setParentId(fixture.parent.getId());
Task done = Fixture.task("done", "Done subtask", TODAY.plusDays(1));
done.setParentId(fixture.parent.getId());
done.setStatus(TaskStatus.COMPLETED);
when(fixture.tasks.getTasks()).thenReturn(List.of(fixture.parent, later, done, fixture.child));
StudyPlannerPanel panel = fixture.panel();

planningTile(panel, "Parent").fireEvent(new KeyEvent(KeyEvent.KEY_PRESSED,
"", "", KeyCode.ENTER, false, false, false, false));
ComboBox<?> target = nodes(fixture.modal).stream().filter(ComboBox.class::isInstance)
.map(node -> (ComboBox<?>) node).findFirst().orElseThrow();
assertEquals(List.of(fixture.parent, fixture.child, later), target.getItems());
assertEquals(fixture.parent, target.getValue());
assertTrue(labelTexts(fixture.modal).contains("Add goal for: Parent"));

target.getSelectionModel().select(target.getItems().indexOf(fixture.child));
assertTrue(labelTexts(fixture.modal).contains("Add goal for: Parent / Subtask"));
nodes(fixture.modal).stream().filter(TextArea.class::isInstance).map(TextArea.class::cast)
.findFirst().orElseThrow().setText("Solve problem 1");
button(fixture.modal, "Add Goal").fire();
verify(fixture.study).addStudyGoal("Solve problem 1", TODAY, fixture.child.getId(), "");

planningTile(panel, "Parent / Subtask").fireEvent(new KeyEvent(KeyEvent.KEY_PRESSED,
"", "", KeyCode.ENTER, false, false, false, false));
assertTrue(nodes(fixture.modal).stream().noneMatch(ComboBox.class::isInstance),
"a subtask has no subtasks to choose from");
}
});
}

@Test
void planningOneRetryLeavesTheOtherRetryAndNewGoalActionAvailable() throws Exception {
onFx(() -> {
Expand Down
Loading