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: - *

    - *
  1. Creation: Goal is set with a description and current date
  2. - *
  3. Tracking: User works toward achieving the goal throughout the day
  4. - *
  5. Evaluation: At day's end, goal is marked achieved or not achieved
  6. - *
  7. Reflection: If not achieved, user can record reasons for learning
  8. - *

- * - *

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: - *

- * - *

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.

- */ + private GoalStatus status = GoalStatus.ACTIVE; + private boolean abandonedExplicitly; + private String achievedAttemptId; + private String attemptId; + private String replannedFromAttemptId; + private AttemptOutcome attemptOutcome = AttemptOutcome.PENDING; + private LocalDateTime outcomeAt; + private int attemptNumber = 1; + private int missedAttemptCount = 0; + + public static void setJdbcTemplate(JdbcTemplate template) { + jdbcTemplate = template; + } + public StudyGoal() { - this.id = java.util.UUID.randomUUID().toString(); + this.id = UUID.randomUUID().toString(); this.date = LocalDate.now(); - this.achieved = false; - this.daysDelayed = 0; - this.isDelayed = false; - this.pointsDeducted = 0; - this.failed = false; - } - - /** - * Creates a study goal with the specified description. - * - *

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