diff --git a/src/main/java/com/studysync/domain/entity/StudySession.java b/src/main/java/com/studysync/domain/entity/StudySession.java index d7b344c..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; @@ -42,7 +43,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,10 +126,41 @@ 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) """; + 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, @@ -132,10 +168,9 @@ 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 ); - - logger.debug("Study session saved: {}", this.id); } /** @@ -303,6 +338,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 +559,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..9f46dd0 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,32 @@ 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(); + // 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 = 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(); @@ -309,7 +333,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 +871,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 +1193,91 @@ 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); + // 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. */ + 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 +1339,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..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); @@ -95,6 +97,40 @@ void startStudySessionPersistsActiveSessionAndFlushesLocally() { verify(googleDriveService).saveLocally(); } + @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(); + + 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 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(); @@ -448,11 +484,15 @@ 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, 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 ) """); }