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
55 changes: 49 additions & 6 deletions src/main/java/com/studysync/domain/entity/StudySession.java
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -121,21 +126,51 @@ 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,
this.confidenceLevel, this.notes, this.subject,
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);
}

/**
Expand Down Expand Up @@ -303,6 +338,8 @@ private static RowMapper<StudySession> 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"));
Expand Down Expand Up @@ -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; }

Expand Down
13 changes: 13 additions & 0 deletions src/main/java/com/studysync/domain/service/StudyService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Task> 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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Object> 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();
Expand All @@ -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…");
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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<Object> 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<Object> combo) {
Object selected = combo.getValue();
combo.getItems().clear();
combo.getItems().add(null);
LocalDate today = dateTimeService.getCurrentDate();
List<StudyGoal> goals = studyService.getStudyGoalsForDate(today).stream()
.filter(g -> !g.isAchieved())
.toList();
combo.getItems().addAll(goals);
Set<String> 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));
Expand Down Expand Up @@ -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;
}

Expand Down
13 changes: 13 additions & 0 deletions src/main/resources/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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.
);

-- ===================================
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading