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
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()}.</p>
*
* <p>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.</p>
*/
final class PlannerAgenda {

Expand Down Expand Up @@ -90,6 +94,32 @@ List<Entry> tiles() {
record Plan(List<Entry> entries, List<Entry> taskless, List<Block> needed, List<Block> 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;
Expand Down Expand Up @@ -216,6 +246,64 @@ private static void addUnplannedSubtasks(LocalDate date, List<Entry> 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> scheduled(LocalDate date, LocalDate today, List<Task> tasks,
List<StudyGoal> attempts, BiPredicate<Task, LocalDate> isOccurrence) {
Map<String, Task> tasksById = new LinkedHashMap<>();
tasks.forEach(task -> tasksById.putIfAbsent(task.getId(), task));
Map<String, List<StudyGoal>> goalsByTask = attempts.stream()
.filter(goal -> date.equals(goal.getDate()) && goal.getTaskId() != null)
.collect(Collectors.groupingBy(StudyGoal::getTaskId));
List<Scheduled> scheduled = new ArrayList<>();
for (Task task : tasksById.values()) {
List<StudyGoal> 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<StudyGoal> 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<String, StudyGoal> 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<StudyGoal> 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");
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
}
Expand Down Expand Up @@ -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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");
Expand All @@ -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);
}

Expand Down Expand Up @@ -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<PlannerAgenda.Scheduled> 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);
Expand Down
20 changes: 20 additions & 0 deletions src/main/resources/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading