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:
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user