StudyGoal enables users to set daily learning objectives and track their achievement over time.
- * This supports habit formation, progress tracking, and reflection on learning outcomes. Goals can
- * be simple daily objectives or more complex learning targets.
- *
- *
Goal Lifecycle:
- *
- *
Creation: Goal is set with a description and current date
- *
Tracking: User works toward achieving the goal throughout the day
- *
Evaluation: At day's end, goal is marked achieved or not achieved
- *
Reflection: If not achieved, user can record reasons for learning
- *
- *
- *
Usage Examples:
- *
- * // Create a simple daily goal
- * StudyGoal goal = new StudyGoal("Complete 2 hours of focused study");
- *
- * // Mark goal as achieved
- * goal.setAchieved(true);
- *
- * // Record reason if not achieved
- * goal.setAchieved(false);
- * goal.setReasonIfNotAchieved("Had unexpected meeting that ran long");
- *
- *
- *
Business Rules:
- *
- *
Each goal is automatically assigned a unique ID and current date
- *
Goals default to not achieved until explicitly marked
- *
Reasons for non-achievement are optional but encouraged for reflection
- *
Goals cannot be modified once created (description is immutable)
- *
- *
- *
Integration: StudyGoals integrate with the daily reflection system
- * and analytics to provide insights into learning patterns and goal achievement rates.
- *
- * @author StudySync Development Team
- * @version 0.1.2
- * @since 0.1.0
- * @see DailyReflection
+ * Domain entity representing a study goal.
+ *
+ *
The database model separates a goal's intent from its scheduled attempts:
+ * {@code study_goals} is the parent intent, while {@code study_goal_attempts}
+ * stores every planned try and outcome. This class intentionally keeps the old
+ * view-style API used by the UI: each queried {@code StudyGoal} instance is a
+ * parent goal plus one selected attempt context.
*/
public class StudyGoal {
private static final Logger logger = LoggerFactory.getLogger(StudyGoal.class);
private static JdbcTemplate jdbcTemplate;
-
- public static void setJdbcTemplate(JdbcTemplate template) {
- jdbcTemplate = template;
- }
-
- /** Unique identifier for this study goal. */
+
+ public enum GoalStatus {
+ ACTIVE,
+ ACHIEVED,
+ ABANDONED
+ }
+
+ public enum AttemptOutcome {
+ PENDING,
+ ACHIEVED,
+ MISSED
+ }
+
+ private static final String SELECT_ATTEMPT_VIEW = """
+ SELECT
+ g.id,
+ g.description,
+ g.task_id,
+ COALESCE(g.status, 'ACTIVE') AS status,
+ g.achieved_attempt_id,
+ g.created_at AS goal_created_at,
+ g.updated_at AS goal_updated_at,
+ a.id AS attempt_id,
+ a.planned_for_date,
+ a.replanned_from_attempt_id,
+ COALESCE(a.outcome, 'PENDING') AS attempt_outcome,
+ a.reason_if_not_achieved,
+ a.outcome_at,
+ a.created_at AS attempt_created_at,
+ a.updated_at AS attempt_updated_at,
+ (
+ SELECT COUNT(*)
+ FROM study_goal_attempts x
+ WHERE x.goal_id = g.id
+ AND (x.created_at < a.created_at OR (x.created_at = a.created_at AND x.id <= a.id))
+ ) AS attempt_number,
+ (
+ SELECT COUNT(*)
+ FROM study_goal_attempts m
+ WHERE m.goal_id = g.id
+ AND m.outcome = 'MISSED'
+ AND (m.created_at < a.created_at OR (m.created_at = a.created_at AND m.id <= a.id))
+ ) AS missed_attempt_count
+ FROM study_goals g
+ JOIN study_goal_attempts a ON a.goal_id = g.id
+ """;
+
private String id;
-
- /** The date this goal was created for (target achievement date). */
private LocalDate date;
-
- /** Description of what the user wants to achieve. */
private String description;
-
- /** Whether this goal has been achieved. */
private boolean achieved;
-
- /** Optional explanation of why the goal was not achieved (for reflection). */
private String reasonIfNotAchieved;
-
- /** Number of days this goal has been delayed (0 for goals on original date). */
private int daysDelayed;
-
- /** Whether this goal is a transferred goal from a previous day. */
private boolean isDelayed;
-
- /** Points deducted due to delays (accumulates over time). */
private int pointsDeducted;
-
- /** Optional ID of the task this goal is linked to. */
private String taskId;
-
- /**
- * When non-null, this goal has been manually rescheduled to appear on this specific date.
- * It is excluded from automatic delay carry-forward and only surfaces on this date.
- */
private LocalDate replannedForDate;
-
- /** Whether this goal has been marked as failed (overdue beyond threshold or user-dismissed). */
private boolean failed;
- /**
- * Default constructor creating a study goal with auto-generated ID and current date.
- *
- *
The goal is initialized as not achieved. The description must be set separately.
The goal is automatically assigned a unique ID, set to the current date,
- * and initialized as not achieved.
- *
- * @param description what the user wants to achieve
- */
+ this.status = GoalStatus.ACTIVE;
+ this.attemptOutcome = AttemptOutcome.PENDING;
+ }
+
public StudyGoal(String description) {
this();
this.description = description;
}
-
- /**
- * Creates a study goal with the specified description and optional task link.
- *
- * @param description what the user wants to achieve
- * @param taskId optional ID of the task this goal is linked to
- */
+
public StudyGoal(String description, String taskId) {
this(description);
this.taskId = taskId;
}
- /**
- * Full constructor for creating StudyGoal from JSON or with all parameters.
- *
- *
This constructor provides null-safety by generating defaults for ID and date
- * if not provided. Used primarily for JSON deserialization and testing.
- *
- * @param id unique identifier (auto-generated if null)
- * @param date goal date (current date if null)
- * @param description goal description
- * @param achieved whether the goal has been achieved
- * @param reasonIfNotAchieved explanation if not achieved
- */
@JsonCreator
public StudyGoal(@JsonProperty("id") String id,
- @JsonProperty("date") LocalDate date,
- @JsonProperty("description") String description,
- @JsonProperty("achieved") boolean achieved,
- @JsonProperty("reasonIfNotAchieved") String reasonIfNotAchieved) {
- this.id = id != null ? id : java.util.UUID.randomUUID().toString();
+ @JsonProperty("date") LocalDate date,
+ @JsonProperty("description") String description,
+ @JsonProperty("achieved") boolean achieved,
+ @JsonProperty("reasonIfNotAchieved") String reasonIfNotAchieved) {
+ this.id = id != null ? id : UUID.randomUUID().toString();
this.date = date != null ? date : LocalDate.now();
this.description = description;
this.achieved = achieved;
this.reasonIfNotAchieved = reasonIfNotAchieved;
- this.daysDelayed = 0;
- this.isDelayed = false;
- this.pointsDeducted = 0;
- }
-
- /**
- * Full constructor for creating StudyGoal with all delay tracking fields.
- * Used internally for database operations.
- */
+ this.status = achieved ? GoalStatus.ACHIEVED : GoalStatus.ACTIVE;
+ this.attemptOutcome = achieved ? AttemptOutcome.ACHIEVED : AttemptOutcome.PENDING;
+ }
+
public StudyGoal(String id, LocalDate date, String description, boolean achieved,
- String reasonIfNotAchieved, int daysDelayed,
- boolean isDelayed, int pointsDeducted, String taskId) {
+ String reasonIfNotAchieved, int daysDelayed,
+ boolean isDelayed, int pointsDeducted, String taskId) {
this(id, date, description, achieved, reasonIfNotAchieved,
- daysDelayed, isDelayed, pointsDeducted, taskId, null);
+ daysDelayed, isDelayed, pointsDeducted, taskId, null, false);
}
- /**
- * Full constructor including the optional replanned-for date.
- * Used when rescheduling goals (failed defaults to false).
- */
public StudyGoal(String id, LocalDate date, String description, boolean achieved,
- String reasonIfNotAchieved, int daysDelayed,
- boolean isDelayed, int pointsDeducted, String taskId,
- LocalDate replannedForDate) {
+ String reasonIfNotAchieved, int daysDelayed,
+ boolean isDelayed, int pointsDeducted, String taskId,
+ LocalDate replannedForDate) {
this(id, date, description, achieved, reasonIfNotAchieved,
- daysDelayed, isDelayed, pointsDeducted, taskId, replannedForDate, false);
+ daysDelayed, isDelayed, pointsDeducted, taskId, replannedForDate, false);
}
- /**
- * Full constructor including failed flag.
- * Used by the row mapper.
- */
public StudyGoal(String id, LocalDate date, String description, boolean achieved,
- String reasonIfNotAchieved, int daysDelayed,
- boolean isDelayed, int pointsDeducted, String taskId,
- LocalDate replannedForDate, boolean failed) {
- this.id = id != null ? id : java.util.UUID.randomUUID().toString();
+ String reasonIfNotAchieved, int daysDelayed,
+ boolean isDelayed, int pointsDeducted, String taskId,
+ LocalDate replannedForDate, boolean failed) {
+ this.id = id != null ? id : UUID.randomUUID().toString();
this.date = date != null ? date : LocalDate.now();
this.description = description;
this.achieved = achieved;
@@ -210,660 +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