diff --git a/scripts/install.sh b/scripts/install.sh
index dba85a5..7787d61 100755
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -109,6 +109,12 @@ install_files() {
mkdir -p "$BIN_DIR"
mkdir -p "$APPLICATIONS_DIR"
mkdir -p "$ICONS_DIR"
+
+ # Remove previous application jars before copying the newly built release.
+ # Dependency jars are kept; only StudySync's own versioned jars are replaced.
+ find "$INSTALL_DIR/lib" -maxdepth 1 -type f \
+ \( -name "StudySync-*.jar" -o -name "studysync-*.jar" \) \
+ -delete
if [[ "$RELEASE_MODE" == true ]]; then
# Copy from release tarball
@@ -130,7 +136,10 @@ install_files() {
# Copy dependencies
local install_lib_dir
- install_lib_dir=$(find "$PROJECT_ROOT/build/install" -mindepth 2 -maxdepth 2 -type d -name lib | head -1)
+ install_lib_dir=""
+ if [[ -d "$PROJECT_ROOT/build/install" ]]; then
+ install_lib_dir=$(find "$PROJECT_ROOT/build/install" -mindepth 2 -maxdepth 2 -type d -name lib | head -1)
+ fi
if [[ -n "$install_lib_dir" && -d "$install_lib_dir" ]]; then
cp "$install_lib_dir/"*.jar "$INSTALL_DIR/lib/" 2>/dev/null || true
fi
@@ -160,12 +169,22 @@ create_launcher() {
# Prefer executable jar when available; otherwise use classpath mode with plain jar
local main_jar
local launch_mode
- main_jar=$(find "$INSTALL_DIR/lib" \( -name "StudySync-*.jar" -o -name "studysync-*.jar" \) | grep -v "\-plain.jar" | head -1)
+ main_jar=$(find "$INSTALL_DIR/lib" -maxdepth 1 -type f \
+ \( -name "StudySync-*.jar" -o -name "studysync-*.jar" \) \
+ ! -name "*-plain.jar" -printf "%T@ %p\n" \
+ | sort -nr \
+ | head -1 \
+ | cut -d' ' -f2-)
if [[ -n "$main_jar" ]]; then
launch_mode="jar"
else
- main_jar=$(find "$INSTALL_DIR/lib" \( -name "StudySync-*-plain.jar" -o -name "studysync-*-plain.jar" \) | head -1)
+ main_jar=$(find "$INSTALL_DIR/lib" -maxdepth 1 -type f \
+ \( -name "StudySync-*-plain.jar" -o -name "studysync-*-plain.jar" \) \
+ -printf "%T@ %p\n" \
+ | sort -nr \
+ | head -1 \
+ | cut -d' ' -f2-)
if [[ -z "$main_jar" ]]; then
print_error "Application JAR not found in $INSTALL_DIR/lib"
exit 1
diff --git a/src/main/java/com/studysync/domain/entity/StudyGoal.java b/src/main/java/com/studysync/domain/entity/StudyGoal.java
index 3999961..61e4910 100644
--- a/src/main/java/com/studysync/domain/entity/StudyGoal.java
+++ b/src/main/java/com/studysync/domain/entity/StudyGoal.java
@@ -1,205 +1,158 @@
-
package com.studysync.domain.entity;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
-import org.springframework.jdbc.core.JdbcTemplate;
-import org.springframework.jdbc.core.RowMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.core.RowMapper;
import java.time.LocalDate;
import java.time.LocalDateTime;
+import java.util.HashSet;
import java.util.List;
import java.util.Optional;
+import java.util.Set;
+import java.util.UUID;
/**
- * Domain entity representing a study goal in the StudySync personal development system.
- *
- *
StudyGoal enables users to set daily learning objectives and track their achievement over time.
- * This supports habit formation, progress tracking, and reflection on learning outcomes. Goals can
- * be simple daily objectives or more complex learning targets.
- *
- *
Goal Lifecycle:
- *
- *
Creation: Goal is set with a description and current date
- *
Tracking: User works toward achieving the goal throughout the day
- *
Evaluation: At day's end, goal is marked achieved or not achieved
- *
Reflection: If not achieved, user can record reasons for learning
- *
- *
- *
Usage Examples:
- *
- * // Create a simple daily goal
- * StudyGoal goal = new StudyGoal("Complete 2 hours of focused study");
- *
- * // Mark goal as achieved
- * goal.setAchieved(true);
- *
- * // Record reason if not achieved
- * goal.setAchieved(false);
- * goal.setReasonIfNotAchieved("Had unexpected meeting that ran long");
- *
- *
- *
Business Rules:
- *
- *
Each goal is automatically assigned a unique ID and current date
- *
Goals default to not achieved until explicitly marked
- *
Reasons for non-achievement are optional but encouraged for reflection
- *
Goals cannot be modified once created (description is immutable)
- *
- *
- *
Integration: StudyGoals integrate with the daily reflection system
- * and analytics to provide insights into learning patterns and goal achievement rates.
- *
- * @author StudySync Development Team
- * @version 0.1.2
- * @since 0.1.0
- * @see DailyReflection
+ * Domain entity representing a study goal.
+ *
+ *
The database model separates a goal's intent from its scheduled attempts:
+ * {@code study_goals} is the parent intent, while {@code study_goal_attempts}
+ * stores every planned try and outcome. This class intentionally keeps the old
+ * view-style API used by the UI: each queried {@code StudyGoal} instance is a
+ * parent goal plus one selected attempt context.
*/
public class StudyGoal {
private static final Logger logger = LoggerFactory.getLogger(StudyGoal.class);
private static JdbcTemplate jdbcTemplate;
-
- public static void setJdbcTemplate(JdbcTemplate template) {
- jdbcTemplate = template;
- }
-
- /** Unique identifier for this study goal. */
+
+ public enum GoalStatus {
+ ACTIVE,
+ ACHIEVED,
+ ABANDONED
+ }
+
+ public enum AttemptOutcome {
+ PENDING,
+ ACHIEVED,
+ MISSED
+ }
+
+ private static final String SELECT_ATTEMPT_VIEW = """
+ SELECT
+ g.id,
+ g.description,
+ g.task_id,
+ COALESCE(g.status, 'ACTIVE') AS status,
+ COALESCE(g.abandoned_explicitly, FALSE) AS abandoned_explicitly,
+ g.achieved_attempt_id,
+ g.created_at AS goal_created_at,
+ g.updated_at AS goal_updated_at,
+ a.id AS attempt_id,
+ a.planned_for_date,
+ a.replanned_from_attempt_id,
+ COALESCE(a.outcome, 'PENDING') AS attempt_outcome,
+ a.reason_if_not_achieved,
+ a.outcome_at,
+ a.created_at AS attempt_created_at,
+ a.updated_at AS attempt_updated_at,
+ (
+ SELECT COUNT(*)
+ FROM study_goal_attempts x
+ WHERE x.goal_id = g.id
+ AND (x.created_at < a.created_at OR (x.created_at = a.created_at AND x.id <= a.id))
+ ) AS attempt_number,
+ (
+ SELECT COUNT(*)
+ FROM study_goal_attempts m
+ WHERE m.goal_id = g.id
+ AND m.outcome = 'MISSED'
+ AND (m.created_at < a.created_at OR (m.created_at = a.created_at AND m.id <= a.id))
+ ) AS missed_attempt_count
+ FROM study_goals g
+ JOIN study_goal_attempts a ON a.goal_id = g.id
+ """;
+
private String id;
-
- /** The date this goal was created for (target achievement date). */
private LocalDate date;
-
- /** Description of what the user wants to achieve. */
private String description;
-
- /** Whether this goal has been achieved. */
private boolean achieved;
-
- /** Optional explanation of why the goal was not achieved (for reflection). */
private String reasonIfNotAchieved;
-
- /** Number of days this goal has been delayed (0 for goals on original date). */
private int daysDelayed;
-
- /** Whether this goal is a transferred goal from a previous day. */
private boolean isDelayed;
-
- /** Points deducted due to delays (accumulates over time). */
private int pointsDeducted;
-
- /** Optional ID of the task this goal is linked to. */
private String taskId;
-
- /**
- * When non-null, this goal has been manually rescheduled to appear on this specific date.
- * It is excluded from automatic delay carry-forward and only surfaces on this date.
- */
private LocalDate replannedForDate;
-
- /** Whether this goal has been marked as failed (overdue beyond threshold or user-dismissed). */
private boolean failed;
- /**
- * Default constructor creating a study goal with auto-generated ID and current date.
- *
- *
The goal is initialized as not achieved. The description must be set separately.
The goal is automatically assigned a unique ID, set to the current date,
- * and initialized as not achieved.
- *
- * @param description what the user wants to achieve
- */
+ this.status = GoalStatus.ACTIVE;
+ this.attemptOutcome = AttemptOutcome.PENDING;
+ }
+
public StudyGoal(String description) {
this();
this.description = description;
}
-
- /**
- * Creates a study goal with the specified description and optional task link.
- *
- * @param description what the user wants to achieve
- * @param taskId optional ID of the task this goal is linked to
- */
+
public StudyGoal(String description, String taskId) {
this(description);
this.taskId = taskId;
}
- /**
- * Full constructor for creating StudyGoal from JSON or with all parameters.
- *
- *
This constructor provides null-safety by generating defaults for ID and date
- * if not provided. Used primarily for JSON deserialization and testing.
- *
- * @param id unique identifier (auto-generated if null)
- * @param date goal date (current date if null)
- * @param description goal description
- * @param achieved whether the goal has been achieved
- * @param reasonIfNotAchieved explanation if not achieved
- */
@JsonCreator
public StudyGoal(@JsonProperty("id") String id,
- @JsonProperty("date") LocalDate date,
- @JsonProperty("description") String description,
- @JsonProperty("achieved") boolean achieved,
- @JsonProperty("reasonIfNotAchieved") String reasonIfNotAchieved) {
- this.id = id != null ? id : java.util.UUID.randomUUID().toString();
+ @JsonProperty("date") LocalDate date,
+ @JsonProperty("description") String description,
+ @JsonProperty("achieved") boolean achieved,
+ @JsonProperty("reasonIfNotAchieved") String reasonIfNotAchieved) {
+ this.id = id != null ? id : UUID.randomUUID().toString();
this.date = date != null ? date : LocalDate.now();
this.description = description;
this.achieved = achieved;
this.reasonIfNotAchieved = reasonIfNotAchieved;
- this.daysDelayed = 0;
- this.isDelayed = false;
- this.pointsDeducted = 0;
- }
-
- /**
- * Full constructor for creating StudyGoal with all delay tracking fields.
- * Used internally for database operations.
- */
+ this.status = achieved ? GoalStatus.ACHIEVED : GoalStatus.ACTIVE;
+ this.attemptOutcome = achieved ? AttemptOutcome.ACHIEVED : AttemptOutcome.PENDING;
+ }
+
public StudyGoal(String id, LocalDate date, String description, boolean achieved,
- String reasonIfNotAchieved, int daysDelayed,
- boolean isDelayed, int pointsDeducted, String taskId) {
+ String reasonIfNotAchieved, int daysDelayed,
+ boolean isDelayed, int pointsDeducted, String taskId) {
this(id, date, description, achieved, reasonIfNotAchieved,
- daysDelayed, isDelayed, pointsDeducted, taskId, null);
+ daysDelayed, isDelayed, pointsDeducted, taskId, null, false);
}
- /**
- * Full constructor including the optional replanned-for date.
- * Used when rescheduling goals (failed defaults to false).
- */
public StudyGoal(String id, LocalDate date, String description, boolean achieved,
- String reasonIfNotAchieved, int daysDelayed,
- boolean isDelayed, int pointsDeducted, String taskId,
- LocalDate replannedForDate) {
+ String reasonIfNotAchieved, int daysDelayed,
+ boolean isDelayed, int pointsDeducted, String taskId,
+ LocalDate replannedForDate) {
this(id, date, description, achieved, reasonIfNotAchieved,
- daysDelayed, isDelayed, pointsDeducted, taskId, replannedForDate, false);
+ daysDelayed, isDelayed, pointsDeducted, taskId, replannedForDate, false);
}
- /**
- * Full constructor including failed flag.
- * Used by the row mapper.
- */
public StudyGoal(String id, LocalDate date, String description, boolean achieved,
- String reasonIfNotAchieved, int daysDelayed,
- boolean isDelayed, int pointsDeducted, String taskId,
- LocalDate replannedForDate, boolean failed) {
- this.id = id != null ? id : java.util.UUID.randomUUID().toString();
+ String reasonIfNotAchieved, int daysDelayed,
+ boolean isDelayed, int pointsDeducted, String taskId,
+ LocalDate replannedForDate, boolean failed) {
+ this.id = id != null ? id : UUID.randomUUID().toString();
this.date = date != null ? date : LocalDate.now();
this.description = description;
this.achieved = achieved;
@@ -210,660 +163,655 @@ public StudyGoal(String id, LocalDate date, String description, boolean achieved
this.taskId = taskId;
this.replannedForDate = replannedForDate;
this.failed = failed;
+ this.status = achieved ? GoalStatus.ACHIEVED : GoalStatus.ACTIVE;
+ this.attemptOutcome = failed ? AttemptOutcome.MISSED : achieved ? AttemptOutcome.ACHIEVED : AttemptOutcome.PENDING;
+ }
+
+ private StudyGoal(String id, LocalDate date, String description, String taskId,
+ GoalStatus status, boolean abandonedExplicitly, String achievedAttemptId,
+ String attemptId, String replannedFromAttemptId, AttemptOutcome attemptOutcome,
+ String reasonIfNotAchieved, LocalDateTime outcomeAt,
+ int attemptNumber, int missedAttemptCount) {
+ this.id = id;
+ this.date = date;
+ this.description = description;
+ this.taskId = taskId;
+ this.status = status;
+ this.abandonedExplicitly = abandonedExplicitly;
+ this.achievedAttemptId = achievedAttemptId;
+ this.attemptId = attemptId;
+ this.replannedFromAttemptId = replannedFromAttemptId;
+ this.attemptOutcome = attemptOutcome;
+ this.reasonIfNotAchieved = reasonIfNotAchieved;
+ this.outcomeAt = outcomeAt;
+ this.attemptNumber = Math.max(1, attemptNumber);
+ this.missedAttemptCount = Math.max(0, missedAttemptCount);
+ this.achieved = attemptOutcome == AttemptOutcome.ACHIEVED;
+ this.failed = attemptOutcome == AttemptOutcome.MISSED || status == GoalStatus.ABANDONED;
+ this.isDelayed = this.missedAttemptCount > 0 || this.failed;
+ this.daysDelayed = this.missedAttemptCount;
+ this.pointsDeducted = this.missedAttemptCount;
+ this.replannedForDate = replannedFromAttemptId != null && attemptOutcome == AttemptOutcome.PENDING ? date : null;
}
- /**
- * Gets the unique identifier of this study goal.
- *
- * @return the goal ID
- */
public String getId() { return id; }
-
- /**
- * Sets the unique identifier for this study goal.
- *
- * @param id the goal ID
- */
public void setId(String id) { this.id = id; }
- /**
- * Gets the date this goal was created for.
- *
- * @return the goal date
- */
public LocalDate getDate() { return date; }
-
- /**
- * Sets the date for this goal.
- *
- * @param date the goal date
- */
public void setDate(LocalDate date) { this.date = date; }
- /**
- * Gets the description of what should be achieved.
- *
- * @return the goal description
- */
public String getDescription() { return description; }
-
- /**
- * Sets the description of what should be achieved.
- *
- * @param description the goal description
- */
public void setDescription(String description) { this.description = description; }
- /**
- * Determines if this goal has been achieved.
- *
- * @return true if the goal has been achieved, false otherwise
- */
public boolean isAchieved() { return achieved; }
-
- /**
- * Sets whether this goal has been achieved.
- *
- * @param achieved true if the goal has been achieved
- */
- public void setAchieved(boolean achieved) { this.achieved = achieved; }
-
- /**
- * Gets the reason why this goal was not achieved.
- *
- *
This is used for reflection and learning from unachieved goals.
- * Only meaningful when {@code achieved} is false.
- *
- * @return the reason for non-achievement, or null if not provided
- */
+ public void setAchieved(boolean achieved) {
+ this.achieved = achieved;
+ if (achieved) {
+ this.failed = false;
+ this.status = GoalStatus.ACHIEVED;
+ this.attemptOutcome = AttemptOutcome.ACHIEVED;
+ } else if (this.attemptOutcome == AttemptOutcome.ACHIEVED) {
+ this.status = GoalStatus.ACTIVE;
+ this.attemptOutcome = AttemptOutcome.PENDING;
+ }
+ }
+
public String getReasonIfNotAchieved() { return reasonIfNotAchieved; }
-
- /**
- * Sets the reason why this goal was not achieved.
- *
- *
This supports reflection and helps identify patterns in goal achievement.
- * Should be used when marking a goal as not achieved.
- *
- * @param reasonIfNotAchieved explanation of why the goal wasn't achieved
- */
- public void setReasonIfNotAchieved(String reasonIfNotAchieved) {
- this.reasonIfNotAchieved = reasonIfNotAchieved;
- }
-
-
- /**
- * Gets the number of days this goal has been delayed.
- *
- * @return days delayed (0 for goals on original date)
- */
+ public void setReasonIfNotAchieved(String reasonIfNotAchieved) {
+ this.reasonIfNotAchieved = reasonIfNotAchieved;
+ }
+
public int getDaysDelayed() { return daysDelayed; }
-
- /**
- * Sets the number of days this goal has been delayed.
- *
- * @param daysDelayed days delayed
- */
public void setDaysDelayed(int daysDelayed) { this.daysDelayed = daysDelayed; }
- /**
- * Checks if this goal is delayed (transferred from a previous day).
- *
- * @return true if the goal is delayed
- */
public boolean isDelayed() { return isDelayed; }
-
- /**
- * Sets whether this goal is delayed.
- *
- * @param delayed true if the goal is delayed
- */
public void setDelayed(boolean delayed) { this.isDelayed = delayed; }
- /**
- * Gets the total points deducted due to delays.
- *
- * @return points deducted
- */
public int getPointsDeducted() { return pointsDeducted; }
-
- /**
- * Sets the points deducted due to delays.
- *
- * @param pointsDeducted points deducted
- */
public void setPointsDeducted(int pointsDeducted) { this.pointsDeducted = pointsDeducted; }
-
- /**
- * Gets the ID of the task this goal is linked to.
- *
- * @return task ID, or null if not linked to any task
- */
+
public String getTaskId() { return taskId; }
-
- /**
- * Sets the ID of the task this goal is linked to.
- *
- * @param taskId task ID, or null to unlink from task
- */
public void setTaskId(String taskId) { this.taskId = taskId; }
- /**
- * Gets the date this goal was manually rescheduled to appear on.
- * When non-null the goal surfaces only on this date and is excluded
- * from automatic delay carry-forward.
- *
- * @return the replanned date, or null if not rescheduled
- */
public LocalDate getReplannedForDate() { return replannedForDate; }
+ public void setReplannedForDate(LocalDate replannedForDate) { this.replannedForDate = replannedForDate; }
- /**
- * Sets the date this goal should be manually rescheduled to.
- *
- * @param replannedForDate the target date, or null to clear
- */
- public void setReplannedForDate(LocalDate replannedForDate) {
- this.replannedForDate = replannedForDate;
+ public boolean isFailed() { return failed; }
+ public void setFailed(boolean failed) {
+ this.failed = failed;
+ if (failed) {
+ this.achieved = false;
+ this.attemptOutcome = AttemptOutcome.MISSED;
+ }
}
- /**
- * Checks if this goal has been marked as failed.
- *
- * @return true if the goal is failed
- */
- public boolean isFailed() { return failed; }
+ public GoalStatus getStatus() { return status; }
+ public boolean isAbandonedExplicitly() { return abandonedExplicitly; }
+ public String getAchievedAttemptId() { return achievedAttemptId; }
+ public String getAttemptId() { return attemptId; }
+ public String getReplannedFromAttemptId() { return replannedFromAttemptId; }
+ public AttemptOutcome getAttemptOutcome() { return attemptOutcome; }
+ public LocalDateTime getOutcomeAt() { return outcomeAt; }
+ public int getAttemptNumber() { return attemptNumber; }
+ public int getMissedAttemptCount() { return missedAttemptCount; }
- /**
- * Sets whether this goal is failed.
- *
- * @param failed true to mark as failed
- */
- public void setFailed(boolean failed) { this.failed = failed; }
-
- /**
- * Calculate the delay penalty based on days delayed.
- * Formula: 5 points for first delay, then 2 points per additional day.
- *
- * @return penalty points for the current delay
- */
public int calculateDelayPenalty() {
- if (daysDelayed == 0) return 0;
- if (daysDelayed == 1) return 5; // First delay
- return 5 + (daysDelayed - 1) * 2; // Additional days
- }
-
- /**
- * Get the color intensity for UI display based on delay.
- * Returns a value between 0.0 (no delay/green) and 1.0 (max delay/red).
- *
- * @return color intensity for delayed goal visualization
- */
+ return missedAttemptCount;
+ }
+
public double getDelayColorIntensity() {
if (!isDelayed) return 0.0;
- // Orange starts at day 1, gradually moves to red
- return Math.min(1.0, daysDelayed / 7.0); // Max intensity at 7 days
- }
-
- /**
- * Returns a string representation of this study goal.
- *
- *
The format shows the goal description followed by a checkmark ([✓]) if achieved
- * or an X ([x]) if not achieved. This provides a quick visual status indicator.
- *
- * @return a string representation showing goal description and achievement status
- */
+ return Math.min(1.0, missedAttemptCount / 5.0);
+ }
+
@Override
public String toString() {
- return description + " - " + (achieved ? "[✓]" : "[x]");
- }
-
- // ==============================================================
- // DATABASE OPERATIONS (Active Record Pattern)
- // ==============================================================
-
- /**
- * Save this study goal to the database (insert or update).
- */
+ return description + " - " + attemptOutcome;
+ }
+
public StudyGoal save() {
- if (jdbcTemplate == null) {
- throw new IllegalStateException("JdbcTemplate not initialized. Make sure Spring context is loaded.");
+ requireJdbcTemplate();
+ if (id == null || id.isBlank()) {
+ id = UUID.randomUUID().toString();
+ }
+ if (date == null) {
+ date = LocalDate.now();
+ }
+ if (status == null) {
+ status = abandonedExplicitly ? GoalStatus.ABANDONED : achieved ? GoalStatus.ACHIEVED : GoalStatus.ACTIVE;
+ }
+ if (attemptOutcome == null) {
+ attemptOutcome = failed ? AttemptOutcome.MISSED : achieved ? AttemptOutcome.ACHIEVED : AttemptOutcome.PENDING;
+ }
+ if (attemptId == null || attemptId.isBlank()) {
+ attemptId = UUID.randomUUID().toString();
+ }
+ // Parent lifecycle mirrors the current attempt outcome on write.
+ if (attemptOutcome == AttemptOutcome.ACHIEVED) {
+ status = GoalStatus.ACHIEVED;
+ achievedAttemptId = attemptId;
+ achieved = true;
+ failed = false;
+ } else if (status == GoalStatus.ACHIEVED) {
+ status = GoalStatus.ACTIVE;
+ achievedAttemptId = null;
}
-
- String sql = """
- MERGE INTO study_goals (id, date, description, achieved, reason_if_not_achieved,
- days_delayed, is_delayed, points_deducted, task_id,
- replanned_for_date, failed, updated_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
- """;
- jdbcTemplate.update(sql,
- this.id, this.date, this.description, this.achieved, this.reasonIfNotAchieved,
- this.daysDelayed, this.isDelayed, this.pointsDeducted, this.taskId,
- this.replannedForDate, this.failed
- );
-
- logger.debug("StudyGoal saved: {} - {}", this.id, this.description);
+ upsertParent();
+ upsertAttempt();
+ logger.debug("StudyGoal saved: {} - {}", id, description);
return this;
}
-
- /**
- * Delete this study goal from the database.
- */
+
public boolean delete() {
- if (jdbcTemplate == null || this.id == null) {
- return false;
- }
-
- String sql = "DELETE FROM study_goals WHERE id = ?";
- int rowsAffected = jdbcTemplate.update(sql, this.id);
- boolean deleted = rowsAffected > 0;
-
- if (deleted) {
- logger.info("StudyGoal deleted: {} - {}", this.id, this.description);
- } else {
- logger.warn("StudyGoal not found for deletion: {}", this.id);
- }
-
- return deleted;
+ return deleteById(id);
}
-
- // ==============================================================
- // STATIC QUERY METHODS
- // ==============================================================
-
- /**
- * Get all study goals ordered by date (most recent first).
- */
+
+ private void upsertParent() {
+ String sql = """
+ MERGE INTO study_goals (
+ id, date, description, achieved, reason_if_not_achieved,
+ days_delayed, is_delayed, points_deducted, task_id,
+ replanned_for_date, failed, status, abandoned_explicitly,
+ achieved_attempt_id, updated_at
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
+ """;
+ jdbcTemplate.update(sql,
+ id, date, description, status == GoalStatus.ACHIEVED, reasonIfNotAchieved,
+ daysDelayed, isDelayed, pointsDeducted, taskId,
+ replannedForDate, attemptOutcome == AttemptOutcome.MISSED, status.name(),
+ abandonedExplicitly, achievedAttemptId);
+ }
+
+ private void upsertAttempt() {
+ String sql = """
+ MERGE INTO study_goal_attempts (
+ id, goal_id, planned_for_date, replanned_from_attempt_id, outcome,
+ reason_if_not_achieved, outcome_at, updated_at
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
+ """;
+ LocalDateTime resolvedOutcomeAt = attemptOutcome == AttemptOutcome.PENDING ? null
+ : outcomeAt != null ? outcomeAt : LocalDateTime.now();
+ jdbcTemplate.update(sql,
+ attemptId, id, date, replannedFromAttemptId, attemptOutcome.name(),
+ reasonIfNotAchieved, resolvedOutcomeAt);
+ }
+
public static List findAll() {
- if (jdbcTemplate == null) {
- throw new IllegalStateException("JdbcTemplate not initialized");
- }
-
- String sql = "SELECT * FROM study_goals ORDER BY date DESC, created_at DESC";
- List goals = jdbcTemplate.query(sql, getRowMapper());
- logger.debug("Retrieved {} study goals", goals.size());
- return goals;
+ requireJdbcTemplate();
+ String sql = SELECT_ATTEMPT_VIEW + """
+ ORDER BY a.planned_for_date DESC, a.created_at DESC
+ """;
+ return jdbcTemplate.query(sql, getAttemptViewMapper());
}
-
- /**
- * Find a study goal by its ID.
- */
+
public static Optional findById(String goalId) {
if (jdbcTemplate == null || goalId == null) {
return Optional.empty();
}
-
- String sql = "SELECT * FROM study_goals WHERE id = ?";
- try {
- StudyGoal goal = jdbcTemplate.queryForObject(sql, getRowMapper(), goalId);
- logger.debug("StudyGoal found: {}", goalId);
- return Optional.ofNullable(goal);
- } catch (Exception e) {
- logger.debug("StudyGoal not found: {}", goalId);
- return Optional.empty();
- }
+ String sql = SELECT_ATTEMPT_VIEW + """
+ WHERE g.id = ?
+ ORDER BY
+ CASE a.outcome WHEN 'PENDING' THEN 0 WHEN 'ACHIEVED' THEN 1 ELSE 2 END,
+ a.planned_for_date DESC,
+ a.created_at DESC
+ LIMIT 1
+ """;
+ List goals = jdbcTemplate.query(sql, getAttemptViewMapper(), goalId);
+ return goals.stream().findFirst();
}
-
- /**
- * Get study goals for a specific date.
- * Failed goals are excluded — use {@link #findAllByDate(LocalDate)} for views
- * that need the complete history (e.g. calendar).
- */
+
public static List findByDate(LocalDate date) {
if (jdbcTemplate == null || date == null) {
return List.of();
}
-
- // Include goals originally planned for this date AND goals manually
- // rescheduled (replanned) to appear on this date, regardless of achieved status.
- // Failed goals are excluded from active views.
- String sql = """
- SELECT * FROM study_goals
- WHERE (failed = FALSE OR failed IS NULL)
- AND (date = ? OR replanned_for_date = ?)
- ORDER BY created_at
+ String sql = SELECT_ATTEMPT_VIEW + """
+ WHERE g.status <> 'ABANDONED'
+ AND a.planned_for_date = ?
+ AND a.outcome IN ('PENDING', 'ACHIEVED')
+ ORDER BY a.outcome ASC, g.created_at ASC
""";
- List goals = jdbcTemplate.query(sql, getRowMapper(), date, date);
- logger.debug("Retrieved {} study goals for date: {}", goals.size(), date);
- return goals;
+ return jdbcTemplate.query(sql, getAttemptViewMapper(), date);
}
- /**
- * Get all study goals for a specific date, including failed ones.
- * Used by calendar view which shows the full history for each day.
- */
public static List findAllByDate(LocalDate date) {
if (jdbcTemplate == null || date == null) {
return List.of();
}
-
- String sql = """
- SELECT * FROM study_goals
- WHERE (date = ? OR replanned_for_date = ?)
- ORDER BY failed ASC, achieved DESC, created_at ASC
- """;
- List goals = jdbcTemplate.query(sql, getRowMapper(), date, date);
- logger.debug("Retrieved {} study goals (all statuses) for date: {}", goals.size(), date);
- return goals;
- }
-
- /**
- * Get study goals for a specific date including delayed goals that should appear on this date.
- * This method returns:
- *
- *
Goals originally created for the specified date
- *
Unachieved goals from previous dates that are now delayed
- *
- *
- * @param date the date to retrieve goals for
- * @return list of study goals including delayed ones, ordered by delay status and creation time
- */
- public static List findByDateIncludingDelayed(LocalDate date) {
- if (jdbcTemplate == null || date == null) {
- return List.of();
- }
-
- // Delayed goals that were manually replanned are excluded from the automatic
- // carry-forward path — they only surface via their replanned_for_date.
- // Failed goals are excluded from active planner views.
- String sql = """
- SELECT * FROM study_goals
- WHERE (failed = FALSE OR failed IS NULL)
- AND (date = ?
- OR (is_delayed = TRUE AND achieved = FALSE AND date < ? AND replanned_for_date IS NULL)
- OR replanned_for_date = ?)
- ORDER BY is_delayed ASC, days_delayed DESC, created_at ASC
+ String sql = SELECT_ATTEMPT_VIEW + """
+ WHERE a.planned_for_date = ?
+ ORDER BY
+ CASE a.outcome WHEN 'PENDING' THEN 0 WHEN 'ACHIEVED' THEN 1 ELSE 2 END,
+ g.created_at ASC
""";
+ return jdbcTemplate.query(sql, getAttemptViewMapper(), date);
+ }
- List goals = jdbcTemplate.query(sql, getRowMapper(), date, date, date);
- logger.debug("Retrieved {} study goals (including delayed) for date: {}", goals.size(), date);
- return goals;
+ public static List findByDateIncludingDelayed(LocalDate date) {
+ return findByDate(date);
}
- /**
- * Like {@link #findByDateIncludingDelayed(LocalDate)} but includes failed goals.
- * Used by calendar view which shows all goals for each day.
- */
public static List findAllByDateIncludingDelayed(LocalDate date) {
- if (jdbcTemplate == null || date == null) {
- return List.of();
- }
-
- String sql = """
- SELECT * FROM study_goals
- WHERE (date = ?
- OR (is_delayed = TRUE AND achieved = FALSE AND failed = FALSE AND date < ? AND replanned_for_date IS NULL)
- OR replanned_for_date = ?)
- ORDER BY failed ASC, is_delayed ASC, days_delayed DESC, created_at ASC
- """;
-
- List goals = jdbcTemplate.query(sql, getRowMapper(), date, date, date);
- logger.debug("Retrieved {} study goals (all statuses, including delayed) for date: {}", goals.size(), date);
- return goals;
+ return findAllByDate(date);
}
-
- /**
- * Get achieved study goals.
- */
+
public static List findAchieved() {
- if (jdbcTemplate == null) {
- throw new IllegalStateException("JdbcTemplate not initialized");
- }
-
- String sql = "SELECT * FROM study_goals WHERE achieved = TRUE AND (failed = FALSE OR failed IS NULL) ORDER BY date DESC";
- List goals = jdbcTemplate.query(sql, getRowMapper());
- logger.debug("Retrieved {} achieved study goals", goals.size());
- return goals;
+ requireJdbcTemplate();
+ String sql = SELECT_ATTEMPT_VIEW + """
+ WHERE a.outcome = 'ACHIEVED'
+ ORDER BY a.planned_for_date DESC, a.created_at DESC
+ """;
+ return jdbcTemplate.query(sql, getAttemptViewMapper());
}
-
- /**
- * Delete a study goal by ID (static method).
- */
+
public static boolean deleteById(String goalId) {
if (jdbcTemplate == null || goalId == null) {
return false;
}
-
- String sql = "DELETE FROM study_goals WHERE id = ?";
- int rowsAffected = jdbcTemplate.update(sql, goalId);
+ jdbcTemplate.update("DELETE FROM study_goal_attempts WHERE goal_id = ?", goalId);
+ int rowsAffected = jdbcTemplate.update("DELETE FROM study_goals WHERE id = ?", goalId);
boolean deleted = rowsAffected > 0;
-
if (deleted) {
logger.info("StudyGoal deleted: {}", goalId);
- } else {
- logger.warn("StudyGoal not found for deletion: {}", goalId);
}
-
return deleted;
}
-
- /**
- * Get count of study goals by achievement status.
- */
+
public static long countByAchievement(boolean achieved) {
if (jdbcTemplate == null) {
return 0L;
}
-
- String sql = "SELECT COUNT(*) FROM study_goals WHERE achieved = ? AND (failed = FALSE OR failed IS NULL)";
- Integer count = jdbcTemplate.queryForObject(sql, Integer.class, achieved);
+ String outcome = achieved ? "ACHIEVED" : "PENDING";
+ Integer count = jdbcTemplate.queryForObject(
+ "SELECT COUNT(*) FROM study_goal_attempts WHERE outcome = ?",
+ Integer.class, outcome);
return count != null ? count : 0L;
}
-
- /**
- * Find unachieved goals for a specific date (candidates for transfer).
- */
+
public static List findUnachievedByDate(LocalDate date) {
if (jdbcTemplate == null || date == null) {
return List.of();
}
-
- String sql = "SELECT * FROM study_goals WHERE date = ? AND achieved = FALSE ORDER BY created_at";
- List goals = jdbcTemplate.query(sql, getRowMapper(), date);
- logger.debug("Retrieved {} unachieved study goals for date: {}", goals.size(), date);
- return goals;
+ String sql = SELECT_ATTEMPT_VIEW + """
+ WHERE a.planned_for_date = ?
+ AND a.outcome <> 'ACHIEVED'
+ ORDER BY a.created_at
+ """;
+ return jdbcTemplate.query(sql, getAttemptViewMapper(), date);
}
-
- /**
- * Find delayed goals (transferred from previous days).
- */
+
public static List findDelayed() {
- if (jdbcTemplate == null) {
- throw new IllegalStateException("JdbcTemplate not initialized");
- }
-
- String sql = "SELECT * FROM study_goals WHERE is_delayed = TRUE ORDER BY days_delayed DESC, date DESC";
- List goals = jdbcTemplate.query(sql, getRowMapper());
- logger.debug("Retrieved {} delayed study goals", goals.size());
- return goals;
+ requireJdbcTemplate();
+ String sql = SELECT_ATTEMPT_VIEW + """
+ WHERE a.outcome = 'MISSED'
+ ORDER BY a.planned_for_date DESC, a.created_at DESC
+ """;
+ return jdbcTemplate.query(sql, getAttemptViewMapper());
}
-
- /**
- * Find delayed goals for a specific date.
- */
+
public static List findDelayedByDate(LocalDate date) {
if (jdbcTemplate == null || date == null) {
return List.of();
}
-
- String sql = "SELECT * FROM study_goals WHERE date = ? AND is_delayed = TRUE ORDER BY days_delayed DESC";
- List goals = jdbcTemplate.query(sql, getRowMapper(), date);
- logger.debug("Retrieved {} delayed study goals for date: {}", goals.size(), date);
- return goals;
- }
-
- /**
- * Find goals linked to a specific task for a given date, including delayed unachieved goals.
- * Returns goals that are either planned for the date or delayed from earlier dates.
- *
- * @param taskId the task ID to find linked goals for
- * @param date the date to scope the query
- * @return list of study goals linked to the task for the given date context
- */
+ String sql = SELECT_ATTEMPT_VIEW + """
+ WHERE a.planned_for_date = ?
+ AND a.outcome = 'MISSED'
+ ORDER BY a.created_at
+ """;
+ return jdbcTemplate.query(sql, getAttemptViewMapper(), date);
+ }
+
public static List findByTaskIdForDate(String taskId, LocalDate date) {
if (jdbcTemplate == null || taskId == null || taskId.isBlank() || date == null) {
return List.of();
}
-
- // Only return goals explicitly scheduled for this date or explicitly
- // re-planned to it. Do NOT auto-carry-forward delayed goals — those
- // stay in the re-plan section until the user explicitly reschedules them.
- // The old auto-carry-forward clause (is_delayed AND achieved = FALSE AND
- // replanned_for_date IS NULL) caused two bugs:
- // 1. Re-planning one goal made ALL delayed goals for the task appear
- // 2. Achieved delayed goals vanished (achieved = TRUE no longer matched)
- String sql = """
- SELECT * FROM study_goals
- WHERE task_id = ?
- AND (failed = FALSE OR failed IS NULL)
- AND (date = ? OR replanned_for_date = ?)
- ORDER BY is_delayed ASC, date ASC, created_at ASC
+ String sql = SELECT_ATTEMPT_VIEW + """
+ WHERE g.task_id = ?
+ AND g.status <> 'ABANDONED'
+ AND a.planned_for_date = ?
+ AND a.outcome IN ('PENDING', 'ACHIEVED')
+ ORDER BY a.outcome ASC, a.created_at ASC
""";
- List goals = jdbcTemplate.query(sql, getRowMapper(), taskId, date, date);
- logger.debug("Retrieved {} goals for task {} on date {}", goals.size(), taskId, date);
- return goals;
- }
-
- /**
- * Find ALL goals ever linked to a specific task, including failed ones.
- * Used for the goal history view in the task management panel.
- *
- * @param taskId the task ID to find all linked goals for
- * @return list of all study goals (active, achieved, failed) linked to the task, ordered by date desc
- */
+ return jdbcTemplate.query(sql, getAttemptViewMapper(), taskId, date);
+ }
+
public static List findByTaskId(String taskId) {
if (jdbcTemplate == null || taskId == null || taskId.isBlank()) {
return List.of();
}
+ String sql = SELECT_ATTEMPT_VIEW + """
+ WHERE g.task_id = ?
+ ORDER BY a.planned_for_date DESC, a.created_at DESC
+ """;
+ return jdbcTemplate.query(sql, getAttemptViewMapper(), taskId);
+ }
+
+ public static boolean updateDetails(String goalId, String description, LocalDate pendingPlannedForDate) {
+ if (jdbcTemplate == null || goalId == null || goalId.isBlank()
+ || description == null || description.isBlank()) {
+ return false;
+ }
+ int parentRows = jdbcTemplate.update("""
+ UPDATE study_goals
+ SET description = ?, updated_at = CURRENT_TIMESTAMP
+ WHERE id = ?
+ """, description.trim(), goalId);
+ if (pendingPlannedForDate != null) {
+ jdbcTemplate.update("""
+ UPDATE study_goal_attempts
+ SET planned_for_date = ?, updated_at = CURRENT_TIMESTAMP
+ WHERE goal_id = ? AND outcome = 'PENDING'
+ """, pendingPlannedForDate, goalId);
+ }
+ return parentRows > 0;
+ }
+ public static Set findAchievedTaskDatePairs(LocalDate rangeStart, LocalDate rangeEnd) {
+ if (jdbcTemplate == null || rangeStart == null || rangeEnd == null) {
+ return Set.of();
+ }
String sql = """
- SELECT * FROM study_goals
- WHERE task_id = ?
- ORDER BY date DESC, created_at DESC
+ SELECT DISTINCT g.task_id, a.planned_for_date
+ FROM study_goals g
+ JOIN study_goal_attempts a ON a.goal_id = g.id
+ WHERE a.outcome = 'ACHIEVED'
+ AND g.task_id IS NOT NULL
+ AND a.planned_for_date >= ? AND a.planned_for_date <= ?
""";
- List goals = jdbcTemplate.query(sql, getRowMapper(), taskId);
- logger.debug("Retrieved {} total goals for task {}", goals.size(), taskId);
- return goals;
- }
-
- /**
- * Batch-queries all (task_id, date) pairs that have at least one achieved
- * goal within the given date range. Returns a set of "taskId|date"
- * strings for O(1) lookup.
- *
- *
This replaces per-cell calls to {@link #hasAchievedGoalForTask} in the
- * calendar month view, eliminating an N+1 query storm.
- *
- * @param rangeStart inclusive start of the date range
- * @param rangeEnd inclusive end of the date range
- * @return set of "taskId|date" keys where an achieved goal exists
- */
- public static java.util.Set findAchievedTaskDatePairs(LocalDate rangeStart, LocalDate rangeEnd) {
+ Set result = new HashSet<>();
+ jdbcTemplate.query(sql, (rs, rowNum) -> {
+ result.add(rs.getString("task_id") + "|" + rs.getDate("planned_for_date").toLocalDate());
+ return null;
+ }, rangeStart, rangeEnd);
+ return result;
+ }
+
+ public static Set findHandledTaskDatePairs(LocalDate rangeStart, LocalDate rangeEnd) {
if (jdbcTemplate == null || rangeStart == null || rangeEnd == null) {
- return java.util.Set.of();
+ return Set.of();
}
String sql = """
- SELECT DISTINCT task_id, date FROM study_goals
- WHERE achieved = TRUE
- AND (failed = FALSE OR failed IS NULL)
- AND task_id IS NOT NULL
- AND date IS NOT NULL
- AND date >= ? AND date <= ?
+ WITH RECURSIVE handled_attempts (
+ task_id, attempt_id, planned_for_date, outcome, replanned_from_attempt_id, depth
+ ) AS (
+ SELECT g.task_id, a.id, a.planned_for_date, a.outcome, a.replanned_from_attempt_id, 0
+ FROM study_goals g
+ JOIN study_goal_attempts a ON a.goal_id = g.id
+ WHERE g.task_id IS NOT NULL
+ AND a.outcome = 'ACHIEVED'
+ UNION ALL
+ SELECT h.task_id, parent.id, parent.planned_for_date, parent.outcome,
+ parent.replanned_from_attempt_id, h.depth + 1
+ FROM study_goal_attempts parent
+ JOIN handled_attempts h ON h.replanned_from_attempt_id = parent.id
+ WHERE h.depth < 20
+ )
+ SELECT DISTINCT task_id, planned_for_date
+ FROM handled_attempts
+ WHERE planned_for_date >= ?
+ AND planned_for_date <= ?
+ AND outcome IN ('ACHIEVED', 'MISSED')
""";
- java.util.Set result = new java.util.HashSet<>();
+ Set result = new HashSet<>();
jdbcTemplate.query(sql, (rs, rowNum) -> {
- result.add(rs.getString("task_id") + "|" + rs.getDate("date").toLocalDate());
+ result.add(rs.getString("task_id") + "|" + rs.getDate("planned_for_date").toLocalDate());
return null;
}, rangeStart, rangeEnd);
return result;
}
- /**
- * Checks whether at least one achieved study goal exists for the given
- * task on exactly the specified date. Used to determine whether a
- * recurring-task occurrence was "handled".
- *
- * @param taskId the task ID
- * @param date the exact occurrence date to check
- * @return {@code true} if a linked, achieved goal exists for that date
- */
public static boolean hasAchievedGoalForTask(String taskId, LocalDate date) {
if (jdbcTemplate == null || taskId == null || taskId.isBlank() || date == null) {
return false;
}
String sql = """
- SELECT COUNT(*) FROM study_goals
- WHERE task_id = ? AND date = ? AND achieved = TRUE
- AND (failed = FALSE OR failed IS NULL)
+ SELECT COUNT(*)
+ FROM study_goals g
+ JOIN study_goal_attempts a ON a.goal_id = g.id
+ WHERE g.task_id = ?
+ AND a.planned_for_date = ?
+ AND a.outcome = 'ACHIEVED'
""";
Integer count = jdbcTemplate.queryForObject(sql, Integer.class, taskId, date);
return count != null && count > 0;
}
-
- /**
- * Find goals that are NOT linked to any task for a given date, including delayed unachieved ones.
- *
- * @param date the date to scope the query
- * @return list of unlinked study goals for the given date context
- */
+
+ public static boolean hasHandledGoalForTaskOccurrence(String taskId, LocalDate occurrenceDate) {
+ if (jdbcTemplate == null || taskId == null || taskId.isBlank() || occurrenceDate == null) {
+ return false;
+ }
+ String sql = """
+ WITH RECURSIVE handled_attempts (
+ attempt_id, planned_for_date, outcome, replanned_from_attempt_id, depth
+ ) AS (
+ SELECT a.id, a.planned_for_date, a.outcome, a.replanned_from_attempt_id, 0
+ FROM study_goals g
+ JOIN study_goal_attempts a ON a.goal_id = g.id
+ WHERE g.task_id = ?
+ AND a.outcome = 'ACHIEVED'
+ UNION ALL
+ SELECT parent.id, parent.planned_for_date, parent.outcome,
+ parent.replanned_from_attempt_id, h.depth + 1
+ FROM study_goal_attempts parent
+ JOIN handled_attempts h ON h.replanned_from_attempt_id = parent.id
+ WHERE h.depth < 20
+ )
+ SELECT COUNT(*)
+ FROM handled_attempts
+ WHERE planned_for_date = ?
+ AND outcome IN ('ACHIEVED', 'MISSED')
+ """;
+ Integer count = jdbcTemplate.queryForObject(sql, Integer.class, taskId, occurrenceDate);
+ return count != null && count > 0;
+ }
+
public static List findUnlinkedForDate(LocalDate date) {
if (jdbcTemplate == null || date == null) {
return List.of();
}
-
- String sql = """
- SELECT * FROM study_goals
- WHERE task_id IS NULL
- AND (failed = FALSE OR failed IS NULL)
- AND (date = ? OR replanned_for_date = ?)
- ORDER BY is_delayed ASC, date ASC, created_at ASC
+ String sql = SELECT_ATTEMPT_VIEW + """
+ WHERE g.task_id IS NULL
+ AND g.status <> 'ABANDONED'
+ AND a.planned_for_date = ?
+ AND a.outcome IN ('PENDING', 'ACHIEVED')
+ ORDER BY a.outcome ASC, a.created_at ASC
""";
- List goals = jdbcTemplate.query(sql, getRowMapper(), date, date);
- logger.debug("Retrieved {} unlinked goals for date {}", goals.size(), date);
- return goals;
- }
-
- /**
- * Find all delayed, unachieved goals that have not yet been manually rescheduled.
- * Used to populate the re-plan dropdown in the Study Planner.
- *
- * @return delayed goals eligible for manual rescheduling, ordered by delay severity
- */
+ return jdbcTemplate.query(sql, getAttemptViewMapper(), date);
+ }
+
public static List findDelayedAndNotReplanned() {
if (jdbcTemplate == null) {
return List.of();
}
+ String sql = SELECT_ATTEMPT_VIEW + """
+ WHERE g.status = 'ACTIVE'
+ AND a.outcome = 'MISSED'
+ AND NOT EXISTS (
+ SELECT 1 FROM study_goal_attempts p
+ WHERE p.goal_id = g.id AND p.outcome = 'PENDING'
+ )
+ AND a.id = (
+ SELECT latest.id
+ FROM study_goal_attempts latest
+ WHERE latest.goal_id = g.id
+ ORDER BY latest.created_at DESC, latest.planned_for_date DESC
+ LIMIT 1
+ )
+ ORDER BY a.planned_for_date ASC, a.created_at ASC
+ """;
+ return jdbcTemplate.query(sql, getAttemptViewMapper());
+ }
+
+ public static int markPendingAttemptsBefore(LocalDate today) {
+ if (jdbcTemplate == null || today == null) {
+ return 0;
+ }
String sql = """
- SELECT * FROM study_goals
- WHERE is_delayed = TRUE
- AND achieved = FALSE
- AND (failed = FALSE OR failed IS NULL)
- AND replanned_for_date IS NULL
- ORDER BY days_delayed DESC, date ASC, created_at ASC
+ UPDATE study_goal_attempts
+ SET outcome = 'MISSED', outcome_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
+ WHERE outcome = 'PENDING'
+ AND planned_for_date < ?
""";
- List goals = jdbcTemplate.query(sql, getRowMapper());
- logger.debug("Retrieved {} delayed goals eligible for rescheduling", goals.size());
- return goals;
- }
-
- /**
- * RowMapper for converting database rows to StudyGoal objects.
- */
- private static RowMapper getRowMapper() {
- return (rs, rowNum) -> {
- String id = rs.getString("id");
- LocalDate date = rs.getObject("date", LocalDate.class);
- String description = rs.getString("description");
- boolean achieved = rs.getBoolean("achieved");
- String reasonIfNotAchieved = rs.getString("reason_if_not_achieved");
- int daysDelayed = rs.getInt("days_delayed");
- boolean isDelayed = rs.getBoolean("is_delayed");
- int pointsDeducted = rs.getInt("points_deducted");
- String taskId = rs.getString("task_id");
- LocalDate replannedForDate = rs.getObject("replanned_for_date", LocalDate.class);
- boolean failed = rs.getBoolean("failed");
-
- return new StudyGoal(id, date, description, achieved, reasonIfNotAchieved,
- daysDelayed, isDelayed, pointsDeducted, taskId, replannedForDate, failed);
- };
+ return jdbcTemplate.update(sql, today);
+ }
+
+ public static boolean createReplanAttempt(String goalId, LocalDate plannedForDate) {
+ if (jdbcTemplate == null || goalId == null || goalId.isBlank() || plannedForDate == null) {
+ return false;
+ }
+ Integer activeGoalCount = jdbcTemplate.queryForObject("""
+ SELECT COUNT(*) FROM study_goals
+ WHERE id = ? AND status = 'ACTIVE'
+ """, Integer.class, goalId);
+ if (activeGoalCount == null || activeGoalCount == 0) {
+ return false;
+ }
+
+ Integer pendingCount = jdbcTemplate.queryForObject("""
+ SELECT COUNT(*) FROM study_goal_attempts
+ WHERE goal_id = ? AND outcome = 'PENDING'
+ """, Integer.class, goalId);
+ if (pendingCount != null && pendingCount > 0) {
+ return false;
+ }
+
+ String latestAttemptId = jdbcTemplate.query("""
+ SELECT id FROM study_goal_attempts
+ WHERE goal_id = ?
+ ORDER BY created_at DESC, planned_for_date DESC
+ LIMIT 1
+ """, rs -> rs.next() ? rs.getString("id") : null, goalId);
+ if (latestAttemptId == null) {
+ return false;
+ }
+
+ String attemptId = UUID.randomUUID().toString();
+ int rows = jdbcTemplate.update("""
+ INSERT INTO study_goal_attempts (
+ id, goal_id, planned_for_date, replanned_from_attempt_id, outcome,
+ created_at, updated_at
+ )
+ VALUES (?, ?, ?, ?, 'PENDING', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
+ """, attemptId, goalId, plannedForDate, latestAttemptId);
+ jdbcTemplate.update("""
+ UPDATE study_goals
+ SET status = 'ACTIVE', achieved_attempt_id = NULL, updated_at = CURRENT_TIMESTAMP
+ WHERE id = ? AND status = 'ACTIVE'
+ """, goalId);
+ return rows > 0;
+ }
+
+ public static boolean markCurrentAttemptAchieved(String goalId, String reasonIfNot) {
+ Optional goalOpt = findById(goalId);
+ if (goalOpt.isEmpty()) {
+ return false;
+ }
+ StudyGoal goal = goalOpt.get();
+ if (goal.attemptId == null) {
+ return false;
+ }
+ jdbcTemplate.update("""
+ UPDATE study_goal_attempts
+ SET outcome = 'ACHIEVED', reason_if_not_achieved = ?, outcome_at = CURRENT_TIMESTAMP,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id = ?
+ """, reasonIfNot, goal.attemptId);
+ jdbcTemplate.update("""
+ UPDATE study_goals
+ SET status = 'ACHIEVED', achieved_attempt_id = ?, achieved = TRUE, failed = FALSE,
+ reason_if_not_achieved = ?, updated_at = CURRENT_TIMESTAMP
+ WHERE id = ?
+ """, goal.attemptId, reasonIfNot, goalId);
+ return true;
+ }
+
+ public static boolean reopenAchievedGoal(String goalId) {
+ if (jdbcTemplate == null || goalId == null || goalId.isBlank()) {
+ return false;
+ }
+ String achievedAttempt = jdbcTemplate.query("""
+ SELECT achieved_attempt_id FROM study_goals WHERE id = ?
+ """, rs -> rs.next() ? rs.getString("achieved_attempt_id") : null, goalId);
+ if (achievedAttempt == null) {
+ return false;
+ }
+ jdbcTemplate.update("""
+ UPDATE study_goal_attempts
+ SET outcome = 'PENDING', reason_if_not_achieved = NULL, outcome_at = NULL,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id = ?
+ """, achievedAttempt);
+ jdbcTemplate.update("""
+ UPDATE study_goals
+ SET status = 'ACTIVE', achieved_attempt_id = NULL, achieved = FALSE,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id = ?
+ """, goalId);
+ return true;
+ }
+
+ public static boolean abandonGoal(String goalId) {
+ Optional goalOpt = findById(goalId);
+ if (goalOpt.isEmpty()) {
+ return false;
+ }
+ StudyGoal goal = goalOpt.get();
+ if (goal.attemptId != null && goal.attemptOutcome == AttemptOutcome.PENDING) {
+ jdbcTemplate.update("""
+ UPDATE study_goal_attempts
+ SET outcome = 'MISSED', outcome_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
+ WHERE id = ?
+ """, goal.attemptId);
+ }
+ int rows = jdbcTemplate.update("""
+ UPDATE study_goals
+ SET status = 'ABANDONED', achieved_attempt_id = NULL, achieved = FALSE, failed = TRUE,
+ abandoned_explicitly = TRUE, updated_at = CURRENT_TIMESTAMP
+ WHERE id = ?
+ """, goalId);
+ return rows > 0;
+ }
+
+ private static RowMapper getAttemptViewMapper() {
+ return (rs, rowNum) -> new StudyGoal(
+ rs.getString("id"),
+ rs.getObject("planned_for_date", LocalDate.class),
+ rs.getString("description"),
+ rs.getString("task_id"),
+ parseGoalStatus(rs.getString("status")),
+ rs.getBoolean("abandoned_explicitly"),
+ rs.getString("achieved_attempt_id"),
+ rs.getString("attempt_id"),
+ rs.getString("replanned_from_attempt_id"),
+ parseAttemptOutcome(rs.getString("attempt_outcome")),
+ rs.getString("reason_if_not_achieved"),
+ rs.getObject("outcome_at", LocalDateTime.class),
+ rs.getInt("attempt_number"),
+ rs.getInt("missed_attempt_count")
+ );
+ }
+
+ private static GoalStatus parseGoalStatus(String value) {
+ try {
+ return value == null ? GoalStatus.ACTIVE : GoalStatus.valueOf(value);
+ } catch (IllegalArgumentException e) {
+ return GoalStatus.ACTIVE;
+ }
+ }
+
+ private static AttemptOutcome parseAttemptOutcome(String value) {
+ try {
+ return value == null ? AttemptOutcome.PENDING : AttemptOutcome.valueOf(value);
+ } catch (IllegalArgumentException e) {
+ return AttemptOutcome.PENDING;
+ }
+ }
+
+ private static void requireJdbcTemplate() {
+ if (jdbcTemplate == null) {
+ throw new IllegalStateException("JdbcTemplate not initialized");
+ }
}
}
diff --git a/src/main/java/com/studysync/domain/service/StudyService.java b/src/main/java/com/studysync/domain/service/StudyService.java
index 8ad76ae..daa3ba3 100644
--- a/src/main/java/com/studysync/domain/service/StudyService.java
+++ b/src/main/java/com/studysync/domain/service/StudyService.java
@@ -18,7 +18,6 @@
import java.time.LocalDate;
import java.time.LocalDateTime;
-import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -210,81 +209,128 @@ public void addStudyGoal(String description, LocalDate date, String taskId) {
}
/**
- * Returns delayed, unachieved goals that are eligible for manual rescheduling
- * to today. Goals linked to CANCELLED or POSTPONED tasks are excluded.
- * Orphan goals (no task) are included so they remain visible and don't
- * silently age into auto-deletion.
+ * Returns active goals whose latest attempt was missed and which do not
+ * already have a pending attempt. Task status is intentionally ignored here:
+ * the parent goal lifecycle is the source of truth for retry eligibility.
*
- * @return list of goals the user can choose to re-plan for today
+ * @return list of goals the user can choose to retry today
*/
@Transactional(readOnly = true)
- public List getDelayedGoalsForReplanning() {
+ public List getDelayedGoalsForReplanning(final String taskId) {
+ if (taskId == null || taskId.isBlank()) {
+ return List.of();
+ }
return StudyGoal.findDelayedAndNotReplanned().stream()
- .filter(goal -> {
- // Orphan goals (no task) are eligible for rescheduling.
- // Without this, they become invisible in the planner and
- // silently auto-delete after 14 days.
- if (goal.getTaskId() == null || goal.getTaskId().isBlank()) {
- return true;
- }
- // NOTE: Do NOT filter out goals whose task is already visible
- // today. findByTaskIdForDate() no longer auto-carries forward
- // delayed goals, so if we also exclude them here, they become
- // unreachable and silently age into auto-deletion.
- return Task.findById(goal.getTaskId())
- .map(task -> task.getStatus() != TaskStatus.CANCELLED
- && task.getStatus() != TaskStatus.POSTPONED)
- .orElse(false);
- })
- .collect(Collectors.toList());
+ .filter(goal -> taskId.equals(goal.getTaskId()))
+ .toList();
}
/**
- * Reschedules a delayed goal to appear on today's date exactly once.
- * The goal's achieved status is not changed. If the user does not complete
- * it today it will not carry forward again — it is a one-shot reschedule.
+ * Returns active unlinked goals whose latest attempt was missed and which
+ * do not already have a pending attempt.
*
- * @param goalId ID of the goal to reschedule
+ * @return list of unlinked goals the user can choose to retry today
*/
- public void replanGoalForToday(String goalId) {
- StudyGoal.findById(goalId).ifPresent(goal -> {
- if (goal.isAchieved() || goal.getReplannedForDate() != null) {
- return; // Already done or already rescheduled
- }
- goal.setReplannedForDate(dateTimeService.getCurrentDate());
- goal.save();
+ @Transactional(readOnly = true)
+ public List getUnlinkedDelayedGoalsForReplanning() {
+ return StudyGoal.findDelayedAndNotReplanned().stream()
+ .filter(goal -> goal.getTaskId() == null || goal.getTaskId().isBlank())
+ .toList();
+ }
+
+ /**
+ * Creates a new pending attempt for an active missed goal on today's date.
+ * Achieved and abandoned goals are not retryable.
+ *
+ * @param goalId ID of the goal to retry
+ * @return {@code true} when a new pending retry attempt was created
+ */
+ public boolean replanGoalForToday(String goalId) {
+ Optional goalOpt = StudyGoal.findById(goalId);
+ if (goalOpt.isEmpty()) {
+ logger.warn("Requested retry for study goal '{}' but it did not exist", goalId);
+ return false;
+ }
+ StudyGoal goal = goalOpt.get();
+ if (goal.getStatus() == StudyGoal.GoalStatus.ABANDONED || goal.isAchieved()) {
+ return false;
+ }
+ boolean created = StudyGoal.createReplanAttempt(goalId, dateTimeService.getCurrentDate());
+ if (created) {
markDirtyAndSaveLocally("study goal replan");
- logger.info("Rescheduled goal '{}' to appear today ({})", goal.getDescription(), dateTimeService.getCurrentDate());
- });
+ logger.info("Created new attempt for goal '{}' on {}", goal.getDescription(), dateTimeService.getCurrentDate());
+ }
+ return created;
}
- public void updateStudyGoalAchievement(String goalId, boolean achieved, String reasonIfNot) {
+ public boolean planGoalAttempt(String goalId, LocalDate plannedForDate) {
+ if (goalId == null || goalId.isBlank()) {
+ throw ValidationException.requiredFieldMissing("goalId");
+ }
+ if (plannedForDate == null) {
+ throw ValidationException.requiredFieldMissing("plannedForDate");
+ }
+ Optional goalOpt = StudyGoal.findById(goalId);
+ if (goalOpt.isEmpty()) {
+ logger.warn("Requested retry for study goal '{}' but it did not exist", goalId);
+ return false;
+ }
+ StudyGoal goal = goalOpt.get();
+ if (goal.getStatus() == StudyGoal.GoalStatus.ABANDONED || goal.isAchieved()) {
+ return false;
+ }
+ boolean created = StudyGoal.createReplanAttempt(goalId, plannedForDate);
+ if (created) {
+ markDirtyAndSaveLocally("study goal retry planning");
+ logger.info("Created new attempt for goal '{}' on {}", goal.getDescription(), plannedForDate);
+ }
+ return created;
+ }
+
+ public boolean updateStudyGoalDetails(String goalId, String description, LocalDate pendingPlannedForDate) {
+ if (goalId == null || goalId.isBlank()) {
+ throw ValidationException.requiredFieldMissing("goalId");
+ }
+ if (description == null || description.trim().isEmpty()) {
+ throw ValidationException.requiredFieldMissing("description");
+ }
Optional goalOpt = StudyGoal.findById(goalId);
- if (goalOpt.isPresent()) {
- StudyGoal goal = goalOpt.get();
- goal.setAchieved(achieved);
- goal.setReasonIfNotAchieved(reasonIfNot);
- goal.save();
+ if (goalOpt.isEmpty()) {
+ logger.warn("Requested update for study goal '{}' but it did not exist", goalId);
+ return false;
+ }
+ StudyGoal goal = goalOpt.get();
+ if (goal.getAttemptOutcome() == StudyGoal.AttemptOutcome.PENDING && pendingPlannedForDate == null) {
+ throw ValidationException.requiredFieldMissing("plannedForDate");
+ }
+ boolean updated = StudyGoal.updateDetails(goalId, description, pendingPlannedForDate);
+ if (updated) {
+ markDirtyAndSaveLocally("study goal details update");
+ }
+ return updated;
+ }
+
+ public void updateStudyGoalAchievement(String goalId, boolean achieved, String reasonIfNot) {
+ boolean updated = achieved
+ ? StudyGoal.markCurrentAttemptAchieved(goalId, reasonIfNot)
+ : StudyGoal.reopenAchievedGoal(goalId);
+ if (updated) {
markDirtyAndSaveLocally("study goal achievement update");
}
}
/**
- * Soft-deletes a study goal by marking it as failed.
- * The goal is preserved for historical display on its planned date.
+ * Abandons a study goal while preserving its attempt history for display.
+ * Abandoned goals are excluded from future retry planning.
*/
public boolean markGoalAsFailed(String goalId) {
if (goalId == null || goalId.isBlank()) {
throw ValidationException.requiredFieldMissing("goalId");
}
- Optional goalOpt = StudyGoal.findById(goalId);
- if (goalOpt.isPresent()) {
- StudyGoal goal = goalOpt.get();
- goal.setFailed(true);
- goal.setAchieved(false);
- goal.save();
+ boolean abandoned = StudyGoal.abandonGoal(goalId);
+ if (abandoned) {
markDirtyAndSaveLocally("study goal failure update");
- logger.info("Marked study goal '{}' as failed", goalId);
+ logger.info("Abandoned study goal '{}'", goalId);
return true;
} else {
logger.warn("Requested mark-as-failed for study goal '{}' but it did not exist", goalId);
@@ -414,120 +460,35 @@ public Map> getSessionsGroupedByDate(int days) {
// ================================================================
/**
- * Process all goals and update delay penalties for overdue goals.
- * Only unachieved goals with dates in the past are considered delayed.
- * Goals overdue by two weeks or more are marked as FAILED (never deleted).
- * This should be called daily (e.g., via scheduled task or on app startup).
+ * Marks pending goal attempts planned before today as missed.
+ * This should be called daily, such as during startup, so overdue attempts
+ * become retryable without deleting the parent goal or applying legacy delay
+ * penalties.
*
- * @return summary of how many goals were updated or marked failed
+ * @return summary of how many pending attempts were marked missed
*/
public GoalDelayProcessingResult processAllDelayedGoals() {
LocalDate today = dateTimeService.getCurrentDate();
- List allGoals = StudyGoal.findAll();
- int updatedGoals = 0;
- int failedGoals = 0;
-
- for (StudyGoal goal : allGoals) {
- // Skip if goal is achieved or already marked failed
- if (goal.isAchieved() || goal.isFailed()) {
- continue;
- }
-
- // Re-planned goals whose replan date has passed without being achieved
- // must be marked as failed immediately — otherwise they vanish from all
- // views (queries only match date or replanned_for_date equal to display date).
- if (goal.getReplannedForDate() != null && goal.getReplannedForDate().isBefore(today)) {
- String goalLabel = (goal.getDescription() != null && !goal.getDescription().isBlank())
- ? goal.getDescription()
- : goal.getId();
- goal.setFailed(true);
- goal.setDelayed(true);
- int daysFromReplan = (int) ChronoUnit.DAYS.between(goal.getReplannedForDate(), today);
- goal.setDaysDelayed(daysFromReplan);
- goal.setPointsDeducted(calculateAccumulatedPenalty(daysFromReplan));
- goal.save();
- failedGoals++;
- logger.info("Marked re-planned study goal '{}' as FAILED (replan date {} has passed)",
- goalLabel, goal.getReplannedForDate());
- continue;
- }
-
- // Protect replanned goals on their replan date (user still has a chance today).
- if (goal.getReplannedForDate() != null) {
- continue;
- }
+ int missedAttempts = StudyGoal.markPendingAttemptsBefore(today);
- // Check if goal date is in the past (delayed)
- if (goal.getDate().isBefore(today)) {
- int daysDelayed = (int) ChronoUnit.DAYS.between(goal.getDate(), today);
- if (daysDelayed >= 14) {
- String goalLabel = (goal.getDescription() != null && !goal.getDescription().isBlank())
- ? goal.getDescription()
- : goal.getId();
- // Mark as failed instead of deleting — keeps history for logging
- goal.setFailed(true);
- goal.setDelayed(true);
- goal.setDaysDelayed(daysDelayed);
- goal.setPointsDeducted(calculateAccumulatedPenalty(daysDelayed));
- goal.save();
- failedGoals++;
- logger.info("Marked study goal '{}' as FAILED after {} days overdue",
- goalLabel, daysDelayed);
- continue;
- }
-
- int penalty = calculateAccumulatedPenalty(daysDelayed);
-
- // Update goal with delay information
- goal.setDelayed(true);
- goal.setDaysDelayed(daysDelayed);
- goal.setPointsDeducted(penalty);
- goal.save();
-
- updatedGoals++;
- } else {
- // Goal is not delayed - ensure delay flags are cleared
- if (goal.isDelayed()) {
- goal.setDelayed(false);
- goal.setDaysDelayed(0);
- goal.setPointsDeducted(0);
- goal.save();
- updatedGoals++;
- }
- }
- }
-
- if (updatedGoals > 0 || failedGoals > 0) {
+ if (missedAttempts > 0) {
markDirtyAndSaveLocally("delayed goal processing");
+ logger.info("Marked {} overdue study goal attempt(s) as MISSED", missedAttempts);
}
- return new GoalDelayProcessingResult(updatedGoals, failedGoals);
+ return new GoalDelayProcessingResult(missedAttempts);
}
/**
- * Summary of delayed-goal processing.
- * @param updatedGoals number of existing goals updated with delay metadata
- * @param failedGoals number of goals marked as failed because they exceeded the delay threshold
+ * Summary of overdue attempt processing.
+ * @param missedAttempts number of pending attempts marked as missed
*/
- public record GoalDelayProcessingResult(int updatedGoals, int failedGoals) {
+ public record GoalDelayProcessingResult(int missedAttempts) {
public boolean hasChanges() {
- return updatedGoals > 0 || failedGoals > 0;
+ return missedAttempts > 0;
}
}
- /**
- * Calculate accumulated penalty for delayed goals.
- * Formula: 5 points for first delay, then 2 points per additional day.
- *
- * @param totalDaysDelayed total days the goal has been delayed
- * @return total penalty points
- */
- private int calculateAccumulatedPenalty(int totalDaysDelayed) {
- if (totalDaysDelayed <= 0) return 0;
- if (totalDaysDelayed == 1) return 5; // First delay
- return 5 + (totalDaysDelayed - 1) * 2; // Additional days
- }
-
/**
* Get delayed goals for today with their delay information.
*/
@@ -543,13 +504,4 @@ public List getTodayDelayedGoals() {
public List getAllDelayedGoals() {
return StudyGoal.findDelayed();
}
-
- /**
- * Calculate total points deducted from delayed goals.
- */
- @Transactional(readOnly = true)
- public int getTotalDelayPenaltyPoints() {
- List delayedGoals = StudyGoal.findDelayed();
- return delayedGoals.stream().mapToInt(StudyGoal::getPointsDeducted).sum();
- }
}
diff --git a/src/main/java/com/studysync/domain/service/TaskService.java b/src/main/java/com/studysync/domain/service/TaskService.java
index 010c82e..cd9cc59 100644
--- a/src/main/java/com/studysync/domain/service/TaskService.java
+++ b/src/main/java/com/studysync/domain/service/TaskService.java
@@ -651,7 +651,7 @@ public List getMissedRecurringOccurrences(LocalDate today) {
}
if (recurringTaskAppliesTo(task, yesterday, anchorMonday)
- && !StudyGoal.hasAchievedGoalForTask(task.getId(), yesterday)) {
+ && !StudyGoal.hasHandledGoalForTaskOccurrence(task.getId(), yesterday)) {
result.add(new MissedOccurrence(task, yesterday));
}
}
@@ -668,6 +668,6 @@ public List getMissedRecurringOccurrences(LocalDate today) {
* @return {@code true} if the occurrence was handled
*/
public boolean isOccurrenceHandled(Task task, LocalDate date) {
- return StudyGoal.hasAchievedGoalForTask(task.getId(), date);
+ return StudyGoal.hasHandledGoalForTaskOccurrence(task.getId(), date);
}
}
diff --git a/src/main/java/com/studysync/presentation/ui/StudySyncUI.java b/src/main/java/com/studysync/presentation/ui/StudySyncUI.java
index cef4889..3d4a3a5 100644
--- a/src/main/java/com/studysync/presentation/ui/StudySyncUI.java
+++ b/src/main/java/com/studysync/presentation/ui/StudySyncUI.java
@@ -100,7 +100,7 @@ public StudySyncUI(TaskService taskService,
Tab tasksTab = new Tab("Tasks");
tasksTab.setGraphic(TaskStyleUtils.iconLabel("\u2611", 14));
panels.put(tasksTab, new TaskManagementPanel(this.taskService, this.categoryService, this.reminderService,
- this::showModal, this::closeModal));
+ this.studyService, this::showModal, this::closeModal));
panelMap = Collections.unmodifiableMap(panels);
}
@@ -279,11 +279,8 @@ private void markDelayedTasksOnStartup() {
private void processDelayedGoalsOnStartup() {
try {
StudyService.GoalDelayProcessingResult result = studyService.processAllDelayedGoals();
- if (result.updatedGoals() > 0) {
- logger.info("Updated delay status for {} study goals carried over from previous days", result.updatedGoals());
- }
- if (result.failedGoals() > 0) {
- logger.info("Marked {} study goals as FAILED (overdue by at least two weeks)", result.failedGoals());
+ if (result.missedAttempts() > 0) {
+ logger.info("Marked {} overdue study goal attempt(s) as missed", result.missedAttempts());
}
} catch (Exception e) {
logger.error("Failed to process delayed goals on startup", e);
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 9c287ed..04fd029 100644
--- a/src/main/java/com/studysync/presentation/ui/components/CalendarViewPanel.java
+++ b/src/main/java/com/studysync/presentation/ui/components/CalendarViewPanel.java
@@ -234,15 +234,15 @@ private void updateCalendarDisplay() {
int row = 0;
int col = startCol;
- // Batch-query all achieved (task, date) pairs for the visible month
+ // Batch-query all handled (task, date) pairs for the visible month
// to avoid an N+1 SQL query per recurring task per day cell.
LocalDate monthStart = currentMonth.atDay(1);
LocalDate monthEnd = currentMonth.atEndOfMonth();
- java.util.Set achievedPairs = StudyGoal.findAchievedTaskDatePairs(monthStart, monthEnd);
+ java.util.Set handledPairs = StudyGoal.findHandledTaskDatePairs(monthStart, monthEnd);
for (int day = 1; day <= daysInMonth; day++) {
LocalDate date = currentMonth.atDay(day);
- VBox dayCell = createDayCell(date, achievedPairs);
+ VBox dayCell = createDayCell(date, handledPairs);
calendarGrid.add(dayCell, col, row);
@@ -254,7 +254,7 @@ private void updateCalendarDisplay() {
}
}
- private VBox createDayCell(LocalDate date, java.util.Set achievedPairs) {
+ private VBox createDayCell(LocalDate date, java.util.Set handledPairs) {
VBox dayCell = new VBox(5);
dayCell.setPrefSize(CELL_WIDTH, CELL_HEIGHT);
dayCell.setPadding(new Insets(8));
@@ -346,7 +346,7 @@ private VBox createDayCell(LocalDate date, java.util.Set achievedPairs)
if (date.isBefore(today)) {
missedCount = dayData.tasks.stream()
.filter(Task::isRecurring)
- .filter(t -> !achievedPairs.contains(t.getId() + "|" + date))
+ .filter(t -> !handledPairs.contains(t.getId() + "|" + date))
.count();
}
long handledRecurring = dayData.tasks.stream().filter(Task::isRecurring).count() - missedCount;
@@ -632,8 +632,8 @@ private VBox createOverviewTab(LocalDate date, DayData dayData) {
metricsGrid.add(new Label("Points Earned:"), 0, 2);
metricsGrid.add(new Label(String.valueOf(dayData.totalPoints)), 1, 2);
- // Goals
- metricsGrid.add(new Label("Goals Achieved:"), 0, 3);
+ // Goal attempts
+ metricsGrid.add(new Label("Attempts Achieved:"), 0, 3);
metricsGrid.add(new Label(dayData.achievedGoals + "/" + dayData.totalGoals), 1, 3);
// Tasks
@@ -708,7 +708,7 @@ private VBox createGoalsTab(LocalDate date, Tab parentTab) {
HBox headerBox = new HBox(15);
headerBox.setAlignment(Pos.CENTER_LEFT);
- Button addGoalBtn = new Button("+ Add Study Goal");
+ Button addGoalBtn = new Button("+ Add Goal");
addGoalBtn.getStyleClass().add("btn-purple");
addGoalBtn.setStyle("-fx-font-size: 12px; -fx-padding: 8 16;");
addGoalBtn.setOnAction(e -> {
@@ -861,7 +861,7 @@ private VBox createTasksTab(LocalDate date, DayData dayData) {
} else if (TaskStyleUtils.isDueToday(task, date)) {
headerRow.getChildren().add(TaskStyleUtils.createDueTodayBadge());
} else if (task.isRecurring() && date.isBefore(LocalDate.now())
- && !StudyGoal.hasAchievedGoalForTask(task.getId(), date)) {
+ && !StudyGoal.hasHandledGoalForTaskOccurrence(task.getId(), date)) {
headerRow.getChildren().add(TaskStyleUtils.createMissedBadge());
// Red border for missed recurring occurrence
taskBox.setStyle("-fx-background-color: white; -fx-background-radius: 8;" +
@@ -945,7 +945,7 @@ private VBox createPerformanceTab(LocalDate date, DayData dayData) {
// Goal achievement analysis
if (dayData.totalGoals > 0) {
double goalCompletionRate = ((double) dayData.achievedGoals / dayData.totalGoals) * 100;
- Label goalAnalysis = new Label(String.format("Goal Completion: %.0f%% (%d out of %d goals achieved)",
+ Label goalAnalysis = new Label(String.format("Goal Completion: %.0f%% (%d out of %d goals achieved)",
goalCompletionRate, dayData.achievedGoals, dayData.totalGoals));
TaskStyleUtils.fontNormal(goalAnalysis, 12);
goalAnalysis.setTextFill(goalCompletionRate == 100 ? Color.web("#27ae60") : Color.web("#e74c3c"));
@@ -983,7 +983,7 @@ private VBox createRecommendationsSection(DayData dayData) {
}
if (dayData.totalGoals > 0 && dayData.achievedGoals < dayData.totalGoals) {
- recommendations.getChildren().add(createRecommendationLabel("• Some goals were not achieved - review and adjust goal difficulty"));
+ recommendations.getChildren().add(createRecommendationLabel("• Some goals were missed - review and adjust goal difficulty"));
}
if (dayData.productivityScore >= 80) {
@@ -1047,16 +1047,16 @@ private VBox createStudyGoalBox(StudyGoal goal) {
String statusText;
if (goal.isFailed()) {
statusIcon = "\u2715";
- statusText = "Failed";
+ statusText = goal.getStatus() == StudyGoal.GoalStatus.ABANDONED ? "Abandoned Goal" : "Missed Goal";
} else if (goal.isAchieved()) {
statusIcon = "\u2705";
- statusText = "Achieved";
+ statusText = "Achieved Goal";
} else if (goal.isDelayed()) {
statusIcon = "[!] ";
- statusText = "Delayed";
+ statusText = "Retry Goal";
} else {
statusIcon = "\u25CB";
- statusText = "Pending";
+ statusText = "Pending Goal";
}
Label statusLabel = new Label(statusIcon + " " + statusText);
@@ -1069,32 +1069,28 @@ private VBox createStudyGoalBox(StudyGoal goal) {
TaskStyleUtils.fontNormal(descriptionLabel, 13);
descriptionLabel.setWrapText(true);
- goalBox.getChildren().addAll(statusLabel, descriptionLabel);
+ Label attemptLabel = new Label(formatGoalAttemptSummary(goal));
+ TaskStyleUtils.fontNormal(attemptLabel, 11);
+ attemptLabel.setTextFill(goal.getMissedAttemptCount() > 0 ? Color.web("#ff5722") : Color.web("#6c757d"));
- // Add delay info if applicable
- if (goal.isDelayed()) {
- Label delayLabel = new Label(String.format("» Originally from: %s • \u2668 %d days delayed",
- goal.getDate().toString(), goal.getDaysDelayed()));
- TaskStyleUtils.fontNormal(delayLabel, 11);
- delayLabel.setTextFill(Color.web("#ff5722"));
- goalBox.getChildren().add(delayLabel);
- }
+ goalBox.getChildren().addAll(statusLabel, descriptionLabel, attemptLabel);
// Action buttons
HBox actionBox = new HBox(8);
actionBox.setAlignment(Pos.CENTER_RIGHT);
- // "Mark as Failed" button — soft-delete (only for active goals)
+ // "Abandon" button — keeps the attempt timeline but stops future replanning.
if (!goal.isAchieved() && !goal.isFailed()) {
- Button failBtn = new Button("Mark as Failed");
+ Button failBtn = new Button("Abandon Goal");
failBtn.setGraphic(TaskStyleUtils.iconLabel("\u2716", 12));
failBtn.getStyleClass().addAll("btn-warning", "btn-small");
failBtn.setOnAction(e -> {
Alert confirmation = new Alert(Alert.AlertType.CONFIRMATION);
confirmation.initOwner(goalBox.getScene() != null ? goalBox.getScene().getWindow() : null);
- confirmation.setTitle("Mark Goal as Failed");
- confirmation.setHeaderText("Mark this goal as failed?");
- confirmation.setContentText("The goal will be kept in history as failed.\nGoal: " + goal.getDescription());
+ confirmation.setTitle("Abandon Study Goal");
+ confirmation.setHeaderText("Abandon this goal?");
+ confirmation.setContentText("The goal will stop appearing for re-planning, but its attempt history will be kept.\nGoal: "
+ + goal.getDescription());
confirmation.showAndWait().ifPresent(response -> {
if (response == ButtonType.OK) {
@@ -1131,6 +1127,14 @@ private VBox createStudyGoalBox(StudyGoal goal) {
return goalBox;
}
+
+ private String formatGoalAttemptSummary(StudyGoal goal) {
+ String summary = "Attempt " + goal.getAttemptNumber() + " planned for " + goal.getDate();
+ if (goal.getStatus() == StudyGoal.GoalStatus.ABANDONED) {
+ summary += " | parent abandoned";
+ }
+ return summary;
+ }
private VBox createStudySessionBox(StudySession session) {
VBox sessionBox = new VBox(8);
@@ -1262,8 +1266,8 @@ private void showAddGoalDialog(LocalDate date) {
dialog.initOwner(this.getScene() != null ? this.getScene().getWindow() : null);
boolean isFutureDate = date.isAfter(LocalDate.now());
- dialog.setTitle(isFutureDate ? "Plan Study Goal" : "Add Study Goal");
- dialog.setHeaderText("Add a study goal for " + date.format(DateTimeFormatter.ofPattern("EEEE, MMMM dd, yyyy")));
+ dialog.setTitle(isFutureDate ? "Plan Goal" : "Add Goal");
+ dialog.setHeaderText("Add a goal for " + date.format(DateTimeFormatter.ofPattern("EEEE, MMMM dd, yyyy")));
DialogPane dialogPane = dialog.getDialogPane();
dialogPane.getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
@@ -1272,8 +1276,8 @@ private void showAddGoalDialog(LocalDate date) {
content.setPadding(new Insets(20));
Label instructionLabel = new Label(isFutureDate
- ? "Plan what you want to achieve on this day:"
- : "What do you want to achieve today?");
+ ? "Plan what you want to study on this day:"
+ : "What do you want to study today?");
TaskStyleUtils.fontNormal(instructionLabel, 12);
TextArea goalTextArea = new TextArea();
@@ -1309,8 +1313,8 @@ private void showAddGoalDialog(LocalDate date) {
successAlert.initOwner(this.getScene() != null ? this.getScene().getWindow() : null);
successAlert.setTitle("Goal Added");
successAlert.setHeaderText(null);
- successAlert.setContentText("Study goal added successfully for " +
- date.format(DateTimeFormatter.ofPattern("MMMM dd, yyyy")));
+ successAlert.setContentText("Goal added successfully for "
+ + date.format(DateTimeFormatter.ofPattern("MMMM dd, yyyy")));
successAlert.showAndWait();
} catch (Exception e) {
Alert errorAlert = new Alert(Alert.AlertType.ERROR);
@@ -1334,4 +1338,4 @@ public void updateDisplay() {
public Node getView() {
return this;
}
-}
\ No newline at end of file
+}
diff --git a/src/main/java/com/studysync/presentation/ui/components/ProfileViewPanel.java b/src/main/java/com/studysync/presentation/ui/components/ProfileViewPanel.java
index ec9b4d8..e35f1b1 100644
--- a/src/main/java/com/studysync/presentation/ui/components/ProfileViewPanel.java
+++ b/src/main/java/com/studysync/presentation/ui/components/ProfileViewPanel.java
@@ -24,6 +24,7 @@
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
+import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
@@ -97,13 +98,14 @@ private void initializeComponents(VBox mainContent) {
// Statistics cards
HBox statsSection = createStatsSection();
- // Achieved goals section
- VBox achievedGoalsSection = createAchievedGoalsSection();
+ // Goal history section
+ VBox goalHistorySection = createGoalHistorySection();
// Charts section
VBox chartsSection = createChartsSection();
- mainContent.getChildren().addAll(headerLabel, driveSyncSection, profileSection, statsSection, achievedGoalsSection, chartsSection);
+ mainContent.getChildren().addAll(headerLabel, driveSyncSection, profileSection, statsSection,
+ goalHistorySection, chartsSection);
}
private VBox createDriveSyncSection() {
@@ -410,61 +412,64 @@ private HBox createStatsSection() {
return section;
}
- private VBox createAchievedGoalsSection() {
+ private VBox createGoalHistorySection() {
VBox section = new VBox(15);
section.getStyleClass().add("section-card");
HBox header = new HBox(15);
header.setAlignment(Pos.CENTER_LEFT);
- Label sectionTitle = new Label("Achieved Goals");
+ Label sectionTitle = new Label("Goal History");
sectionTitle.setGraphic(TaskStyleUtils.iconLabel("\u2666", 18));
TaskStyleUtils.fontBold(sectionTitle, 18);
- // Get recent achieved goals count
- List allAchievedGoals = studyService.getStudyGoals().stream()
- .filter(StudyGoal::isAchieved)
- .collect(Collectors.toList());
+ List allGoals = getSortedGoalHistory("Newest first");
+ long achievedCount = allGoals.stream().filter(StudyGoal::isAchieved).count();
+ long failedCount = allGoals.stream().filter(StudyGoal::isFailed).count();
+ long activeCount = allGoals.stream()
+ .filter(goal -> !goal.isAchieved() && !goal.isFailed())
+ .count();
- Label countLabel = new Label("(" + allAchievedGoals.size() + " total)");
+ Label countLabel = new Label("(" + achievedCount + " achieved, "
+ + failedCount + " missed/failed, " + activeCount + " active)");
TaskStyleUtils.fontNormal(countLabel, 14);
countLabel.setTextFill(Color.web("#7f8c8d"));
- Button viewAllBtn = new Button("» View All Achieved Goals");
+ Button viewAllBtn = new Button("» View All Goals");
viewAllBtn.getStyleClass().add("btn-success");
- viewAllBtn.setOnAction(e -> showAllAchievedGoalsDialog());
+ viewAllBtn.setOnAction(e -> showAllGoalsDialog());
Region spacer = new Region();
HBox.setHgrow(spacer, Priority.ALWAYS);
header.getChildren().addAll(sectionTitle, countLabel, spacer, viewAllBtn);
- // Show recent achieved goals (last 5)
+ // Show recent goal activity (last 5 attempts, newest first)
VBox recentGoalsContainer = new VBox(8);
- List recentAchievedGoals = allAchievedGoals.stream()
- .sorted((g1, g2) -> g2.getDate().compareTo(g1.getDate())) // Sort by date descending
+ List recentGoals = allGoals.stream()
.limit(5)
.collect(Collectors.toList());
- if (recentAchievedGoals.isEmpty()) {
- Label noGoalsLabel = new Label("No goals achieved yet. Start setting and completing goals to see them here!");
+ if (recentGoals.isEmpty()) {
+ Label noGoalsLabel = new Label("No goals recorded yet.");
TaskStyleUtils.fontNormal(noGoalsLabel, 12);
noGoalsLabel.setTextFill(Color.web("#7f8c8d"));
noGoalsLabel.setPadding(new Insets(10, 0, 0, 0));
recentGoalsContainer.getChildren().add(noGoalsLabel);
} else {
- Label recentLabel = new Label("Recent Achievements:");
+ Label recentLabel = new Label("Recent Goal Activity:");
TaskStyleUtils.fontSemiBold(recentLabel, 12);
recentLabel.setTextFill(Color.web("#2c3e50"));
recentGoalsContainer.getChildren().add(recentLabel);
- for (StudyGoal goal : recentAchievedGoals) {
- HBox goalItem = createAchievedGoalItem(goal);
+ for (StudyGoal goal : recentGoals) {
+ HBox goalItem = createGoalHistoryItem(goal, false);
recentGoalsContainer.getChildren().add(goalItem);
}
- if (allAchievedGoals.size() > 5) {
- Label moreLabel = new Label("... and " + (allAchievedGoals.size() - 5) + " more. Click 'View All' to see them.");
+ if (allGoals.size() > 5) {
+ Label moreLabel = new Label("... and " + (allGoals.size() - 5)
+ + " more. Click 'View All Goals' to filter and sort them.");
TaskStyleUtils.fontItalic(moreLabel, 11);
moreLabel.setTextFill(Color.web("#95a5a6"));
recentGoalsContainer.getChildren().add(moreLabel);
@@ -475,50 +480,60 @@ private VBox createAchievedGoalsSection() {
return section;
}
- private HBox createAchievedGoalItem(StudyGoal goal) {
+ private HBox createGoalHistoryItem(StudyGoal goal, boolean detailed) {
HBox item = new HBox(10);
item.setAlignment(Pos.CENTER_LEFT);
- item.setPadding(new Insets(8, 12, 8, 12));
- item.setStyle("-fx-background-color: #e8f5e8; -fx-background-radius: 5; -fx-border-color: #27ae60; -fx-border-radius: 5;");
-
- Label checkLabel = new Label("\u2713");
- TaskStyleUtils.fontEmoji(checkLabel, 14);
-
- VBox textContainer = new VBox(2);
+ item.setPadding(detailed ? new Insets(10, 15, 10, 15) : new Insets(8, 12, 8, 12));
+ item.setStyle("-fx-background-color: " + goalHistoryBackground(goal)
+ + "; -fx-background-radius: " + (detailed ? "8" : "5")
+ + "; -fx-border-color: " + goalHistoryColor(goal)
+ + "; -fx-border-radius: " + (detailed ? "8" : "5")
+ + "; -fx-border-width: 1;");
+
+ Label statusIcon = new Label(goalHistoryIcon(goal));
+ TaskStyleUtils.fontEmoji(statusIcon, detailed ? 16 : 14);
+ statusIcon.setTextFill(Color.web(goalHistoryColor(goal)));
+
+ VBox textContainer = new VBox(detailed ? 4 : 2);
+ HBox.setHgrow(textContainer, Priority.ALWAYS);
Label descLabel = new Label(goal.getDescription());
- TaskStyleUtils.fontNormal(descLabel, 12);
+ TaskStyleUtils.fontNormal(descLabel, detailed ? 13 : 12);
descLabel.setWrapText(true);
- Label dateLabel = new Label("Achieved on " + goal.getDate().format(DateTimeFormatter.ofPattern("MMM dd, yyyy")));
- TaskStyleUtils.fontNormal(dateLabel, 10);
+ HBox detailsBox = new HBox(15);
+ detailsBox.setAlignment(Pos.CENTER_LEFT);
+
+ Label dateLabel = new Label(goalHistoryDateText(goal, detailed));
+ TaskStyleUtils.fontNormal(dateLabel, detailed ? 11 : 10);
dateLabel.setTextFill(Color.web("#7f8c8d"));
+
+ Label attemptLabel = new Label("Attempt " + goal.getAttemptNumber());
+ TaskStyleUtils.fontNormal(attemptLabel, detailed ? 11 : 10);
+ attemptLabel.setTextFill(Color.web(goalHistoryColor(goal)));
- textContainer.getChildren().addAll(descLabel, dateLabel);
+ detailsBox.getChildren().addAll(dateLabel, attemptLabel);
+ textContainer.getChildren().addAll(descLabel, detailsBox);
- item.getChildren().addAll(checkLabel, textContainer);
+ item.getChildren().addAll(statusIcon, textContainer);
return item;
}
- private void showAllAchievedGoalsDialog() {
+ private void showAllGoalsDialog() {
Dialog dialog = new Dialog<>();
dialog.initOwner(this.getScene() != null ? this.getScene().getWindow() : null);
- dialog.setTitle("All Achieved Goals");
- dialog.setHeaderText("\u2666 Your Achievement History");
+ dialog.setTitle("All Goals");
+ dialog.setHeaderText("\u2666 Goal History");
- VBox content = new VBox(10);
+ VBox content = new VBox(12);
content.setPadding(new Insets(20));
- content.setPrefWidth(600);
- content.setPrefHeight(500);
+ content.setPrefWidth(720);
+ content.setPrefHeight(560);
- // Get all achieved goals sorted by date (most recent first)
- List allAchievedGoals = studyService.getStudyGoals().stream()
- .filter(StudyGoal::isAchieved)
- .sorted((g1, g2) -> g2.getDate().compareTo(g1.getDate()))
- .collect(Collectors.toList());
+ List allGoals = studyService.getStudyGoals();
- if (allAchievedGoals.isEmpty()) {
- Label noGoalsLabel = new Label("You haven't achieved any goals yet.\n\nStart by setting daily study goals and completing them to build your achievement history!");
+ if (allGoals.isEmpty()) {
+ Label noGoalsLabel = new Label("No goals recorded yet.");
TaskStyleUtils.fontNormal(noGoalsLabel, 14);
noGoalsLabel.setTextFill(Color.web("#7f8c8d"));
noGoalsLabel.setWrapText(true);
@@ -526,90 +541,172 @@ private void showAllAchievedGoalsDialog() {
noGoalsLabel.setPadding(new Insets(50, 20, 50, 20));
content.getChildren().add(noGoalsLabel);
} else {
- Label totalLabel = new Label("Total Achieved Goals: " + allAchievedGoals.size());
+ HBox controls = new HBox(10);
+ controls.setAlignment(Pos.CENTER_LEFT);
+
+ ComboBox filterCombo = new ComboBox<>();
+ filterCombo.getItems().addAll("All goals", "Achieved", "Missed / failed", "Active / pending");
+ filterCombo.setValue("All goals");
+
+ ComboBox sortCombo = new ComboBox<>();
+ sortCombo.getItems().addAll("Newest first", "Oldest first", "Status", "Attempt number", "Description");
+ sortCombo.setValue("Newest first");
+
+ Label totalLabel = new Label();
TaskStyleUtils.fontBold(totalLabel, 14);
- totalLabel.setTextFill(Color.web("#27ae60"));
- content.getChildren().add(totalLabel);
+ totalLabel.setTextFill(Color.web("#2c3e50"));
+
+ controls.getChildren().addAll(new Label("Show:"), filterCombo, new Label("Sort:"), sortCombo, totalLabel);
ScrollPane scrollPane = new ScrollPane();
VBox goalsContainer = new VBox(8);
goalsContainer.setPadding(new Insets(10));
-
- // Group goals by month for better organization
- Map> goalsByMonth = allAchievedGoals.stream()
- .collect(Collectors.groupingBy(goal ->
- goal.getDate().format(DateTimeFormatter.ofPattern("MMMM yyyy"))));
-
- for (Map.Entry> monthEntry : goalsByMonth.entrySet()) {
- // Month header
- Label monthLabel = new Label("» " + monthEntry.getKey());
- TaskStyleUtils.fontBold(monthLabel, 16);
- monthLabel.setPadding(new Insets(15, 0, 5, 0));
- goalsContainer.getChildren().add(monthLabel);
-
- // Goals for this month
- for (StudyGoal goal : monthEntry.getValue()) {
- HBox goalItem = createDetailedAchievedGoalItem(goal);
- goalsContainer.getChildren().add(goalItem);
- }
- }
-
+
scrollPane.setContent(goalsContainer);
scrollPane.setFitToWidth(true);
- scrollPane.setPrefHeight(400);
- content.getChildren().add(scrollPane);
+ scrollPane.setPrefHeight(430);
+
+ Runnable refresh = () -> populateGoalHistoryDialog(
+ goalsContainer, totalLabel, allGoals, filterCombo.getValue(), sortCombo.getValue());
+ filterCombo.setOnAction(e -> refresh.run());
+ sortCombo.setOnAction(e -> refresh.run());
+ refresh.run();
+
+ content.getChildren().addAll(controls, scrollPane);
}
dialog.getDialogPane().setContent(content);
dialog.getDialogPane().getButtonTypes().add(ButtonType.CLOSE);
- dialog.getDialogPane().setMinSize(620, 540);
+ dialog.getDialogPane().setMinSize(740, 600);
dialog.setResizable(true);
dialog.setOnShown(e -> {
- dialog.setWidth(640);
- dialog.setHeight(560);
+ dialog.setWidth(760);
+ dialog.setHeight(620);
});
dialog.showAndWait();
}
-
- private HBox createDetailedAchievedGoalItem(StudyGoal goal) {
- HBox item = new HBox(12);
- item.setAlignment(Pos.CENTER_LEFT);
- item.setPadding(new Insets(10, 15, 10, 15));
- item.setStyle("-fx-background-color: #f8f9fa; -fx-background-radius: 8; -fx-border-color: #27ae60; -fx-border-radius: 8; -fx-border-width: 1;");
-
- Label checkLabel = new Label("\u2713");
- TaskStyleUtils.fontEmoji(checkLabel, 16);
-
- VBox textContainer = new VBox(4);
- HBox.setHgrow(textContainer, Priority.ALWAYS);
-
- Label descLabel = new Label(goal.getDescription());
- TaskStyleUtils.fontNormal(descLabel, 13);
- descLabel.setWrapText(true);
-
- HBox detailsBox = new HBox(15);
- detailsBox.setAlignment(Pos.CENTER_LEFT);
-
- Label dateLabel = new Label("» " + goal.getDate().format(DateTimeFormatter.ofPattern("EEE, MMM dd, yyyy")));
- TaskStyleUtils.fontNormal(dateLabel, 11);
- dateLabel.setTextFill(Color.web("#7f8c8d"));
-
- // Show if it was delayed before achievement
- if (goal.isDelayed()) {
- Label delayLabel = new Label("[!] Delayed " + goal.getDaysDelayed() + " day(s)");
- TaskStyleUtils.fontNormal(delayLabel, 11);
- delayLabel.setTextFill(Color.web("#e67e22"));
- detailsBox.getChildren().addAll(dateLabel, delayLabel);
- } else {
- detailsBox.getChildren().add(dateLabel);
+
+ private void populateGoalHistoryDialog(VBox goalsContainer, Label totalLabel,
+ List allGoals, String filter, String sort) {
+ goalsContainer.getChildren().clear();
+ List visibleGoals = allGoals.stream()
+ .filter(goal -> matchesGoalHistoryFilter(goal, filter))
+ .sorted(goalHistoryComparator(sort))
+ .collect(Collectors.toList());
+
+ totalLabel.setText("(" + visibleGoals.size() + " shown)");
+
+ if (visibleGoals.isEmpty()) {
+ Label emptyLabel = new Label("No goals match this filter.");
+ TaskStyleUtils.fontNormal(emptyLabel, 13);
+ emptyLabel.setTextFill(Color.web("#7f8c8d"));
+ emptyLabel.setPadding(new Insets(30));
+ goalsContainer.getChildren().add(emptyLabel);
+ return;
}
-
- textContainer.getChildren().addAll(descLabel, detailsBox);
-
- item.getChildren().addAll(checkLabel, textContainer);
- return item;
+
+ for (StudyGoal goal : visibleGoals) {
+ goalsContainer.getChildren().add(createGoalHistoryItem(goal, true));
+ }
+ }
+
+ private List getSortedGoalHistory(String sort) {
+ return studyService.getStudyGoals().stream()
+ .sorted(goalHistoryComparator(sort))
+ .collect(Collectors.toList());
+ }
+
+ private boolean matchesGoalHistoryFilter(StudyGoal goal, String filter) {
+ return switch (filter) {
+ case "Achieved" -> goal.isAchieved();
+ case "Missed / failed" -> goal.isFailed();
+ case "Active / pending" -> !goal.isAchieved() && !goal.isFailed();
+ default -> true;
+ };
+ }
+
+ private Comparator goalHistoryComparator(String sort) {
+ return switch (sort) {
+ case "Oldest first" -> Comparator
+ .comparing(StudyGoal::getDate)
+ .thenComparingInt(StudyGoal::getAttemptNumber)
+ .thenComparing(goal -> safeText(goal.getDescription()), String.CASE_INSENSITIVE_ORDER);
+ case "Attempt number" -> Comparator
+ .comparingInt(StudyGoal::getAttemptNumber).reversed()
+ .thenComparing(StudyGoal::getDate, Comparator.reverseOrder())
+ .thenComparing(goal -> safeText(goal.getDescription()), String.CASE_INSENSITIVE_ORDER);
+ case "Status" -> Comparator
+ .comparingInt(this::goalHistoryStatusRank)
+ .thenComparing(StudyGoal::getDate, Comparator.reverseOrder())
+ .thenComparing(Comparator.comparingInt(StudyGoal::getAttemptNumber).reversed())
+ .thenComparing(goal -> safeText(goal.getDescription()), String.CASE_INSENSITIVE_ORDER);
+ case "Description" -> Comparator
+ .comparing((StudyGoal goal) -> safeText(goal.getDescription()), String.CASE_INSENSITIVE_ORDER)
+ .thenComparing(StudyGoal::getDate, Comparator.reverseOrder())
+ .thenComparing(Comparator.comparingInt(StudyGoal::getAttemptNumber).reversed());
+ default -> Comparator
+ .comparing(StudyGoal::getDate, Comparator.reverseOrder())
+ .thenComparing(Comparator.comparingInt(StudyGoal::getAttemptNumber).reversed())
+ .thenComparing(goal -> safeText(goal.getDescription()), String.CASE_INSENSITIVE_ORDER);
+ };
+ }
+
+ private int goalHistoryStatusRank(StudyGoal goal) {
+ if (goal.isFailed()) {
+ return 0;
+ }
+ if (goal.isAchieved()) {
+ return 1;
+ }
+ return 2;
+ }
+
+ private String goalHistoryIcon(StudyGoal goal) {
+ if (goal.isAchieved()) {
+ return "\u2713";
+ }
+ if (goal.isFailed()) {
+ return "\u2715";
+ }
+ return "\u25CB";
+ }
+
+ private String goalHistoryColor(StudyGoal goal) {
+ if (goal.isAchieved()) {
+ return "#27ae60";
+ }
+ if (goal.isFailed()) {
+ return "#c0392b";
+ }
+ return "#3498db";
+ }
+
+ private String goalHistoryBackground(StudyGoal goal) {
+ if (goal.isAchieved()) {
+ return "#e8f5e8";
+ }
+ if (goal.isFailed()) {
+ return "#fdecea";
+ }
+ return "#e3f2fd";
+ }
+
+ private String goalHistoryDateText(StudyGoal goal, boolean detailed) {
+ String date = goal.getDate().format(DateTimeFormatter.ofPattern(
+ detailed ? "EEE, MMM dd, yyyy" : "MMM dd, yyyy"));
+ if (goal.isAchieved()) {
+ return "Achieved on " + date;
+ }
+ if (goal.isFailed()) {
+ return (goal.getStatus() == StudyGoal.GoalStatus.ABANDONED ? "Abandoned on " : "Missed on ") + date;
+ }
+ return "Planned for " + date;
+ }
+
+ private String safeText(String text) {
+ return text == null ? "" : text;
}
private VBox createChartsSection() {
@@ -665,8 +762,8 @@ private void updateStatsCards() {
recentSessions.stream().mapToInt(StudySession::getFocusLevel).average().orElse(0);
int achievedGoals = (int) recentGoals.stream().filter(StudyGoal::isAchieved).count();
- int totalGoals = recentGoals.size();
- double goalCompletionRate = totalGoals > 0 ? (double) achievedGoals / totalGoals * 100 : 0;
+ int missedGoals = (int) recentGoals.stream().filter(StudyGoal::isFailed).count();
+ int goalAttemptScore = achievedGoals - missedGoals;
long completedTasks = allTasks.stream().filter(t -> t.getStatus() == TaskStatus.COMPLETED).count();
long totalActiveTasks = allTasks.stream().filter(t -> t.getStatus() != TaskStatus.COMPLETED).count();
@@ -685,7 +782,7 @@ private void updateStatsCards() {
HBox row2 = new HBox(15);
row2.setAlignment(Pos.CENTER);
- VBox goalsCard = createStatCard("Goal Rate", String.format("%.0f%%", goalCompletionRate), "Goals achieved", "#27ae60");
+ VBox goalsCard = createStatCard("Lifetime net", String.format("%+d", goalAttemptScore), "Achieved minus missed goals", "#27ae60");
VBox tasksCard = createStatCard("Tasks Done", String.valueOf(completedTasks), "Completed tasks", "#16a085");
VBox efficiencyCard = createStatCard("Efficiency",
totalMinutes > 0 ? String.format("%.1f", (double) totalPoints / totalMinutes * 60) : "0",
@@ -875,14 +972,16 @@ private double calculateProductivityScore(List sessions) {
double avgMinutesPerDay = totalMinutes / 30.0;
double volumeScore = Math.min(1.0, avgMinutesPerDay / 120.0) * 20; // 2 hours per day = max score
- // Goal achievement score (10% weight) — failed/delayed goals count against the rate
+ // Goal attempt score (10% weight): achieved attempts help, missed attempts hurt.
List goals = studyService.getStudyGoals().stream()
.filter(g -> g.getDate().isAfter(dateTimeService.getCurrentDate().minusDays(30)))
.collect(Collectors.toList());
double goalScore = 0;
if (!goals.isEmpty()) {
long achievedGoals = goals.stream().filter(StudyGoal::isAchieved).count();
- goalScore = ((double) achievedGoals / goals.size()) * 10;
+ long missedGoals = goals.stream().filter(StudyGoal::isFailed).count();
+ double normalized = ((double) (achievedGoals - missedGoals + goals.size())) / (2.0 * goals.size());
+ goalScore = Math.max(0, Math.min(1, normalized)) * 10;
}
return focusScore + consistencyScore + volumeScore + goalScore;
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 e014093..f476402 100644
--- a/src/main/java/com/studysync/presentation/ui/components/StudyPlannerPanel.java
+++ b/src/main/java/com/studysync/presentation/ui/components/StudyPlannerPanel.java
@@ -14,7 +14,6 @@
import com.studysync.domain.valueobject.TaskCategory;
import com.studysync.domain.valueobject.TaskPriority;
import com.studysync.domain.valueobject.TaskStatus;
-import javafx.util.Callback;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.*;
@@ -22,8 +21,10 @@
import javafx.scene.paint.Color;
import javafx.scene.Node;
+import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
+import java.time.temporal.TemporalAdjusters;
import java.util.List;
import java.util.ArrayList;
import java.util.Comparator;
@@ -65,12 +66,23 @@ public class StudyPlannerPanel extends ScrollPane implements RefreshablePanel {
// Tracks which task cards are currently expanded so they survive UI rebuilds
private final Set expandedTaskIds = new HashSet<>();
+ private enum PlannerTaskView {
+ TODAY,
+ ALL_TASKS
+ }
+
// Sort / group state for the tasks section
private String currentSort = "Status";
private String currentGroup = "None";
+ private PlannerTaskView currentTaskView = PlannerTaskView.TODAY;
// UI containers that get rebuilt on navigation
private VBox tasksContainer;
+ private FlowPane attemptOverviewContainer;
+ private Label taskSectionTitle;
+ private List displayDateAttempts = List.of();
+ private Map> displayDateAttemptsByTaskId = Map.of();
+ private Set taskIdsWithAttemptsOnDisplayDate = Set.of();
private FlowPane sessionsFlowPane;
private TextArea reflectionArea;
private ProgressBar dailyProgressBar;
@@ -187,9 +199,9 @@ private void createTasksSection(VBox mainContent) {
HBox sectionHeader = new HBox(15);
sectionHeader.setAlignment(Pos.CENTER_LEFT);
- Label sectionTitle = new Label("Today's Tasks");
- sectionTitle.setGraphic(TaskStyleUtils.iconLabel("\u2611", 18));
- TaskStyleUtils.fontBold(sectionTitle, 18);
+ taskSectionTitle = new Label("Today's Tasks & Goals");
+ taskSectionTitle.setGraphic(TaskStyleUtils.iconLabel("\u2611", 18));
+ TaskStyleUtils.fontBold(taskSectionTitle, 18);
Region spacer = new Region();
HBox.setHgrow(spacer, Priority.ALWAYS);
@@ -198,30 +210,66 @@ private void createTasksSection(VBox mainContent) {
addGoalBtn.getStyleClass().add("btn-purple");
addGoalBtn.setOnAction(e -> showAddGoalDialog(null));
- sectionHeader.getChildren().addAll(sectionTitle, spacer, addGoalBtn);
+ sectionHeader.getChildren().addAll(taskSectionTitle, spacer, addGoalBtn);
+
+ attemptOverviewContainer = new FlowPane();
+ attemptOverviewContainer.setHgap(10);
+ attemptOverviewContainer.setVgap(8);
+ attemptOverviewContainer.setAlignment(Pos.CENTER_LEFT);
// Sort / group toolbar
HBox toolbar = new HBox(10);
toolbar.setAlignment(Pos.CENTER_LEFT);
+ ToggleButton todayViewBtn = new ToggleButton("Today");
+ ToggleButton allTasksViewBtn = new ToggleButton("All Tasks");
+ ToggleGroup viewGroup = new ToggleGroup();
+ todayViewBtn.setToggleGroup(viewGroup);
+ allTasksViewBtn.setToggleGroup(viewGroup);
+ todayViewBtn.setSelected(currentTaskView == PlannerTaskView.TODAY);
+ allTasksViewBtn.setSelected(currentTaskView == PlannerTaskView.ALL_TASKS);
+ todayViewBtn.getStyleClass().addAll("planner-view-toggle", "btn-small");
+ allTasksViewBtn.getStyleClass().addAll("planner-view-toggle", "btn-small");
+ todayViewBtn.setOnAction(e -> {
+ if (!todayViewBtn.isSelected()) {
+ todayViewBtn.setSelected(true);
+ }
+ currentTaskView = PlannerTaskView.TODAY;
+ updateTasksDisplay();
+ });
+ allTasksViewBtn.setOnAction(e -> {
+ if (!allTasksViewBtn.isSelected()) {
+ allTasksViewBtn.setSelected(true);
+ }
+ currentTaskView = PlannerTaskView.ALL_TASKS;
+ updateTasksDisplay();
+ });
+
Label sortLabel = new Label("Sort:");
TaskStyleUtils.fontBold(sortLabel, 12);
ComboBox sortCombo = new ComboBox<>();
sortCombo.getItems().addAll("Status", "Priority", "Deadline", "Title");
sortCombo.setValue(currentSort);
- sortCombo.setOnAction(e -> { currentSort = sortCombo.getValue(); updateTasksDisplay(); });
+ sortCombo.setOnAction(e -> {
+ currentSort = sortCombo.getValue();
+ updateTasksDisplay();
+ });
Label groupLabel = new Label("Group:");
TaskStyleUtils.fontBold(groupLabel, 12);
ComboBox groupCombo = new ComboBox<>();
- groupCombo.getItems().addAll("None", "Status", "Category");
+ groupCombo.getItems().addAll("None", "Status", "Category", "Deadline");
groupCombo.setValue(currentGroup);
- groupCombo.setOnAction(e -> { currentGroup = groupCombo.getValue(); updateTasksDisplay(); });
+ groupCombo.setOnAction(e -> {
+ currentGroup = groupCombo.getValue();
+ updateTasksDisplay();
+ });
- toolbar.getChildren().addAll(sortLabel, sortCombo, groupLabel, groupCombo);
+ toolbar.getChildren().addAll(
+ todayViewBtn, allTasksViewBtn, sortLabel, sortCombo, groupLabel, groupCombo);
tasksContainer = new VBox(10);
- section.getChildren().addAll(sectionHeader, toolbar, tasksContainer);
+ section.getChildren().addAll(sectionHeader, attemptOverviewContainer, toolbar, tasksContainer);
mainContent.getChildren().add(section);
}
@@ -314,14 +362,30 @@ private void createReflectionSection(VBox mainContent) {
private void updateTasksDisplay() {
tasksContainer.getChildren().clear();
- List tasks = new ArrayList<>(taskService.getTasksForDate(displayDate));
+ if (taskSectionTitle != null) {
+ taskSectionTitle.setText(taskSectionTitleText());
+ }
+
+ refreshDisplayDateAttemptCache();
+ List tasks = new ArrayList<>(tasksForCurrentView());
LocalDate today = dateTimeService.getCurrentDate();
+ boolean dateScopedView = currentTaskView == PlannerTaskView.TODAY;
+ if (attemptOverviewContainer != null) {
+ attemptOverviewContainer.setVisible(dateScopedView);
+ attemptOverviewContainer.setManaged(dateScopedView);
+ if (dateScopedView) {
+ updateAttemptOverview();
+ } else {
+ attemptOverviewContainer.getChildren().clear();
+ }
+ }
if (tasks.isEmpty()) {
// No tasks for this day — offer "Create Task" shortcut
VBox emptyBox = new VBox(8);
emptyBox.setAlignment(Pos.CENTER_LEFT);
- Label emptyLabel = new Label("No tasks scheduled for this day.");
+ Label emptyLabel = new Label(dateScopedView
+ ? "No tasks scheduled for this day." : "No active tasks.");
TaskStyleUtils.fontNormal(emptyLabel, 14);
emptyLabel.setTextFill(Color.web("#7f8c8d"));
@@ -331,7 +395,7 @@ private void updateTasksDisplay() {
emptyBox.getChildren().addAll(emptyLabel, createTaskBtn);
tasksContainer.getChildren().add(emptyBox);
- // Do NOT return — unlinked goals and re-plan section must still render
+ // Do NOT return — unlinked goals must still render in the date-scoped view
} else {
// Apply user-selected sort order
tasks.sort(taskSortComparator());
@@ -361,7 +425,7 @@ private void updateTasksDisplay() {
}
// Missed recurring-task occurrences (carry-forward to today)
- if (displayDate.equals(today)) {
+ if (dateScopedView && displayDate.equals(today)) {
List missed = taskService.getMissedRecurringOccurrences(today);
Set shownTaskIds = tasks.stream().map(Task::getId).collect(Collectors.toSet());
LinkedHashMap> byTask = new LinkedHashMap<>();
@@ -389,33 +453,176 @@ private void updateTasksDisplay() {
}
}
- // Unlinked goals section (goals with no task)
- List allUnlinked = StudyGoal.findUnlinkedForDate(displayDate);
- List unlinkedGoals = allUnlinked.stream()
- .filter(g -> !g.isAchieved()).toList();
- List completedUnlinked = allUnlinked.stream()
- .filter(StudyGoal::isAchieved).toList();
- if (!unlinkedGoals.isEmpty() || !completedUnlinked.isEmpty()) {
- VBox unlinkedSection = new VBox(6);
- unlinkedSection.setPadding(new Insets(10, 0, 0, 0));
- Label unlinkedTitle = new Label("Goals without a task:");
- TaskStyleUtils.fontBold(unlinkedTitle, 13);
- unlinkedTitle.setTextFill(Color.web("#6c757d"));
- unlinkedSection.getChildren().add(unlinkedTitle);
- for (StudyGoal goal : unlinkedGoals) {
- unlinkedSection.getChildren().add(buildGoalRow(goal, null));
- }
- if (!completedUnlinked.isEmpty()) {
- unlinkedSection.getChildren().add(
- buildCompletedGoalsSection(completedUnlinked));
+ VBox unlinkedRetrySection = buildUnlinkedRetrySection();
+ if (unlinkedRetrySection != null) {
+ tasksContainer.getChildren().add(unlinkedRetrySection);
+ }
+
+ // Unlinked attempts section (goals with no task)
+ if (dateScopedView) {
+ List allUnlinked = StudyGoal.findUnlinkedForDate(displayDate);
+ List unlinkedGoals = allUnlinked.stream()
+ .filter(g -> !g.isAchieved()).toList();
+ List completedUnlinked = allUnlinked.stream()
+ .filter(StudyGoal::isAchieved).toList();
+ if (!unlinkedGoals.isEmpty() || !completedUnlinked.isEmpty()) {
+ VBox unlinkedSection = new VBox(6);
+ unlinkedSection.setPadding(new Insets(10, 0, 0, 0));
+ Label unlinkedTitle = new Label("Goals without a task:");
+ TaskStyleUtils.fontBold(unlinkedTitle, 13);
+ unlinkedTitle.setTextFill(Color.web("#6c757d"));
+ unlinkedSection.getChildren().add(unlinkedTitle);
+ for (StudyGoal goal : unlinkedGoals) {
+ unlinkedSection.getChildren().add(buildGoalRow(goal, null));
+ }
+ if (!completedUnlinked.isEmpty()) {
+ unlinkedSection.getChildren().add(
+ buildCompletedGoalsSection(completedUnlinked));
+ }
+ tasksContainer.getChildren().add(unlinkedSection);
}
- tasksContainer.getChildren().add(unlinkedSection);
}
+ }
+
+ private void updateAttemptOverview() {
+ if (attemptOverviewContainer == null) {
+ return;
+ }
+ attemptOverviewContainer.getChildren().clear();
+
+ List attempts = displayDateAttempts;
+ long pending = attempts.stream()
+ .filter(goal -> goal.getAttemptOutcome() == StudyGoal.AttemptOutcome.PENDING)
+ .count();
+ long achieved = attempts.stream().filter(StudyGoal::isAchieved).count();
+ long missed = attempts.stream()
+ .filter(goal -> goal.getAttemptOutcome() == StudyGoal.AttemptOutcome.MISSED)
+ .count();
+ long retrying = attempts.stream()
+ .filter(goal -> goal.getAttemptOutcome() == StudyGoal.AttemptOutcome.PENDING
+ && goal.getMissedAttemptCount() > 0)
+ .count();
+ int score = (int) (achieved - missed);
+
+ attemptOverviewContainer.getChildren().addAll(
+ createAttemptMetricCard("Today's net", String.format("%+d", score),
+ TaskStyleUtils.COLOR_PURPLE, TaskStyleUtils.TINT_PURPLE, true),
+ createAttemptMetricCard("Goals", String.valueOf(attempts.size()),
+ TaskStyleUtils.COLOR_PRIMARY, TaskStyleUtils.TINT_NEUTRAL, false),
+ createAttemptMetricCard("Pending", String.valueOf(pending),
+ TaskStyleUtils.COLOR_PRIMARY, TaskStyleUtils.TINT_NEUTRAL, false),
+ createAttemptMetricCard("Retry", String.valueOf(retrying),
+ TaskStyleUtils.COLOR_ORANGE, TaskStyleUtils.TINT_NEUTRAL, false),
+ createAttemptMetricCard("Done", String.valueOf(achieved),
+ TaskStyleUtils.COLOR_SUCCESS, TaskStyleUtils.TINT_NEUTRAL, false),
+ createAttemptMetricCard("Missed", String.valueOf(missed),
+ TaskStyleUtils.COLOR_DANGER, TaskStyleUtils.TINT_NEUTRAL, false)
+ );
+ }
+
+ private VBox createAttemptMetricCard(String title, String value, String textColor,
+ String backgroundColor, boolean primary) {
+ VBox card = new VBox(2);
+ card.setAlignment(Pos.CENTER_LEFT);
+ card.setMinWidth(primary ? 120 : 78);
+ card.setPadding(primary ? new Insets(8, 12, 8, 12) : new Insets(6, 9, 6, 9));
+ card.setStyle("-fx-background-color: " + backgroundColor + "; -fx-background-radius: 8;"
+ + " -fx-border-color: #d6dbe0; -fx-border-radius: 8;");
+
+ Label valueLabel = new Label(value);
+ TaskStyleUtils.fontBold(valueLabel, primary ? 18 : 14);
+ valueLabel.setTextFill(Color.web(textColor));
- // Re-plan section — only available when viewing today
+ Label titleLabel = new Label(title);
+ TaskStyleUtils.fontNormal(titleLabel, 10);
+ titleLabel.setTextFill(Color.web(textColor));
+
+ card.getChildren().addAll(valueLabel, titleLabel);
+ return card;
+ }
+
+ private String taskSectionTitleText() {
+ if (currentTaskView == PlannerTaskView.ALL_TASKS) {
+ return "All Active Tasks";
+ }
+ LocalDate today = dateTimeService.getCurrentDate();
if (displayDate.equals(today)) {
- tasksContainer.getChildren().add(buildReplanSection());
+ return "Today's Tasks & Goals";
}
+ return "Tasks & Goals - " + displayDate.format(DateTimeFormatter.ofPattern("MMM d"));
+ }
+
+ private List getAllAttemptsForDisplayDate() {
+ LocalDate today = dateTimeService.getCurrentDate();
+ if (displayDate.isAfter(today)) {
+ return studyService.getAllGoalsForFutureDate(displayDate);
+ }
+ return studyService.getAllGoalsForDate(displayDate);
+ }
+
+ private void refreshDisplayDateAttemptCache() {
+ displayDateAttempts = getAllAttemptsForDisplayDate();
+ displayDateAttemptsByTaskId = displayDateAttempts.stream()
+ .filter(goal -> goal.getTaskId() != null && !goal.getTaskId().isBlank())
+ .collect(Collectors.groupingBy(
+ StudyGoal::getTaskId,
+ LinkedHashMap::new,
+ Collectors.toList()));
+ taskIdsWithAttemptsOnDisplayDate = new HashSet<>(displayDateAttemptsByTaskId.keySet());
+ }
+
+ private List tasksForCurrentView() {
+ if (currentTaskView == PlannerTaskView.ALL_TASKS) {
+ return taskService.getTasks().stream()
+ .filter(this::isActivePlannerTask)
+ .toList();
+ }
+ return taskService.getTasksForDate(displayDate);
+ }
+
+ private boolean isActivePlannerTask(Task task) {
+ TaskStatus status = task.getStatus();
+ return status == TaskStatus.OPEN
+ || status == TaskStatus.IN_PROGRESS
+ || status == TaskStatus.DELAYED;
+ }
+
+ private boolean surfacesByDateRules(Task task, LocalDate date) {
+ if (task == null || date == null) {
+ return false;
+ }
+
+ TaskStatus status = task.getStatus();
+ boolean active = status == TaskStatus.OPEN || status == TaskStatus.IN_PROGRESS;
+ boolean delayed = status == TaskStatus.DELAYED;
+
+ if (task.isRecurring()) {
+ if (!active) {
+ return false;
+ }
+ if (task.getStartDate() != null && date.isBefore(task.getStartDate())) {
+ return false;
+ }
+ if (task.getDeadline() != null && date.isAfter(task.getDeadline())) {
+ return false;
+ }
+ LocalDate anchorMonday = task.getRecurrenceAnchor()
+ .with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
+ return taskService.recurringTaskAppliesTo(task, date, anchorMonday);
+ }
+
+ if (!(active || delayed)) {
+ return false;
+ }
+ LocalDate deadline = task.getDeadline();
+ if (deadline == null) {
+ return date.equals(dateTimeService.getCurrentDate());
+ }
+ return !date.isBefore(deadline);
+ }
+
+ private boolean hasGoalOnDisplayDate(Task task) {
+ return task != null && taskIdsWithAttemptsOnDisplayDate.contains(task.getId());
}
/** Returns a comparator based on the user's current sort choice. */
@@ -451,104 +658,33 @@ private String groupKeyFor(Task task) {
case "Status" -> task.getStatus().name();
case "Category" -> task.getCategory() != null && !task.getCategory().isBlank()
? task.getCategory() : "Uncategorized";
+ case "Deadline", "Reason" -> reasonGroupKeyFor(task);
default -> "";
};
}
- /**
- * Builds the "Re-plan a delayed goal for today" section.
- * Shows a ComboBox of delayed goals (from non-cancelled/non-postponed tasks)
- * and a button to reschedule the selected goal to appear today exactly once.
- */
- private VBox buildReplanSection() {
- List candidates = studyService.getDelayedGoalsForReplanning();
-
- VBox section = new VBox(8);
- section.setPadding(new Insets(14, 0, 0, 0));
-
- // Collapsible header row
- Label header = new Label("Re-plan a Delayed Goal");
- header.setGraphic(TaskStyleUtils.iconLabel("\u21BA", 13));
- TaskStyleUtils.fontBold(header, 13);
- header.setTextFill(Color.web("#6c757d"));
-
- if (candidates.isEmpty()) {
- Label none = new Label("No delayed goals available to re-plan.");
- TaskStyleUtils.fontNormal(none, 12);
- none.setTextFill(Color.web("#7f8c8d"));
- section.getChildren().addAll(header, none);
- return section;
+ private String reasonGroupKeyFor(Task task) {
+ if (task.isRecurring() && surfacesByDateRules(task, displayDate)) {
+ return "Recurring";
}
-
- // Preload task titles once to avoid DB lookups during ComboBox cell rendering.
- Map taskTitles = new LinkedHashMap<>();
- for (StudyGoal goal : candidates) {
- String taskId = goal.getTaskId();
- if (taskId == null || taskId.isBlank() || taskTitles.containsKey(taskId)) {
- continue;
- }
- Task.findById(taskId).ifPresent(t -> taskTitles.put(taskId, t.getTitle()));
+ LocalDate deadline = task.getDeadline();
+ if (deadline != null && deadline.equals(displayDate) && surfacesByDateRules(task, displayDate)) {
+ return "Due";
}
-
- ComboBox combo = new ComboBox<>();
- combo.getItems().addAll(candidates);
- combo.setVisibleRowCount(Math.min(candidates.size(), 15));
- combo.setMaxWidth(Double.MAX_VALUE);
- Callback, ListCell> cellFactory = lv -> new ListCell<>() {
- @Override
- protected void updateItem(StudyGoal goal, boolean empty) {
- super.updateItem(goal, empty);
- if (empty || goal == null) {
- setText(null);
- setGraphic(null);
- } else {
- setText(formatDelayedGoal(goal, taskTitles));
- setGraphic(null);
- }
- }
- };
- combo.setCellFactory(cellFactory);
- combo.setButtonCell(cellFactory.call(null));
- combo.setPromptText("Select a delayed goal to re-plan...");
-
- Button replanBtn = new Button("Re-plan for Today");
- replanBtn.setGraphic(TaskStyleUtils.iconLabel("\u21BA", 13));
- replanBtn.getStyleClass().addAll("btn-orange", "btn-small");
- replanBtn.setDisable(true);
-
- combo.setOnAction(e -> replanBtn.setDisable(combo.getValue() == null));
-
- replanBtn.setOnAction(e -> {
- StudyGoal selected = combo.getValue();
- if (selected == null) return;
- studyService.replanGoalForToday(selected.getId());
- updateTasksDisplay();
- updateProgress();
- });
-
- HBox controls = new HBox(8, combo, replanBtn);
- controls.setAlignment(Pos.CENTER_LEFT);
- HBox.setHgrow(combo, Priority.ALWAYS);
-
- section.getChildren().addAll(header, controls);
- return section;
- }
-
- /** Formats a delayed goal for display in the re-plan ComboBox. */
- private String formatDelayedGoal(StudyGoal goal, Map taskTitles) {
- String taskLabel = "";
- String taskId = goal.getTaskId();
- if (taskId != null && !taskId.isBlank()) {
- String title = taskTitles.get(taskId);
- if (title != null && !title.isBlank()) {
- taskLabel = title + ": ";
- }
+ if (deadline != null && deadline.isBefore(displayDate) && surfacesByDateRules(task, displayDate)) {
+ return "Overdue";
+ }
+ if (hasGoalOnDisplayDate(task)) {
+ return "Has goal";
}
- String desc = goal.getDescription();
- if (desc != null && desc.length() > 60) {
- desc = desc.substring(0, 57) + "...";
+ if (currentTaskView == PlannerTaskView.TODAY
+ && !task.isRecurring()
+ && deadline == null
+ && isActivePlannerTask(task)
+ && displayDate.equals(dateTimeService.getCurrentDate())) {
+ return "Open (no deadline)";
}
- return taskLabel + desc + " (" + goal.getDaysDelayed() + "d delayed)";
+ return "Other active tasks";
}
/**
@@ -573,6 +709,9 @@ private VBox buildTaskRow(Task task) {
Label taskTitle = new Label(task.getTitle());
TaskStyleUtils.fontBold(taskTitle, 14);
+ taskTitle.setWrapText(true);
+ taskTitle.setMaxWidth(Double.MAX_VALUE);
+ HBox.setHgrow(taskTitle, Priority.ALWAYS);
Label priorityLabel = new Label(task.getPriority() != null ? task.getPriority().toString() : "");
priorityLabel.setTextFill(Color.web("#f39c12"));
@@ -584,17 +723,17 @@ private VBox buildTaskRow(Task task) {
TaskStyleUtils.fontBold(statusBadge, 10);
statusBadge.setTextFill(TaskStyleUtils.statusTextColor(task.getStatus()));
+ Region spacer = new Region();
+ HBox.setHgrow(spacer, Priority.ALWAYS);
+
+ headerRow.getChildren().add(arrow);
if (task.isRecurring()) {
Label recurBadge = TaskStyleUtils.iconLabel("\u21BA", 12);
recurBadge.setTooltip(new Tooltip(task.getRecurringSummary()));
headerRow.getChildren().add(recurBadge);
}
-
- Region spacer = new Region();
- HBox.setHgrow(spacer, Priority.ALWAYS);
-
- headerRow.getChildren().addAll(0, List.of(arrow)); // arrow first
- headerRow.getChildren().addAll(taskTitle, priorityLabel, spacer);
+ headerRow.getChildren().addAll(taskTitle, priorityLabel);
+ headerRow.getChildren().add(spacer);
// Overdue / due-today badge (between spacer and status badge)
if (TaskStyleUtils.isOverdue(task, displayDate)) {
@@ -605,6 +744,14 @@ private VBox buildTaskRow(Task task) {
headerRow.getChildren().add(statusBadge);
+ List