From e82864ed5d2ecb3e28149588d2dd48e90534578f Mon Sep 17 00:00:00 2001 From: geokoko Date: Wed, 29 Apr 2026 19:34:17 +0300 Subject: [PATCH 1/9] feat: model study goals as per-attempt history --- .../studysync/domain/entity/StudyGoal.java | 1186 +++++++---------- .../domain/service/StudyService.java | 116 +- .../presentation/ui/StudySyncUI.java | 4 +- .../ui/components/CalendarViewPanel.java | 23 +- .../ui/components/ProfileViewPanel.java | 18 +- .../ui/components/StudyPlannerPanel.java | 24 +- .../ui/components/TaskManagementPanel.java | 10 +- src/main/resources/schema.sql | 120 +- .../service/StudyServicePersistenceTest.java | 50 + 9 files changed, 758 insertions(+), 793 deletions(-) diff --git a/src/main/java/com/studysync/domain/entity/StudyGoal.java b/src/main/java/com/studysync/domain/entity/StudyGoal.java index 3999961..ad9956f 100644 --- a/src/main/java/com/studysync/domain/entity/StudyGoal.java +++ b/src/main/java/com/studysync/domain/entity/StudyGoal.java @@ -1,205 +1,156 @@ - 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, + 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 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 +161,559 @@ public StudyGoal(String id, LocalDate date, String description, boolean achieved this.taskId = taskId; this.replannedForDate = replannedForDate; this.failed = failed; + this.status = failed ? GoalStatus.ABANDONED : 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, 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.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.status = GoalStatus.ABANDONED; + 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 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 = failed ? 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(); + } + 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, achieved_attempt_id, updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + """; + jdbcTemplate.update(sql, + id, date, description, status == GoalStatus.ACHIEVED, reasonIfNotAchieved, + daysDelayed, isDelayed, pointsDeducted, taskId, + replannedForDate, status == GoalStatus.ABANDONED, status.name(), 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 * FROM study_goals - WHERE task_id = ? - ORDER BY date DESC, created_at DESC + String sql = SELECT_ATTEMPT_VIEW + """ + WHERE g.task_id = ? + ORDER BY a.planned_for_date DESC, a.created_at DESC """; - 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) { + return jdbcTemplate.query(sql, getAttemptViewMapper(), taskId); + } + + public static Set findAchievedTaskDatePairs(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 <= ? + 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 <= ? """; - 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 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 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, + 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.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..31b4bd8 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; @@ -248,23 +247,22 @@ public List getDelayedGoalsForReplanning() { */ public void replanGoalForToday(String goalId) { StudyGoal.findById(goalId).ifPresent(goal -> { - if (goal.isAchieved() || goal.getReplannedForDate() != null) { - return; // Already done or already rescheduled + if (goal.isAchieved()) { + return; // Already done + } + boolean created = StudyGoal.createReplanAttempt(goalId, dateTimeService.getCurrentDate()); + if (created) { + markDirtyAndSaveLocally("study goal replan"); + logger.info("Created new attempt for goal '{}' on {}", goal.getDescription(), dateTimeService.getCurrentDate()); } - goal.setReplannedForDate(dateTimeService.getCurrentDate()); - goal.save(); - markDirtyAndSaveLocally("study goal replan"); - logger.info("Rescheduled goal '{}' to appear today ({})", goal.getDescription(), dateTimeService.getCurrentDate()); }); } public void updateStudyGoalAchievement(String goalId, boolean achieved, String reasonIfNot) { - Optional goalOpt = StudyGoal.findById(goalId); - if (goalOpt.isPresent()) { - StudyGoal goal = goalOpt.get(); - goal.setAchieved(achieved); - goal.setReasonIfNotAchieved(reasonIfNot); - goal.save(); + boolean updated = achieved + ? StudyGoal.markCurrentAttemptAchieved(goalId, reasonIfNot) + : StudyGoal.reopenAchievedGoal(goalId); + if (updated) { markDirtyAndSaveLocally("study goal achievement update"); } } @@ -277,14 +275,10 @@ 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); @@ -423,85 +417,14 @@ public Map> getSessionsGroupedByDate(int days) { */ 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; - } - - // 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++; - } - } - } + int missedAttempts = StudyGoal.markPendingAttemptsBefore(today); - 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, 0); } /** @@ -549,7 +472,6 @@ public List getAllDelayedGoals() { */ @Transactional(readOnly = true) public int getTotalDelayPenaltyPoints() { - List delayedGoals = StudyGoal.findDelayed(); - return delayedGoals.stream().mapToInt(StudyGoal::getPointsDeducted).sum(); + return StudyGoal.findDelayed().size(); } } diff --git a/src/main/java/com/studysync/presentation/ui/StudySyncUI.java b/src/main/java/com/studysync/presentation/ui/StudySyncUI.java index cef4889..b4adcf9 100644 --- a/src/main/java/com/studysync/presentation/ui/StudySyncUI.java +++ b/src/main/java/com/studysync/presentation/ui/StudySyncUI.java @@ -280,10 +280,10 @@ 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()); + logger.info("Marked {} overdue study goal attempt(s) as missed", result.updatedGoals()); } if (result.failedGoals() > 0) { - logger.info("Marked {} study goals as FAILED (overdue by at least two weeks)", result.failedGoals()); + logger.info("Abandoned {} study goal(s)", result.failedGoals()); } } 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..ec244ca 100644 --- a/src/main/java/com/studysync/presentation/ui/components/CalendarViewPanel.java +++ b/src/main/java/com/studysync/presentation/ui/components/CalendarViewPanel.java @@ -1047,13 +1047,13 @@ private VBox createStudyGoalBox(StudyGoal goal) { String statusText; if (goal.isFailed()) { statusIcon = "\u2715"; - statusText = "Failed"; + statusText = goal.getStatus() == StudyGoal.GoalStatus.ABANDONED ? "Abandoned" : "Missed"; } else if (goal.isAchieved()) { statusIcon = "\u2705"; statusText = "Achieved"; } else if (goal.isDelayed()) { statusIcon = "[!] "; - statusText = "Delayed"; + statusText = "Attempt " + goal.getAttemptNumber(); } else { statusIcon = "\u25CB"; statusText = "Pending"; @@ -1073,8 +1073,10 @@ private VBox createStudyGoalBox(StudyGoal goal) { // 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())); + Label delayLabel = new Label(String.format("» Attempt %d • %d prior miss%s", + goal.getAttemptNumber(), + goal.getMissedAttemptCount(), + goal.getMissedAttemptCount() == 1 ? "" : "es")); TaskStyleUtils.fontNormal(delayLabel, 11); delayLabel.setTextFill(Color.web("#ff5722")); goalBox.getChildren().add(delayLabel); @@ -1084,17 +1086,18 @@ private VBox createStudyGoalBox(StudyGoal goal) { 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) { @@ -1334,4 +1337,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..97b3ca8 100644 --- a/src/main/java/com/studysync/presentation/ui/components/ProfileViewPanel.java +++ b/src/main/java/com/studysync/presentation/ui/components/ProfileViewPanel.java @@ -596,9 +596,11 @@ private HBox createDetailedAchievedGoalItem(StudyGoal goal) { TaskStyleUtils.fontNormal(dateLabel, 11); dateLabel.setTextFill(Color.web("#7f8c8d")); - // Show if it was delayed before achievement + // Show attempt history if achievement followed one or more misses. if (goal.isDelayed()) { - Label delayLabel = new Label("[!] Delayed " + goal.getDaysDelayed() + " day(s)"); + Label delayLabel = new Label("[!] Attempt " + goal.getAttemptNumber() + + " after " + goal.getMissedAttemptCount() + " miss" + + (goal.getMissedAttemptCount() == 1 ? "" : "es")); TaskStyleUtils.fontNormal(delayLabel, 11); delayLabel.setTextFill(Color.web("#e67e22")); detailsBox.getChildren().addAll(dateLabel, delayLabel); @@ -665,8 +667,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 +687,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("Goal Score", String.format("%+d", goalAttemptScore), "Achieved minus missed attempts", "#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 +877,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..e17f095 100644 --- a/src/main/java/com/studysync/presentation/ui/components/StudyPlannerPanel.java +++ b/src/main/java/com/studysync/presentation/ui/components/StudyPlannerPanel.java @@ -548,7 +548,9 @@ private String formatDelayedGoal(StudyGoal goal, Map taskTitles) if (desc != null && desc.length() > 60) { desc = desc.substring(0, 57) + "..."; } - return taskLabel + desc + " (" + goal.getDaysDelayed() + "d delayed)"; + int misses = goal.getMissedAttemptCount(); + String missLabel = misses == 1 ? "1 prior miss" : misses + " prior misses"; + return taskLabel + desc + " (attempt " + (goal.getAttemptNumber() + 1) + ", " + missLabel + ")"; } /** @@ -750,8 +752,10 @@ private HBox buildGoalRow(StudyGoal goal, Task linkedTask) { textBox.getChildren().add(goalLabel); if (goal.isDelayed()) { - Label delayLabel = new Label(String.format("Delayed %d day(s) — -%d pts", - goal.getDaysDelayed(), goal.getPointsDeducted())); + Label delayLabel = new Label(String.format("Attempt %d (%d prior miss%s)", + goal.getAttemptNumber(), + goal.getMissedAttemptCount(), + goal.getMissedAttemptCount() == 1 ? "" : "es")); delayLabel.setStyle("-fx-text-fill: #dc3545;"); TaskStyleUtils.fontNormal(delayLabel, 11); textBox.getChildren().add(delayLabel); @@ -803,7 +807,19 @@ private VBox buildCompletedGoalsSection(List completedGoals) { label.setStyle("-fx-strikethrough: true; -fx-text-fill: #7f8c8d;"); TaskStyleUtils.fontNormal(label, 12); - row.getChildren().addAll(check, label); + VBox textBox = new VBox(2); + textBox.getChildren().add(label); + if (goal.getMissedAttemptCount() > 0) { + Label attempts = new Label(String.format("Achieved on attempt %d after %d miss%s", + goal.getAttemptNumber(), + goal.getMissedAttemptCount(), + goal.getMissedAttemptCount() == 1 ? "" : "es")); + attempts.setTextFill(Color.web("#7f8c8d")); + TaskStyleUtils.fontNormal(attempts, 10); + textBox.getChildren().add(attempts); + } + + row.getChildren().addAll(check, textBox); itemsBox.getChildren().add(row); } diff --git a/src/main/java/com/studysync/presentation/ui/components/TaskManagementPanel.java b/src/main/java/com/studysync/presentation/ui/components/TaskManagementPanel.java index 0d98c3b..42ce77e 100644 --- a/src/main/java/com/studysync/presentation/ui/components/TaskManagementPanel.java +++ b/src/main/java/com/studysync/presentation/ui/components/TaskManagementPanel.java @@ -403,12 +403,12 @@ private void populateGoalHistory(VBox pane, Task task) { // Summary stats long achieved = goals.stream().filter(StudyGoal::isAchieved).count(); - long failed = goals.stream().filter(StudyGoal::isFailed).count(); + long missed = goals.stream().filter(StudyGoal::isFailed).count(); long active = goals.stream().filter(g -> !g.isAchieved() && !g.isFailed()).count(); Label statsLabel = new Label("Total: " + goals.size() + " | Achieved: " + achieved - + " | Failed: " + failed + + " | Missed: " + missed + " | Active: " + active); TaskStyleUtils.fontSemiBold(statsLabel, 11); statsLabel.setTextFill(Color.web("#495057")); @@ -469,11 +469,13 @@ private void populateGoalHistory(VBox pane, Task task) { badge.setStyle("-fx-background-color: #e8f5e9; -fx-background-radius: 8; -fx-padding: 1 6;"); badge.setTextFill(Color.web("#1b5e20")); } else if (goal.isFailed()) { - badge = new Label("Failed"); + badge = new Label(goal.getStatus() == StudyGoal.GoalStatus.ABANDONED ? "Abandoned" : "Missed"); badge.setStyle("-fx-background-color: #ffebee; -fx-background-radius: 8; -fx-padding: 1 6;"); badge.setTextFill(Color.web("#b71c1c")); } else if (goal.isDelayed()) { - badge = new Label("Delayed (" + goal.getDaysDelayed() + "d)"); + badge = new Label("Attempt " + goal.getAttemptNumber() + + " (" + goal.getMissedAttemptCount() + " miss" + + (goal.getMissedAttemptCount() == 1 ? "" : "es") + ")"); badge.setStyle("-fx-background-color: #fff3e0; -fx-background-radius: 8; -fx-padding: 1 6;"); badge.setTextFill(Color.web("#e65100")); } else { diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index de6605c..7a1f422 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -107,11 +107,30 @@ CREATE TABLE IF NOT EXISTS study_goals ( points_deducted INTEGER DEFAULT 0, task_id VARCHAR(50), replanned_for_date DATE, + status VARCHAR(20) DEFAULT 'ACTIVE', + achieved_attempt_id VARCHAR(50), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE SET NULL ); +-- =================================== +-- Study Goal Attempts Table +-- =================================== +CREATE TABLE IF NOT EXISTS study_goal_attempts ( + id VARCHAR(50) PRIMARY KEY, + goal_id VARCHAR(50) NOT NULL, + planned_for_date DATE NOT NULL, + replanned_from_attempt_id VARCHAR(50), + outcome VARCHAR(20) DEFAULT 'PENDING', + reason_if_not_achieved TEXT, + outcome_at TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (goal_id) REFERENCES study_goals(id) ON DELETE CASCADE, + FOREIGN KEY (replanned_from_attempt_id) REFERENCES study_goal_attempts(id) ON DELETE SET NULL +); + -- =================================== -- Daily Reflections Table -- =================================== @@ -163,6 +182,9 @@ CREATE INDEX IF NOT EXISTS idx_project_sessions_project_id ON project_sessions(p CREATE INDEX IF NOT EXISTS idx_study_goals_date ON study_goals(date); CREATE INDEX IF NOT EXISTS idx_study_goals_is_delayed ON study_goals(is_delayed); +CREATE INDEX IF NOT EXISTS idx_study_goal_attempts_goal_id ON study_goal_attempts(goal_id); +CREATE INDEX IF NOT EXISTS idx_study_goal_attempts_date ON study_goal_attempts(planned_for_date); +CREATE INDEX IF NOT EXISTS idx_study_goal_attempts_outcome ON study_goal_attempts(outcome); CREATE INDEX IF NOT EXISTS idx_daily_reflections_date ON daily_reflections(date); -- =================================== @@ -182,4 +204,100 @@ ALTER TABLE study_goals ADD COLUMN IF NOT EXISTS replanned_for_date DATE; -- Add failed flag to study_goals. Failed goals are kept for historical logging -- but excluded from active planner views. -ALTER TABLE study_goals ADD COLUMN IF NOT EXISTS failed BOOLEAN DEFAULT FALSE; \ No newline at end of file +ALTER TABLE study_goals ADD COLUMN IF NOT EXISTS failed BOOLEAN DEFAULT FALSE; + +-- Add per-attempt goal lifecycle columns. Legacy columns remain intentionally: +-- schema.sql is re-run on every startup, so destructive DROP COLUMN migrations +-- would make the compatibility backfill below unsafe on later launches. +ALTER TABLE study_goals ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'ACTIVE'; +ALTER TABLE study_goals ADD COLUMN IF NOT EXISTS achieved_attempt_id VARCHAR(50); + +CREATE INDEX IF NOT EXISTS idx_study_goals_status ON study_goals(status); + +-- Backfill the first attempt from the legacy study_goals row. Existing attempt +-- rows are left untouched so this migration is safe to re-run. +INSERT INTO study_goal_attempts ( + id, goal_id, planned_for_date, replanned_from_attempt_id, outcome, + reason_if_not_achieved, outcome_at, created_at, updated_at +) +SELECT + id || '-attempt-1', + id, + date, + NULL, + CASE + WHEN replanned_for_date IS NOT NULL THEN 'MISSED' + WHEN failed = TRUE THEN 'MISSED' + WHEN achieved = TRUE THEN 'ACHIEVED' + ELSE 'PENDING' + END, + reason_if_not_achieved, + CASE + WHEN replanned_for_date IS NOT NULL OR failed = TRUE OR achieved = TRUE THEN updated_at + ELSE NULL + END, + created_at, + updated_at +FROM study_goals g +WHERE NOT EXISTS ( + SELECT 1 FROM study_goal_attempts a WHERE a.goal_id = g.id +); + +-- Backfill the explicit re-plan attempt when the legacy row had one. If the +-- re-planned goal was eventually achieved, the achieved outcome belongs to the +-- re-plan date, while the original date remains a missed attempt. +INSERT INTO study_goal_attempts ( + id, goal_id, planned_for_date, replanned_from_attempt_id, outcome, + reason_if_not_achieved, outcome_at, created_at, updated_at +) +SELECT + id || '-attempt-2', + id, + replanned_for_date, + id || '-attempt-1', + CASE + WHEN achieved = TRUE THEN 'ACHIEVED' + WHEN failed = TRUE THEN 'MISSED' + ELSE 'PENDING' + END, + reason_if_not_achieved, + CASE + WHEN achieved = TRUE OR failed = TRUE THEN updated_at + ELSE NULL + END, + updated_at, + updated_at +FROM study_goals g +WHERE replanned_for_date IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM study_goal_attempts a WHERE a.id = g.id || '-attempt-2' + ); + +UPDATE study_goals g +SET achieved_attempt_id = ( + SELECT a.id + FROM study_goal_attempts a + WHERE a.goal_id = g.id AND a.outcome = 'ACHIEVED' + ORDER BY a.planned_for_date DESC, a.created_at DESC + LIMIT 1 +) +WHERE g.achieved_attempt_id IS NULL + AND EXISTS ( + SELECT 1 FROM study_goal_attempts a + WHERE a.goal_id = g.id AND a.outcome = 'ACHIEVED' + ); + +UPDATE study_goals g +SET status = 'ACHIEVED' +WHERE EXISTS ( + SELECT 1 FROM study_goal_attempts a + WHERE a.goal_id = g.id AND a.outcome = 'ACHIEVED' +); + +UPDATE study_goals g +SET status = 'ABANDONED' +WHERE failed = TRUE + AND NOT EXISTS ( + SELECT 1 FROM study_goal_attempts a + WHERE a.goal_id = g.id AND a.outcome IN ('PENDING', 'ACHIEVED') + ); diff --git a/src/test/java/com/studysync/domain/service/StudyServicePersistenceTest.java b/src/test/java/com/studysync/domain/service/StudyServicePersistenceTest.java index 84fef36..6d5566f 100644 --- a/src/test/java/com/studysync/domain/service/StudyServicePersistenceTest.java +++ b/src/test/java/com/studysync/domain/service/StudyServicePersistenceTest.java @@ -147,10 +147,45 @@ void updateStudyGoalAchievementFlushesCompletedGoalLocally() { StudyGoal stored = StudyGoal.findById(goal.getId()).orElseThrow(); assertTrue(stored.isAchieved()); + Integer achievedAttempts = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM study_goal_attempts WHERE goal_id = ? AND outcome = 'ACHIEVED'", + Integer.class, + goal.getId()); + assertEquals(1, achievedAttempts); + verify(googleDriveService).markLocalDbDirty(); verify(googleDriveService).saveLocally(); } + @Test + void overdueGoalAttemptBecomesMissedAndCanBeReplanned() { + StudyGoal goal = new StudyGoal("Review missed topic"); + goal.setDate(LocalDate.of(2026, 3, 27)); + goal.save(); + + reset(googleDriveService); + when(googleDriveService.saveLocally()).thenReturn(true); + + StudyService.GoalDelayProcessingResult result = studyService.processAllDelayedGoals(); + + assertEquals(1, result.updatedGoals()); + assertEquals(0, result.failedGoals()); + assertEquals(1, StudyGoal.findDelayedAndNotReplanned().size()); + + studyService.replanGoalForToday(goal.getId()); + + List todayGoals = studyService.getStudyGoalsForDate(LocalDate.of(2026, 3, 28)); + assertEquals(1, todayGoals.size()); + assertEquals(2, todayGoals.getFirst().getAttemptNumber()); + assertEquals(1, todayGoals.getFirst().getMissedAttemptCount()); + + Integer attempts = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM study_goal_attempts WHERE goal_id = ?", + Integer.class, + goal.getId()); + assertEquals(2, attempts); + } + @Test void getActiveSessionFindsSessionThatStartedPreviousDay() { LocalDate sessionDate = LocalDate.of(2026, 3, 27); @@ -221,6 +256,21 @@ id VARCHAR(50) PRIMARY KEY, task_id VARCHAR(50), replanned_for_date DATE, failed BOOLEAN DEFAULT FALSE, + status VARCHAR(20) DEFAULT 'ACTIVE', + achieved_attempt_id VARCHAR(50), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """); + jdbcTemplate.execute(""" + CREATE TABLE study_goal_attempts ( + id VARCHAR(50) PRIMARY KEY, + goal_id VARCHAR(50) NOT NULL, + planned_for_date DATE NOT NULL, + replanned_from_attempt_id VARCHAR(50), + outcome VARCHAR(20) DEFAULT 'PENDING', + reason_if_not_achieved TEXT, + outcome_at TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) From 5d9986c6b7e5a8499c89dd8b987a82fc126250f5 Mon Sep 17 00:00:00 2001 From: geokoko Date: Thu, 30 Apr 2026 03:00:55 +0300 Subject: [PATCH 2/9] Fix installer jar replacement --- scripts/install.sh | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) 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 From 9b71ee7b795a9e6643e177efd6f3ac857948e937 Mon Sep 17 00:00:00 2001 From: geokoko Date: Thu, 30 Apr 2026 03:01:11 +0300 Subject: [PATCH 3/9] Improve goal attempt history UI --- .../domain/service/StudyService.java | 28 +- .../ui/components/CalendarViewPanel.java | 61 ++-- .../ui/components/ProfileViewPanel.java | 325 +++++++++++------- .../ui/components/StudyPlannerPanel.java | 221 +++++++++--- .../ui/components/TaskManagementPanel.java | 242 ++++++++----- src/main/resources/styles.css | 5 + 6 files changed, 587 insertions(+), 295 deletions(-) diff --git a/src/main/java/com/studysync/domain/service/StudyService.java b/src/main/java/com/studysync/domain/service/StudyService.java index 31b4bd8..910ee68 100644 --- a/src/main/java/com/studysync/domain/service/StudyService.java +++ b/src/main/java/com/studysync/domain/service/StudyService.java @@ -209,33 +209,15 @@ 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() { - 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()); + return StudyGoal.findDelayedAndNotReplanned(); } /** 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 ec244ca..227ba2c 100644 --- a/src/main/java/com/studysync/presentation/ui/components/CalendarViewPanel.java +++ b/src/main/java/com/studysync/presentation/ui/components/CalendarViewPanel.java @@ -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 Attempt"); addGoalBtn.getStyleClass().add("btn-purple"); addGoalBtn.setStyle("-fx-font-size: 12px; -fx-padding: 8 16;"); addGoalBtn.setOnAction(e -> { @@ -729,8 +729,8 @@ private VBox createGoalsTab(LocalDate date, Tab parentTab) { if (studyGoals.isEmpty()) { String emptyMessage = isFutureDate - ? "No goals planned yet. Click the button above to plan ahead!" - : "No goals recorded for this date"; + ? "No goal attempts planned yet. Click the button above to plan ahead!" + : "No goal attempts recorded for this date"; Label noGoalsLabel = new Label(emptyMessage); noGoalsLabel.setGraphic(TaskStyleUtils.iconLabel(isFutureDate ? "\u25C6" : "\u25CB", 14)); TaskStyleUtils.fontNormal(noGoalsLabel, 14); @@ -741,7 +741,7 @@ private VBox createGoalsTab(LocalDate date, Tab parentTab) { return content; } - Label goalsTitle = new Label("\u25CE Study Goals (" + studyGoals.size() + ")"); + Label goalsTitle = new Label("\u25CE Study Goal Attempts (" + studyGoals.size() + ")"); TaskStyleUtils.fontBold(goalsTitle, 18); goalsTitle.setTextFill(Color.web("#9b59b6")); content.getChildren().add(goalsTitle); @@ -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("Attempt Completion: %.0f%% (%d out of %d attempts 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 attempts 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 = goal.getStatus() == StudyGoal.GoalStatus.ABANDONED ? "Abandoned" : "Missed"; + statusText = goal.getStatus() == StudyGoal.GoalStatus.ABANDONED ? "Abandoned Goal" : "Missed Attempt"; } else if (goal.isAchieved()) { statusIcon = "\u2705"; - statusText = "Achieved"; + statusText = "Achieved Attempt"; } else if (goal.isDelayed()) { statusIcon = "[!] "; - statusText = "Attempt " + goal.getAttemptNumber(); + statusText = "Retry Attempt"; } else { statusIcon = "\u25CB"; - statusText = "Pending"; + statusText = "Pending Attempt"; } Label statusLabel = new Label(statusIcon + " " + statusText); @@ -1069,18 +1069,11 @@ 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("» Attempt %d • %d prior miss%s", - goal.getAttemptNumber(), - goal.getMissedAttemptCount(), - goal.getMissedAttemptCount() == 1 ? "" : "es")); - 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); @@ -1134,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); @@ -1265,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 Attempt" : "Add Goal Attempt"); + dialog.setHeaderText("Add a goal attempt for " + date.format(DateTimeFormatter.ofPattern("EEEE, MMMM dd, yyyy"))); DialogPane dialogPane = dialog.getDialogPane(); dialogPane.getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL); @@ -1275,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 attempt on this day:" + : "What do you want to attempt today?"); TaskStyleUtils.fontNormal(instructionLabel, 12); TextArea goalTextArea = new TextArea(); @@ -1310,10 +1311,10 @@ private void showAddGoalDialog(LocalDate date) { // Show confirmation Alert successAlert = new Alert(Alert.AlertType.INFORMATION); successAlert.initOwner(this.getScene() != null ? this.getScene().getWindow() : null); - successAlert.setTitle("Goal Added"); + successAlert.setTitle("Goal Attempt Added"); successAlert.setHeaderText(null); - successAlert.setContentText("Study goal added successfully for " + - date.format(DateTimeFormatter.ofPattern("MMMM dd, yyyy"))); + successAlert.setContentText("Goal attempt added successfully for " + + date.format(DateTimeFormatter.ofPattern("MMMM dd, yyyy"))); successAlert.showAndWait(); } catch (Exception e) { Alert errorAlert = new Alert(Alert.AlertType.ERROR); 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 97b3ca8..6e9c872 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 goal attempts 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 goal attempts recorded yet."); TaskStyleUtils.fontNormal(noGoalsLabel, 14); noGoalsLabel.setTextFill(Color.web("#7f8c8d")); noGoalsLabel.setWrapText(true); @@ -526,92 +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 attempt history if achievement followed one or more misses. - if (goal.isDelayed()) { - Label delayLabel = new Label("[!] Attempt " + goal.getAttemptNumber() - + " after " + goal.getMissedAttemptCount() + " miss" - + (goal.getMissedAttemptCount() == 1 ? "" : "es")); - 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() { 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 e17f095..9295c93 100644 --- a/src/main/java/com/studysync/presentation/ui/components/StudyPlannerPanel.java +++ b/src/main/java/com/studysync/presentation/ui/components/StudyPlannerPanel.java @@ -14,6 +14,7 @@ import com.studysync.domain.valueobject.TaskCategory; import com.studysync.domain.valueobject.TaskPriority; import com.studysync.domain.valueobject.TaskStatus; +import javafx.application.Platform; import javafx.util.Callback; import javafx.geometry.Insets; import javafx.geometry.Pos; @@ -71,6 +72,7 @@ public class StudyPlannerPanel extends ScrollPane implements RefreshablePanel { // UI containers that get rebuilt on navigation private VBox tasksContainer; + private FlowPane attemptOverviewContainer; private FlowPane sessionsFlowPane; private TextArea reflectionArea; private ProgressBar dailyProgressBar; @@ -187,19 +189,24 @@ private void createTasksSection(VBox mainContent) { HBox sectionHeader = new HBox(15); sectionHeader.setAlignment(Pos.CENTER_LEFT); - Label sectionTitle = new Label("Today's Tasks"); + Label sectionTitle = new Label("Today's Tasks & Goal Attempts"); sectionTitle.setGraphic(TaskStyleUtils.iconLabel("\u2611", 18)); TaskStyleUtils.fontBold(sectionTitle, 18); Region spacer = new Region(); HBox.setHgrow(spacer, Priority.ALWAYS); - Button addGoalBtn = new Button("+ Add Goal"); + Button addGoalBtn = new Button("+ Add Goal Attempt"); addGoalBtn.getStyleClass().add("btn-purple"); addGoalBtn.setOnAction(e -> showAddGoalDialog(null)); sectionHeader.getChildren().addAll(sectionTitle, 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); @@ -221,7 +228,7 @@ private void createTasksSection(VBox mainContent) { toolbar.getChildren().addAll(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); } @@ -316,6 +323,7 @@ private void updateTasksDisplay() { List tasks = new ArrayList<>(taskService.getTasksForDate(displayDate)); LocalDate today = dateTimeService.getCurrentDate(); + updateAttemptOverview(); if (tasks.isEmpty()) { // No tasks for this day — offer "Create Task" shortcut @@ -389,7 +397,7 @@ private void updateTasksDisplay() { } } - // Unlinked goals section (goals with no task) + // Unlinked attempts section (goals with no task) List allUnlinked = StudyGoal.findUnlinkedForDate(displayDate); List unlinkedGoals = allUnlinked.stream() .filter(g -> !g.isAchieved()).toList(); @@ -398,7 +406,7 @@ private void updateTasksDisplay() { 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:"); + Label unlinkedTitle = new Label("Goal attempts without a task:"); TaskStyleUtils.fontBold(unlinkedTitle, 13); unlinkedTitle.setTextFill(Color.web("#6c757d")); unlinkedSection.getChildren().add(unlinkedTitle); @@ -418,6 +426,64 @@ private void updateTasksDisplay() { } } + private void updateAttemptOverview() { + if (attemptOverviewContainer == null) { + return; + } + attemptOverviewContainer.getChildren().clear(); + + List attempts = getAllAttemptsForDisplayDate(); + 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("Attempts Today", String.valueOf(attempts.size()), "#3949ab", "#e8eaf6"), + createAttemptMetricCard("Pending", String.valueOf(pending), "#0d47a1", "#e3f2fd"), + createAttemptMetricCard("Retries", String.valueOf(retrying), "#e65100", "#fff3e0"), + createAttemptMetricCard("Achieved", String.valueOf(achieved), "#1b5e20", "#e8f5e9"), + createAttemptMetricCard("Missed", String.valueOf(missed), "#b71c1c", "#ffebee"), + createAttemptMetricCard("Score", String.format("%+d", score), "#4a148c", "#f3e5f5") + ); + } + + private VBox createAttemptMetricCard(String title, String value, String textColor, String backgroundColor) { + VBox card = new VBox(2); + card.setAlignment(Pos.CENTER_LEFT); + card.setMinWidth(105); + card.setPadding(new Insets(8, 10, 8, 10)); + 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, 18); + valueLabel.setTextFill(Color.web(textColor)); + + Label titleLabel = new Label(title); + TaskStyleUtils.fontNormal(titleLabel, 10); + titleLabel.setTextFill(Color.web(textColor)); + + card.getChildren().addAll(valueLabel, titleLabel); + return card; + } + + private List getAllAttemptsForDisplayDate() { + LocalDate today = dateTimeService.getCurrentDate(); + if (displayDate.isAfter(today)) { + return studyService.getAllGoalsForFutureDate(displayDate); + } + return studyService.getAllGoalsForDate(displayDate); + } + /** Returns a comparator based on the user's current sort choice. */ private Comparator taskSortComparator() { return switch (currentSort) { @@ -456,9 +522,8 @@ private String groupKeyFor(Task task) { } /** - * 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. + * Builds the retry section for missed goal attempts. + * Shows a ComboBox of missed attempts whose parent goal can be tried again. */ private VBox buildReplanSection() { List candidates = studyService.getDelayedGoalsForReplanning(); @@ -467,13 +532,13 @@ private VBox buildReplanSection() { section.setPadding(new Insets(14, 0, 0, 0)); // Collapsible header row - Label header = new Label("Re-plan a Delayed Goal"); + Label header = new Label("Plan a Retry for a Missed 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."); + Label none = new Label("No missed goal attempts available to retry."); TaskStyleUtils.fontNormal(none, 12); none.setTextFill(Color.web("#7f8c8d")); section.getChildren().addAll(header, none); @@ -492,8 +557,10 @@ private VBox buildReplanSection() { ComboBox combo = new ComboBox<>(); combo.getItems().addAll(candidates); - combo.setVisibleRowCount(Math.min(candidates.size(), 15)); + combo.setVisibleRowCount(Math.max(1, Math.min(candidates.size(), 8))); combo.setMaxWidth(Double.MAX_VALUE); + combo.setOnShowing(e -> combo.setVisibleRowCount(Math.max(1, Math.min(candidates.size(), 8)))); + combo.setOnShown(e -> sizeComboPopupToRows(combo, candidates.size())); Callback, ListCell> cellFactory = lv -> new ListCell<>() { @Override protected void updateItem(StudyGoal goal, boolean empty) { @@ -509,9 +576,9 @@ protected void updateItem(StudyGoal goal, boolean empty) { }; combo.setCellFactory(cellFactory); combo.setButtonCell(cellFactory.call(null)); - combo.setPromptText("Select a delayed goal to re-plan..."); + combo.setPromptText("Select a missed goal to retry..."); - Button replanBtn = new Button("Re-plan for Today"); + Button replanBtn = new Button("Plan Retry Today"); replanBtn.setGraphic(TaskStyleUtils.iconLabel("\u21BA", 13)); replanBtn.getStyleClass().addAll("btn-orange", "btn-small"); replanBtn.setDisable(true); @@ -534,7 +601,7 @@ protected void updateItem(StudyGoal goal, boolean empty) { return section; } - /** Formats a delayed goal for display in the re-plan ComboBox. */ + /** Formats a missed attempt for display in the retry ComboBox. */ private String formatDelayedGoal(StudyGoal goal, Map taskTitles) { String taskLabel = ""; String taskId = goal.getTaskId(); @@ -549,8 +616,23 @@ private String formatDelayedGoal(StudyGoal goal, Map taskTitles) desc = desc.substring(0, 57) + "..."; } int misses = goal.getMissedAttemptCount(); - String missLabel = misses == 1 ? "1 prior miss" : misses + " prior misses"; - return taskLabel + desc + " (attempt " + (goal.getAttemptNumber() + 1) + ", " + missLabel + ")"; + int nextAttempt = Math.max(goal.getAttemptNumber() + 1, misses + 2); + return taskLabel + desc + " (attempt " + nextAttempt + ")"; + } + + private void sizeComboPopupToRows(ComboBox combo, int itemCount) { + Platform.runLater(() -> { + Node popupContent = combo.lookup(".list-view"); + if (popupContent instanceof ListView listView) { + double rowHeight = 28.0; + int visibleRows = Math.max(1, Math.min(itemCount, 8)); + double height = visibleRows * rowHeight + 2; + listView.setFixedCellSize(rowHeight); + listView.setMinHeight(height); + listView.setPrefHeight(height); + listView.setMaxHeight(height); + } + }); } /** @@ -596,7 +678,9 @@ private VBox buildTaskRow(Task task) { 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().addAll(createTaskAttemptBadges(task)); + headerRow.getChildren().add(spacer); // Overdue / due-today badge (between spacer and status badge) if (TaskStyleUtils.isOverdue(task, displayDate)) { @@ -638,6 +722,50 @@ private VBox buildTaskRow(Task task) { return card; } + private List