feat(engine): 14-day daily-challenge calendar in the Profile modal

The daily challenge already updated streak counters, but past
completions were invisible — the player had no in-game surface to
see streak length or the actual day-by-day record. Adds a 14-dot
horizontal calendar above the Profile modal's achievements section
with a "Current streak: N · Longest: M" caption.

Each dot represents a day in the trailing 14-day window ending
today. Today's dot gets a 2-px Balatro-yellow ring; completed days
fill STATE_SUCCESS; missed days fill BG_ELEVATED. Geometry: 14 ×
12 px + 13 × 6 px gap ≈ 246 px — fits comfortably inside the
modal's 360 px min_width even on the 800 px window minimum.

PlayerProgress gains two #[serde(default)] fields:
- daily_challenge_history: Vec<NaiveDate> capped at 365 entries
  (one year of history; older entries pushed off when the cap is
  hit). Sorted ascending, deduped on insert so same-day re-runs
  don't bloat the list.
- daily_challenge_longest_streak: u32, updated whenever streak
  exceeds the previous max.

Legacy progress.json files load to empty/0 via #[serde(default)].

solitaire_sync::merge unions histories from local + remote (sorted,
capped) and takes max(longest_streak), with a clamp to ensure
longest is never below the merged current streak — guards against
legacy payloads where longest=0 but current is mid-streak.

13 new tests across solitaire_sync (record_daily history append,
chronological order, dedupe, cap, longest update, merge union,
merge cap, max longest, clamp), solitaire_data (history append,
longest update, legacy deserialise), and solitaire_engine
(modal renders 14 dots, today marker on rightmost only).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-05 01:05:54 +00:00
parent ba527de351
commit 1a1047664b
4 changed files with 491 additions and 4 deletions
+66
View File
@@ -298,4 +298,70 @@ mod tests {
assert!(!recorded_again, "same-day completion must report no-op");
assert_eq!(p.daily_challenge_streak, 1);
}
// --- Daily challenge history & longest streak ---
#[test]
fn record_daily_completion_appends_to_history() {
// Recording a completion adds the date to history, preserving the
// pre-call length + 1, and the new entry is the chronological tail.
let mut p = PlayerProgress::default();
let prev_len = p.daily_challenge_history.len();
let today = NaiveDate::from_ymd_opt(2026, 5, 5).unwrap();
let recorded = p.record_daily_completion(today);
assert!(recorded);
assert_eq!(p.daily_challenge_history.len(), prev_len + 1);
assert_eq!(p.daily_challenge_history.last().copied(), Some(today));
}
#[test]
fn record_daily_completion_updates_longest_streak() {
// A streak of 4 must lift `daily_challenge_longest_streak` from 2 to 4
// (we seed the previous best at 2 and watch it get overtaken).
let mut p = PlayerProgress {
daily_challenge_longest_streak: 2,
..Default::default()
};
let d = NaiveDate::from_ymd_opt(2026, 5, 1).unwrap();
p.record_daily_completion(d);
p.record_daily_completion(d + Duration::days(1));
p.record_daily_completion(d + Duration::days(2));
// 3rd consecutive day equals the previous best; longest should match.
assert_eq!(p.daily_challenge_streak, 3);
assert_eq!(p.daily_challenge_longest_streak, 3);
// 4th consecutive day overtakes the previous best.
p.record_daily_completion(d + Duration::days(3));
assert_eq!(p.daily_challenge_streak, 4);
assert_eq!(p.daily_challenge_longest_streak, 4);
}
#[test]
fn legacy_progress_without_history_deserializes_to_empty() {
// A progress.json file produced before the history fields existed
// must still round-trip through serde::from_slice without error,
// with the new fields landing on their `#[serde(default)]` values.
let path = tmp_path("legacy_no_history");
let _ = fs::remove_file(&path);
let legacy_json = br#"{
"total_xp": 1500,
"level": 3,
"daily_challenge_last_completed": null,
"daily_challenge_streak": 0,
"weekly_goal_progress": {},
"unlocked_card_backs": [0],
"unlocked_backgrounds": [0],
"last_modified": "2026-04-29T12:00:00Z"
}"#;
fs::write(&path, legacy_json).expect("write");
let p = load_progress_from(&path);
assert_eq!(p.total_xp, 1500);
assert!(
p.daily_challenge_history.is_empty(),
"legacy file lacking daily_challenge_history must default to empty"
);
assert_eq!(
p.daily_challenge_longest_streak, 0,
"legacy file lacking daily_challenge_longest_streak must default to 0"
);
}
}