From 91afa2fb6ba834d58610576532dc606990f3c01c Mon Sep 17 00:00:00 2001 From: geokoko Date: Thu, 9 Jul 2026 23:48:02 +0300 Subject: [PATCH 1/3] Allow new goals alongside retries, task links in calendar planning, and session-goal linking - Planner: the "+ Create Goal" button now renders even when a task only has missed goals waiting for retry, instead of being replaced by the retry section (issue #31) - Calendar: the "Plan ahead for this day" dialog gets an optional task selector and passes the chosen task to addStudyGoal, so future-day goals can be linked to tasks (issue #30) - Sessions: study_sessions gains optional goal_id/task_id columns (FKs added idempotently in the migration section, ON DELETE SET NULL), a "Link to (optional)" picker next to Start Session offering today's open goals and tasks, and a muted link label on session cards (issue #17) Closes #31 Closes #30 Closes #17 Co-Authored-By: Claude Fable 5 --- .../studysync/domain/entity/StudySession.java | 21 +++- .../domain/service/StudyService.java | 13 +++ .../ui/components/CalendarViewPanel.java | 30 ++++- .../ui/components/StudyPlannerPanel.java | 109 ++++++++++++++++-- src/main/resources/schema.sql | 13 +++ .../service/StudyServicePersistenceTest.java | 17 +++ 6 files changed, 185 insertions(+), 18 deletions(-) diff --git a/src/main/java/com/studysync/domain/entity/StudySession.java b/src/main/java/com/studysync/domain/entity/StudySession.java index d7b344c..51ee0f3 100644 --- a/src/main/java/com/studysync/domain/entity/StudySession.java +++ b/src/main/java/com/studysync/domain/entity/StudySession.java @@ -42,7 +42,11 @@ public class StudySession { private String improvementNote; private int pointsEarned; private String sessionText; - + + /** Optional links to the goal/task this session works on. */ + private String goalId; + private String taskId; + // Real-time tracking fields private boolean isActive; private LocalDateTime lastUpdateTime; @@ -121,9 +125,9 @@ public void save() { MERGE INTO study_sessions (id, date, start_time, end_time, duration_minutes, completed, focus_level, confidence_level, notes, subject, topic, location, outcome_expected, actual_work, what_helped, what_distracted, - improvement_note, points_earned, session_text, is_active, - last_update_time, current_elapsed_minutes, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + improvement_note, points_earned, session_text, goal_id, task_id, + is_active, last_update_time, current_elapsed_minutes, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) """; jdbcTemplate.update(sql, this.id, this.date, this.startTime, this.endTime, @@ -132,6 +136,7 @@ MERGE INTO study_sessions (id, date, start_time, end_time, duration_minutes, com this.topic, this.location, this.outcomeExpected, this.actualWork, this.whatHelped, this.whatDistracted, this.improvementNote, this.pointsEarned, this.sessionText, + this.goalId, this.taskId, this.isActive, this.lastUpdateTime, this.currentElapsedMinutes ); @@ -303,6 +308,8 @@ private static RowMapper getRowMapper() { rs.getInt("points_earned"), rs.getString("session_text") ); + session.setGoalId(rs.getString("goal_id")); + session.setTaskId(rs.getString("task_id")); session.setActive(rs.getBoolean("is_active")); session.setLastUpdateTime(rs.getObject("last_update_time", LocalDateTime.class)); session.setCurrentElapsedMinutes(rs.getInt("current_elapsed_minutes")); @@ -522,6 +529,12 @@ public void setSessionText(String sessionText) { this.lastUpdateTime = LocalDateTime.now(); } + public String getGoalId() { return goalId; } + public void setGoalId(String goalId) { this.goalId = goalId; } + + public String getTaskId() { return taskId; } + public void setTaskId(String taskId) { this.taskId = taskId; } + public boolean isActive() { return isActive; } public void setActive(boolean isActive) { this.isActive = isActive; } diff --git a/src/main/java/com/studysync/domain/service/StudyService.java b/src/main/java/com/studysync/domain/service/StudyService.java index bd41ab2..57a12e9 100644 --- a/src/main/java/com/studysync/domain/service/StudyService.java +++ b/src/main/java/com/studysync/domain/service/StudyService.java @@ -360,7 +360,20 @@ public boolean deleteStudyGoal(String goalId) { } public StudySession startStudySession() { + return startStudySession(null, null); + } + + /** + * Starts a study session optionally linked to a goal and/or task (issue #17). + * + * @param goalId optional ID of the study goal this session works on + * @param taskId optional ID of the task this session works on + * @return the started session + */ + public StudySession startStudySession(String goalId, String taskId) { StudySession session = new StudySession(); + session.setGoalId(goalId); + session.setTaskId(taskId); session.startSession(); session.save(); markDirtyAndSaveLocally("study session start"); diff --git a/src/main/java/com/studysync/presentation/ui/components/CalendarViewPanel.java b/src/main/java/com/studysync/presentation/ui/components/CalendarViewPanel.java index 04fd029..15136bc 100644 --- a/src/main/java/com/studysync/presentation/ui/components/CalendarViewPanel.java +++ b/src/main/java/com/studysync/presentation/ui/components/CalendarViewPanel.java @@ -1284,8 +1284,30 @@ private void showAddGoalDialog(LocalDate date) { goalTextArea.setPromptText("e.g., Complete Chapter 5, Practice 10 problems, Review notes from class..."); goalTextArea.setPrefRowCount(3); goalTextArea.setWrapText(true); - - content.getChildren().addAll(instructionLabel, goalTextArea); + + Label taskLabel = new Label("Link to task (optional):"); + TaskStyleUtils.fontBold(taskLabel, 12); + ComboBox taskCombo = new ComboBox<>(); + 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" : item.getTitle()); + } + }); + taskCombo.setButtonCell(new ListCell<>() { + @Override + protected void updateItem(Task item, boolean empty) { + super.updateItem(item, empty); + setText(empty || item == null ? "None" : item.getTitle()); + } + }); + + content.getChildren().addAll(instructionLabel, goalTextArea, taskLabel, taskCombo); dialogPane.setContent(content); // Enable/disable OK button based on input @@ -1305,7 +1327,9 @@ private void showAddGoalDialog(LocalDate date) { dialog.showAndWait().ifPresent(goalDescription -> { if (!goalDescription.isEmpty()) { try { - studyService.addStudyGoal(goalDescription, date); + Task linkedTask = taskCombo.getValue(); + studyService.addStudyGoal(goalDescription, date, + linkedTask != null ? linkedTask.getId() : null); updateCalendarDisplay(); // Show confirmation 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 8222de9..ae97d9a 100644 --- a/src/main/java/com/studysync/presentation/ui/components/StudyPlannerPanel.java +++ b/src/main/java/com/studysync/presentation/ui/components/StudyPlannerPanel.java @@ -294,8 +294,19 @@ private void createSessionSection(VBox mainContent) { sessionStatusLabel = new Label("No active session"); TaskStyleUtils.fontNormal(sessionStatusLabel, 14); + // Optional goal/task link for the session (issue #17) + ComboBox linkCombo = new ComboBox<>(); + linkCombo.setPromptText("Link to (optional)"); + linkCombo.setCellFactory(lv -> sessionLinkCell()); + linkCombo.setButtonCell(sessionLinkCell()); + linkCombo.setOnShowing(e -> populateSessionLinkCombo(linkCombo)); + startSessionBtn.setOnAction(e -> { - currentSession = studyService.startStudySession(); + Object link = linkCombo.getValue(); + String goalId = link instanceof StudyGoal goal ? goal.getId() : null; + String taskId = link instanceof StudyGoal goal ? goal.getTaskId() + : link instanceof Task task ? task.getId() : null; + currentSession = studyService.startStudySession(goalId, taskId); sessionTextArea.clear(); startSessionTimer(); syncSessionControls(); @@ -309,7 +320,7 @@ private void createSessionSection(VBox mainContent) { } }); - sessionControls.getChildren().addAll(startSessionBtn, endSessionBtn, sessionStatusLabel); + sessionControls.getChildren().addAll(startSessionBtn, linkCombo, endSessionBtn, sessionStatusLabel); sessionTextArea = new TextArea(); sessionTextArea.setPromptText("Write your session notes, thoughts, or study content here…"); @@ -847,16 +858,18 @@ private void populateGoalsPanel(VBox goalsPanel, Task task) { Label noGoals = new Label("No goals linked to this task for this date."); TaskStyleUtils.fontNormal(noGoals, 12); noGoals.setTextFill(Color.web("#7f8c8d")); - - Button addGoalBtn = new Button("+ Create Goal"); - addGoalBtn.getStyleClass().addAll("btn-purple", "btn-small"); - addGoalBtn.setOnAction(e -> { - showAddGoalDialog(task); - populateGoalsPanel(goalsPanel, task); // refresh after add - }); - - goalsPanel.getChildren().addAll(noGoals, addGoalBtn); + goalsPanel.getChildren().add(noGoals); } + + // A new goal can always be created, even when only missed goals + // are waiting for a retry (issue #31). + Button addGoalBtn = new Button("+ Create Goal"); + addGoalBtn.getStyleClass().addAll("btn-purple", "btn-small"); + addGoalBtn.setOnAction(e -> { + showAddGoalDialog(task); + populateGoalsPanel(goalsPanel, task); // refresh after add + }); + goalsPanel.getChildren().add(addGoalBtn); } else { for (StudyGoal goal : activeGoals) { goalsPanel.getChildren().add(buildGoalRow(goal, task)); @@ -1167,9 +1180,79 @@ private VBox buildSessionCard(StudySession session) { btnRow.getChildren().addAll(detailsBtn, deleteBtn); card.getChildren().addAll(timeLabel, durationLabel, focusLabel, pointsLabel, btnRow); + Label linkLabel = buildSessionLinkLabel(session); + if (linkLabel != null) { + card.getChildren().add(card.getChildren().size() - 1, linkLabel); + } return card; } + /** Renders a goal or task entry in the session link ComboBox. */ + private ListCell sessionLinkCell() { + return new ListCell<>() { + @Override + protected void updateItem(Object item, boolean empty) { + super.updateItem(item, empty); + setText(empty ? null : sessionLinkText(item)); + } + }; + } + + private String sessionLinkText(Object item) { + String text; + if (item instanceof StudyGoal goal) { + text = "Goal: " + goal.getDescription(); + } else if (item instanceof Task task) { + text = "Task: " + task.getTitle(); + } else { + return "None"; + } + return text.length() > 60 ? text.substring(0, 57) + "\u2026" : text; + } + + /** Fills the link ComboBox with today's goals, then tasks not already covered by one. */ + private void populateSessionLinkCombo(ComboBox combo) { + Object selected = combo.getValue(); + combo.getItems().clear(); + combo.getItems().add(null); + LocalDate today = dateTimeService.getCurrentDate(); + List goals = studyService.getStudyGoalsForDate(today).stream() + .filter(g -> !g.isAchieved()) + .toList(); + combo.getItems().addAll(goals); + Set linkedTaskIds = goals.stream() + .map(StudyGoal::getTaskId) + .filter(id -> id != null && !id.isBlank()) + .collect(java.util.stream.Collectors.toSet()); + taskService.getTasksForDate(today).stream() + .filter(t -> !linkedTaskIds.contains(t.getId())) + .forEach(combo.getItems()::add); + if (selected != null && combo.getItems().contains(selected)) { + combo.setValue(selected); + } + } + + /** Small muted label describing the session's goal/task link, or null when unlinked. */ + private Label buildSessionLinkLabel(StudySession session) { + String text = null; + if (session.getGoalId() != null) { + text = StudyGoal.findById(session.getGoalId()) + .map(g -> "Goal: " + g.getDescription()).orElse(null); + } + if (text == null && session.getTaskId() != null) { + text = Task.findById(session.getTaskId()) + .map(t -> "Task: " + t.getTitle()).orElse(null); + } + if (text == null) { + return null; + } + Label link = new Label(text.length() > 40 ? text.substring(0, 37) + "\u2026" : text); + link.setGraphic(TaskStyleUtils.iconLabel("\u29BF", 10)); + TaskStyleUtils.fontNormal(link, 11); + link.setTextFill(Color.web("#7f8c8d")); + return link; + } + private VBox buildIncompleteSessionCard(StudySession session) { VBox card = new VBox(5); card.setPadding(new Insets(10, 12, 10, 12)); @@ -1231,6 +1314,10 @@ private VBox buildIncompleteSessionCard(StudySession session) { btnRow.setAlignment(Pos.CENTER_RIGHT); card.getChildren().addAll(statusLabel, timeLabel, durationLabel, btnRow); + Label linkLabel = buildSessionLinkLabel(session); + if (linkLabel != null) { + card.getChildren().add(card.getChildren().size() - 1, linkLabel); + } return card; } diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index b8a4895..44ab044 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -63,11 +63,15 @@ CREATE TABLE IF NOT EXISTS study_sessions ( improvement_note TEXT, points_earned INTEGER DEFAULT 0, session_text TEXT, + goal_id VARCHAR(50), + task_id VARCHAR(50), is_active BOOLEAN DEFAULT FALSE, last_update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, current_elapsed_minutes INTEGER DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + -- goal_id/task_id FKs are added in the migrations section below, after + -- the study_goals table exists. ); -- =================================== @@ -207,6 +211,15 @@ ALTER TABLE study_goals ADD COLUMN IF NOT EXISTS replanned_for_date DATE; -- but excluded from active planner views. ALTER TABLE study_goals ADD COLUMN IF NOT EXISTS failed BOOLEAN DEFAULT FALSE; +-- Link study sessions to an optional goal/task (issue #17). Named constraints +-- keep the ALTERs idempotent for both fresh and migrated databases. +ALTER TABLE study_sessions ADD COLUMN IF NOT EXISTS goal_id VARCHAR(50); +ALTER TABLE study_sessions ADD COLUMN IF NOT EXISTS task_id VARCHAR(50); +ALTER TABLE study_sessions ADD CONSTRAINT IF NOT EXISTS fk_study_sessions_goal + FOREIGN KEY (goal_id) REFERENCES study_goals(id) ON DELETE SET NULL; +ALTER TABLE study_sessions ADD CONSTRAINT IF NOT EXISTS fk_study_sessions_task + FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE SET NULL; + -- Add per-attempt goal lifecycle columns. Legacy columns remain intentionally: -- schema.sql is re-run on every startup, so destructive DROP COLUMN migrations -- would make the compatibility backfill below unsafe on later launches. diff --git a/src/test/java/com/studysync/domain/service/StudyServicePersistenceTest.java b/src/test/java/com/studysync/domain/service/StudyServicePersistenceTest.java index 3bb1fcb..22ebbe6 100644 --- a/src/test/java/com/studysync/domain/service/StudyServicePersistenceTest.java +++ b/src/test/java/com/studysync/domain/service/StudyServicePersistenceTest.java @@ -95,6 +95,21 @@ void startStudySessionPersistsActiveSessionAndFlushesLocally() { verify(googleDriveService).saveLocally(); } + @Test + void startStudySessionPersistsOptionalGoalAndTaskLink() { + StudyGoal goal = new StudyGoal("Linked session goal", "task-1"); + goal.setDate(LocalDate.of(2026, 3, 28)); + goal.save(); + + StudySession session = studyService.startStudySession(goal.getId(), goal.getTaskId()); + + StudySession stored = StudySession.findByDate(session.getDate()).stream() + .filter(s -> s.getId().equals(session.getId())) + .findFirst().orElseThrow(); + assertEquals(goal.getId(), stored.getGoalId()); + assertEquals("task-1", stored.getTaskId()); + } + @Test void endStudySessionUpdatesExistingRowToCompletedAndFlushesLocally() { StudySession session = studyService.startStudySession(); @@ -448,6 +463,8 @@ location VARCHAR(255), improvement_note TEXT, points_earned INTEGER DEFAULT 0, session_text TEXT, + goal_id VARCHAR(50), + task_id VARCHAR(50), is_active BOOLEAN DEFAULT FALSE, last_update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, current_elapsed_minutes INTEGER DEFAULT 0, From 8902f25a66f298f526aa47e77f720e949fdd93ec Mon Sep 17 00:00:00 2001 From: geokoko Date: Fri, 10 Jul 2026 00:10:16 +0300 Subject: [PATCH 2/3] Address review: validate session links against deleted goals/tasks - Re-validate the link selection in the Start Session handler: a goal or task deleted after the combo was populated now degrades to an unlinked (or task-only) session instead of an uncaught FK violation; the combo is cleared after starting so the next session doesn't silently inherit it, and a blank legacy taskId can no longer be written - StudySession.save() drops a link id whose target row was deleted while the session was in memory and retries once, so an active session can always be ended; the surviving link is preserved - Re-select the previous combo choice by id (StudyGoal has no equals), and clear a stale value whose item vanished from the fresh list - Harden the persistence tests: session-table DDL now carries the same FKs as production (tables reordered to satisfy them), the link round-trip test creates a real task row, and a new test covers ending a session whose linked goal was deleted mid-session Co-Authored-By: Claude Fable 5 --- .../studysync/domain/entity/StudySession.java | 34 ++++++++++++++++-- .../ui/components/StudyPlannerPanel.java | 35 ++++++++++++++++--- .../service/StudyServicePersistenceTest.java | 27 ++++++++++++-- 3 files changed, 87 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/studysync/domain/entity/StudySession.java b/src/main/java/com/studysync/domain/entity/StudySession.java index 51ee0f3..51e729f 100644 --- a/src/main/java/com/studysync/domain/entity/StudySession.java +++ b/src/main/java/com/studysync/domain/entity/StudySession.java @@ -1,6 +1,7 @@ package com.studysync.domain.entity; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.RowMapper; import org.slf4j.Logger; @@ -129,6 +130,37 @@ MERGE INTO study_sessions (id, date, start_time, end_time, duration_minutes, com is_active, last_update_time, current_elapsed_minutes, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) """; + try { + executeSaveMerge(sql); + } catch (DataIntegrityViolationException e) { + if (this.goalId == null && this.taskId == null) { + throw e; + } + // A linked goal/task was deleted while this session was held in + // memory (the DB row's FK was SET NULL, but this instance still + // carries the stale id). Drop only the dead link and retry rather + // than fail — otherwise an active session could never be ended. + logger.warn("Session {} link target no longer exists; dropping dead link ({})", + this.id, e.getMessage()); + if (this.goalId != null && !rowExists("study_goals", this.goalId)) { + this.goalId = null; + } + if (this.taskId != null && !rowExists("tasks", this.taskId)) { + this.taskId = null; + } + executeSaveMerge(sql); + } + + logger.debug("Study session saved: {}", this.id); + } + + private static boolean rowExists(String table, String id) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM " + table + " WHERE id = ?", Integer.class, id); + return count != null && count > 0; + } + + private void executeSaveMerge(String sql) { jdbcTemplate.update(sql, this.id, this.date, this.startTime, this.endTime, this.durationMinutes, this.completed, this.focusLevel, @@ -139,8 +171,6 @@ MERGE INTO study_sessions (id, date, start_time, end_time, duration_minutes, com this.goalId, this.taskId, this.isActive, this.lastUpdateTime, this.currentElapsedMinutes ); - - logger.debug("Study session saved: {}", this.id); } /** 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 ae97d9a..9f46dd0 100644 --- a/src/main/java/com/studysync/presentation/ui/components/StudyPlannerPanel.java +++ b/src/main/java/com/studysync/presentation/ui/components/StudyPlannerPanel.java @@ -302,11 +302,24 @@ private void createSessionSection(VBox mainContent) { linkCombo.setOnShowing(e -> populateSessionLinkCombo(linkCombo)); startSessionBtn.setOnAction(e -> { + // Re-validate the selection against the DB: the goal/task may have + // been deleted since the combo was populated, and a stale id would + // violate the session's FK. Fall back to an unlinked session. Object link = linkCombo.getValue(); - String goalId = link instanceof StudyGoal goal ? goal.getId() : null; - String taskId = link instanceof StudyGoal goal ? goal.getTaskId() - : link instanceof Task task ? task.getId() : null; + String goalId = null; + String taskId = null; + if (link instanceof StudyGoal goal) { + goalId = StudyGoal.findById(goal.getId()).map(StudyGoal::getId).orElse(null); + String linkedTaskId = goal.getTaskId(); + if (goalId != null && linkedTaskId != null && !linkedTaskId.isBlank() + && Task.findById(linkedTaskId).isPresent()) { + taskId = linkedTaskId; + } + } else if (link instanceof Task task) { + taskId = Task.findById(task.getId()).map(Task::getId).orElse(null); + } currentSession = studyService.startStudySession(goalId, taskId); + linkCombo.setValue(null); sessionTextArea.clear(); startSessionTimer(); syncSessionControls(); @@ -1227,9 +1240,21 @@ private void populateSessionLinkCombo(ComboBox combo) { taskService.getTasksForDate(today).stream() .filter(t -> !linkedTaskIds.contains(t.getId())) .forEach(combo.getItems()::add); - if (selected != null && combo.getItems().contains(selected)) { - combo.setValue(selected); + // Re-select by id: StudyGoal has no equals(), and the previously + // selected instance is never in the freshly loaded list. + combo.setValue(combo.getItems().stream() + .filter(item -> sameLinkTarget(item, selected)) + .findFirst().orElse(null)); + } + + private static boolean sameLinkTarget(Object item, Object selected) { + if (item instanceof StudyGoal goal && selected instanceof StudyGoal prev) { + return goal.getId().equals(prev.getId()); + } + if (item instanceof Task task && selected instanceof Task prev) { + return task.getId().equals(prev.getId()); } + return false; } /** Small muted label describing the session's goal/task link, or null when unlinked. */ diff --git a/src/test/java/com/studysync/domain/service/StudyServicePersistenceTest.java b/src/test/java/com/studysync/domain/service/StudyServicePersistenceTest.java index 22ebbe6..b998477 100644 --- a/src/test/java/com/studysync/domain/service/StudyServicePersistenceTest.java +++ b/src/test/java/com/studysync/domain/service/StudyServicePersistenceTest.java @@ -21,6 +21,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; @@ -49,9 +50,10 @@ void setUp() { dataSource = new HikariDataSource(config); jdbcTemplate = new JdbcTemplate(dataSource); - createStudySessionsTable(); + // Order matters: study_sessions carries FKs to tasks and study_goals. createTasksTable(); createStudyGoalsTable(); + createStudySessionsTable(); StudySession.setJdbcTemplate(jdbcTemplate); Task.setJdbcTemplate(jdbcTemplate); @@ -97,6 +99,7 @@ void startStudySessionPersistsActiveSessionAndFlushesLocally() { @Test void startStudySessionPersistsOptionalGoalAndTaskLink() { + recurringMondayTask("task-1", LocalDate.of(2026, 3, 23)); StudyGoal goal = new StudyGoal("Linked session goal", "task-1"); goal.setDate(LocalDate.of(2026, 3, 28)); goal.save(); @@ -110,6 +113,24 @@ void startStudySessionPersistsOptionalGoalAndTaskLink() { assertEquals("task-1", stored.getTaskId()); } + @Test + void endStudySessionSurvivesDeletionOfLinkedGoal() { + recurringMondayTask("task-2", LocalDate.of(2026, 3, 23)); + StudyGoal goal = new StudyGoal("Deleted mid-session", "task-2"); + goal.setDate(LocalDate.of(2026, 3, 28)); + goal.save(); + + StudySession session = studyService.startStudySession(goal.getId(), "task-2"); + jdbcTemplate.update("DELETE FROM study_goals WHERE id = ?", goal.getId()); + + studyService.endStudySession(session, new StudySessionEnd(3, "wrapped up")); + + StudySession stored = StudySession.findById(session.getId()).orElseThrow(); + assertTrue(stored.isCompleted()); + assertNull(stored.getGoalId()); + assertEquals("task-2", stored.getTaskId()); + } + @Test void endStudySessionUpdatesExistingRowToCompletedAndFlushesLocally() { StudySession session = studyService.startStudySession(); @@ -469,7 +490,9 @@ task_id VARCHAR(50), last_update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, current_elapsed_minutes INTEGER DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (goal_id) REFERENCES study_goals(id) ON DELETE SET NULL, + FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE SET NULL ) """); } From b1b345b51eda7fa3009b73b21418653eab93498f Mon Sep 17 00:00:00 2001 From: geokoko Date: Fri, 10 Jul 2026 00:32:36 +0300 Subject: [PATCH 3/3] Trigger CI after retargeting PR base to master