diff --git a/solitaire_data/src/progress.rs b/solitaire_data/src/progress.rs index a91fc14..10427c9 100644 --- a/solitaire_data/src/progress.rs +++ b/solitaire_data/src/progress.rs @@ -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" + ); + } } diff --git a/solitaire_engine/src/profile_plugin.rs b/solitaire_engine/src/profile_plugin.rs index 7438206..9ec8013 100644 --- a/solitaire_engine/src/profile_plugin.rs +++ b/solitaire_engine/src/profile_plugin.rs @@ -6,6 +6,7 @@ use bevy::input::ButtonInput; use bevy::prelude::*; +use chrono::{Duration, Local, NaiveDate}; use solitaire_core::achievement::achievement_by_id; use solitaire_data::SyncBackend; @@ -20,14 +21,38 @@ use crate::ui_modal::{ spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant, }; use crate::ui_theme::{ - ACCENT_PRIMARY, STATE_INFO, STATE_SUCCESS, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, - TYPE_BODY_LG, VAL_SPACE_2, Z_MODAL_PANEL, + ACCENT_PRIMARY, BG_ELEVATED, BORDER_STRONG, SPACE_1, STATE_INFO, STATE_SUCCESS, TEXT_PRIMARY, + TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2, Z_MODAL_PANEL, }; +/// Number of days surfaced in the daily-challenge calendar row. +/// +/// 14 = trailing two weeks ending today. At ~12 px per dot with a 6 px gap +/// the row is ~246 px wide — well inside the 360 px minimum modal width on +/// the smallest supported window (800 px). +const CALENDAR_DAYS: usize = 14; + +/// Diameter of each calendar dot, in pixels. +const CALENDAR_DOT_SIZE_PX: f32 = 12.0; + /// Marker component on the profile overlay root node. #[derive(Component, Debug)] pub struct ProfileScreen; +/// Marker on each daily-challenge calendar dot inside the Profile modal. +/// +/// One entity per day in the trailing 14-day window — tests can query +/// for this component to assert the row was rendered. +#[derive(Component, Debug, Clone, Copy)] +pub struct DailyCalendarDot { + /// The calendar date this dot represents. + pub date: NaiveDate, + /// Whether the player completed the daily challenge on `date`. + pub completed: bool, + /// `true` if `date == today` (the rightmost dot). + pub is_today: bool, +} + /// Registers the `P` key toggle for the profile overlay. pub struct ProfilePlugin; @@ -195,6 +220,16 @@ fn spawn_profile_screen( font_row.clone(), TextColor(TEXT_PRIMARY), )); + + // 14-day daily-challenge calendar row. + spawn_daily_calendar( + card, + &prog.daily_challenge_history, + prog.daily_challenge_streak, + prog.daily_challenge_longest_streak, + Local::now().date_naive(), + font_res, + ); } // ── Achievements section ──────────────────────────────────── @@ -300,6 +335,98 @@ fn spawn_spacer(parent: &mut ChildSpawnerCommands, height: Val) { }); } +/// Spawn the daily-challenge calendar row: a caption + 14 dots. +/// +/// `history` is the player's full chronological completion history. +/// `current_streak` and `longest_streak` are surfaced in the caption. +/// `today` is passed in (rather than read directly) so the function is +/// trivially testable with a fixed reference date. +/// +/// Layout: caption row → row of 14 dots (~12 px each, 6 px gap). The +/// rightmost dot represents today; past dots fill from oldest (left) to +/// most recent (right). Each dot carries a [`DailyCalendarDot`] marker. +fn spawn_daily_calendar( + parent: &mut ChildSpawnerCommands, + history: &[NaiveDate], + current_streak: u32, + longest_streak: u32, + today: NaiveDate, + font_res: Option<&FontResource>, +) { + use std::collections::HashSet; + let history_set: HashSet = history.iter().copied().collect(); + + let font_caption = TextFont { + font: font_res.map(|f| f.0.clone()).unwrap_or_default(), + font_size: TYPE_CAPTION, + ..default() + }; + + parent.spawn(( + Text::new(format!( + "Current streak: {current_streak} \u{00B7} Longest: {longest_streak}" + )), + font_caption, + TextColor(TEXT_SECONDARY), + Node { + margin: UiRect { + top: VAL_SPACE_1, + bottom: VAL_SPACE_1, + ..default() + }, + ..default() + }, + )); + + parent + .spawn(Node { + flex_direction: FlexDirection::Row, + column_gap: Val::Px(SPACE_1 + 2.0), // 6 px between dots + align_items: AlignItems::Center, + ..default() + }) + .with_children(|row| { + // Iterate from oldest (today − 13) to today (rightmost). + for offset in (0..CALENDAR_DAYS as i64).rev() { + let date = today - Duration::days(offset); + let is_today = offset == 0; + let completed = history_set.contains(&date); + // Today's dot keeps the outlined-ring look (Balatro-yellow + // accent border) regardless of completion; past days use a + // subtle border so the row reads as a row of pills, not a + // strip of bare squares. + let border_color = if is_today { ACCENT_PRIMARY } else { BORDER_STRONG }; + let border_width = if is_today { 2.0 } else { 0.0 }; + row.spawn(( + DailyCalendarDot { + date, + completed, + is_today, + }, + Node { + width: Val::Px(CALENDAR_DOT_SIZE_PX), + height: Val::Px(CALENDAR_DOT_SIZE_PX), + border: UiRect::all(Val::Px(border_width)), + border_radius: BorderRadius::all(Val::Px(CALENDAR_DOT_SIZE_PX / 2.0)), + ..default() + }, + BackgroundColor(calendar_dot_color(completed)), + BorderColor::all(border_color), + )); + } + }); +} + +/// Background colour for a calendar dot. `STATE_SUCCESS` for completed +/// days, `BG_ELEVATED` for missed/pending days. +fn calendar_dot_color(completed: bool) -> Color { + if completed { + STATE_SUCCESS + } else { + BG_ELEVATED + } +} + /// Return `(backend_name, username_display)` for the given sync backend. fn sync_info(backend: &SyncBackend) -> (&'static str, String) { match backend { @@ -417,4 +544,43 @@ mod tests { // Level 10 is the first post-table level (span = 1000, starts at 5000). assert_eq!(xp_progress(5_000, 10), (1_000, 0)); } + + #[test] + fn profile_modal_renders_14_calendar_dots() { + // Open the Profile modal and assert the 14-day calendar row was + // populated with one DailyCalendarDot entity per day. + let mut app = headless_app(); + app.world_mut() + .resource_mut::>() + .press(KeyCode::KeyP); + app.update(); + + let dot_count = app + .world_mut() + .query::<&DailyCalendarDot>() + .iter(app.world()) + .count(); + assert_eq!( + dot_count, CALENDAR_DAYS, + "Profile modal must render exactly {CALENDAR_DAYS} calendar dots" + ); + } + + #[test] + fn calendar_dot_today_marker_is_set_on_rightmost_dot_only() { + // Exactly one of the 14 dots is the "today" dot (the rightmost). + let mut app = headless_app(); + app.world_mut() + .resource_mut::>() + .press(KeyCode::KeyP); + app.update(); + + let today_count = app + .world_mut() + .query::<&DailyCalendarDot>() + .iter(app.world()) + .filter(|d| d.is_today) + .count(); + assert_eq!(today_count, 1, "exactly one dot must be marked is_today"); + } } diff --git a/solitaire_sync/src/merge.rs b/solitaire_sync/src/merge.rs index 596a9f8..44d95ff 100644 --- a/solitaire_sync/src/merge.rs +++ b/solitaire_sync/src/merge.rs @@ -3,10 +3,10 @@ //! All functions are free of I/O and side effects — safe to call from any //! context including unit tests and the Bevy main thread. -use chrono::Utc; +use chrono::{NaiveDate, Utc}; use crate::{AchievementRecord, ConflictReport, PlayerProgress, StatsSnapshot, SyncPayload}; -use crate::progress::level_for_xp; +use crate::progress::{level_for_xp, DAILY_CHALLENGE_HISTORY_CAP}; /// Merge two [`SyncPayload`]s into a single authoritative result. /// @@ -240,6 +240,22 @@ fn merge_progress( // Challenge index: take the higher (further ahead in challenge progression). let challenge_index = local.challenge_index.max(remote.challenge_index); + // Daily-challenge history: union the two ordered lists into a sorted, + // deduplicated, capped Vec so completions made on either device survive. + let daily_challenge_history = union_naive_dates( + &local.daily_challenge_history, + &remote.daily_challenge_history, + ); + + // Longest streak ever: simple max — never regresses. + let daily_challenge_longest_streak = local + .daily_challenge_longest_streak + .max(remote.daily_challenge_longest_streak) + // Also defend against an old payload whose `longest_streak` was + // never written but whose current `daily_challenge_streak` exceeds + // the recorded longest — keep them coherent post-merge. + .max(daily_challenge_streak); + PlayerProgress { total_xp, level: level_for_xp(total_xp), @@ -250,6 +266,8 @@ fn merge_progress( unlocked_card_backs, unlocked_backgrounds, challenge_index, + daily_challenge_history, + daily_challenge_longest_streak, last_modified: Utc::now(), } } @@ -261,6 +279,20 @@ fn union_usize_vecs(a: &[usize], b: &[usize]) -> Vec { set.into_iter().collect() } +/// Returns the sorted union of two `NaiveDate` slices with duplicates +/// removed and the result capped at [`DAILY_CHALLENGE_HISTORY_CAP`] +/// entries (oldest dates trimmed first). +fn union_naive_dates(a: &[NaiveDate], b: &[NaiveDate]) -> Vec { + use std::collections::BTreeSet; + let set: BTreeSet = a.iter().chain(b.iter()).copied().collect(); + let mut v: Vec = set.into_iter().collect(); + if v.len() > DAILY_CHALLENGE_HISTORY_CAP { + let excess = v.len() - DAILY_CHALLENGE_HISTORY_CAP; + v.drain(0..excess); + } + v +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -753,4 +785,110 @@ mod tests { let (merged, _) = merge(&local, &remote); assert_eq!(merged.stats.fastest_win_seconds, 300); } + + // ----------------------------------------------------------------------- + // Daily-challenge history + longest-streak merge + // ----------------------------------------------------------------------- + + fn nd(y: i32, m: u32, d: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, d).unwrap() + } + + #[test] + fn merge_unions_daily_challenge_history() { + // Local and remote have disjoint completion dates; the merged + // history must contain all of them, sorted ascending, with no + // duplicates and within the cap. + let mut local = default_payload(); + local.progress.daily_challenge_history = + vec![nd(2026, 4, 20), nd(2026, 4, 22), nd(2026, 4, 24)]; + let mut remote = default_payload(); + remote.progress.daily_challenge_history = + vec![nd(2026, 4, 21), nd(2026, 4, 22), nd(2026, 4, 25)]; + + let (merged, _) = merge(&local, &remote); + assert_eq!( + merged.progress.daily_challenge_history, + vec![ + nd(2026, 4, 20), + nd(2026, 4, 21), + nd(2026, 4, 22), + nd(2026, 4, 24), + nd(2026, 4, 25), + ], + "history union must be sorted, deduplicated, and contain every date from either side" + ); + assert!( + merged.progress.daily_challenge_history.len() <= DAILY_CHALLENGE_HISTORY_CAP, + "merged history must respect the 365-entry cap" + ); + } + + #[test] + fn merge_caps_daily_challenge_history_at_max() { + // Construct a local history that already has CAP entries and a + // remote history that adds 50 fresher entries — the merge must + // drop the oldest 50 so the cap is preserved. + let start = nd(2024, 1, 1); + let local_dates: Vec = (0..DAILY_CHALLENGE_HISTORY_CAP as i64) + .map(|i| start + chrono::Duration::days(i)) + .collect(); + let remote_dates: Vec = (DAILY_CHALLENGE_HISTORY_CAP as i64 + ..DAILY_CHALLENGE_HISTORY_CAP as i64 + 50) + .map(|i| start + chrono::Duration::days(i)) + .collect(); + + let mut local = default_payload(); + local.progress.daily_challenge_history = local_dates.clone(); + let mut remote = default_payload(); + remote.progress.daily_challenge_history = remote_dates.clone(); + + let (merged, _) = merge(&local, &remote); + assert_eq!( + merged.progress.daily_challenge_history.len(), + DAILY_CHALLENGE_HISTORY_CAP, + "merged history must be capped at DAILY_CHALLENGE_HISTORY_CAP" + ); + // The oldest 50 entries should have been evicted; oldest retained + // is therefore start + 50 days. + assert_eq!( + merged.progress.daily_challenge_history.first().copied(), + Some(start + chrono::Duration::days(50)) + ); + // Most recent retained is the last remote date. + assert_eq!( + merged.progress.daily_challenge_history.last().copied(), + remote_dates.last().copied() + ); + } + + #[test] + fn merge_takes_max_longest_streak() { + let mut local = default_payload(); + local.progress.daily_challenge_longest_streak = 4; + let mut remote = default_payload(); + remote.progress.daily_challenge_longest_streak = 9; + let (merged, _) = merge(&local, &remote); + assert_eq!( + merged.progress.daily_challenge_longest_streak, 9, + "longest streak must be the max across both sides" + ); + } + + #[test] + fn merge_longest_streak_never_below_current_streak() { + // If a payload's `daily_challenge_longest_streak` was never written + // (legacy file) but its `daily_challenge_streak` is non-zero, the + // merged longest must reflect at least the current streak so the + // two values stay coherent. + let mut local = default_payload(); + local.progress.daily_challenge_streak = 7; + local.progress.daily_challenge_longest_streak = 0; // legacy + let remote = default_payload(); + let (merged, _) = merge(&local, &remote); + assert!( + merged.progress.daily_challenge_longest_streak >= 7, + "longest streak must be at least as large as the merged current streak" + ); + } } diff --git a/solitaire_sync/src/progress.rs b/solitaire_sync/src/progress.rs index ef28a53..7a55637 100644 --- a/solitaire_sync/src/progress.rs +++ b/solitaire_sync/src/progress.rs @@ -18,6 +18,13 @@ pub fn level_for_xp(xp: u64) -> u32 { } } +/// Maximum number of dates retained in [`PlayerProgress::daily_challenge_history`]. +/// +/// Bounds the per-player file size across years of play. ~365 entries is +/// roughly a year of daily completions, far more than the 14-day window the +/// in-game calendar surfaces. +pub const DAILY_CHALLENGE_HISTORY_CAP: usize = 365; + /// Persisted player progression state. /// /// Mutation helpers such as `add_xp`, `record_daily_completion`, etc. are @@ -45,6 +52,14 @@ pub struct PlayerProgress { /// Index of the next Challenge-mode seed to serve to this player. #[serde(default)] pub challenge_index: u32, + /// All dates the player has completed the daily challenge, in + /// chronological ascending order. Bounded to the most recent 365 + /// entries so file size stays bounded across years of play. + #[serde(default)] + pub daily_challenge_history: Vec, + /// Longest daily-challenge streak ever achieved on this profile. + #[serde(default)] + pub daily_challenge_longest_streak: u32, /// Wall-clock time of the last modification (used for conflict detection). pub last_modified: DateTime, } @@ -61,6 +76,8 @@ impl Default for PlayerProgress { unlocked_card_backs: vec![0], unlocked_backgrounds: vec![0], challenge_index: 0, + daily_challenge_history: Vec::new(), + daily_challenge_longest_streak: 0, last_modified: DateTime::UNIX_EPOCH, } } @@ -114,6 +131,12 @@ impl PlayerProgress { /// - Completion the day after the previous: streak increments. /// - Same day as the previous: no-op (idempotent). /// + /// On every fresh completion, `date` is appended to + /// `daily_challenge_history` (kept sorted ascending and capped at + /// [`DAILY_CHALLENGE_HISTORY_CAP`] entries) and + /// `daily_challenge_longest_streak` is bumped if the current streak + /// exceeds it. + /// /// Returns `true` if this call recorded a fresh completion. pub fn record_daily_completion(&mut self, date: NaiveDate) -> bool { match self.daily_challenge_last_completed { @@ -126,6 +149,19 @@ impl PlayerProgress { } } self.daily_challenge_last_completed = Some(date); + // Append to history (defensive against duplicates and out-of-order + // dates so a hand-edited or merged file can't corrupt the order). + if !self.daily_challenge_history.contains(&date) { + self.daily_challenge_history.push(date); + self.daily_challenge_history.sort(); + if self.daily_challenge_history.len() > DAILY_CHALLENGE_HISTORY_CAP { + let excess = self.daily_challenge_history.len() - DAILY_CHALLENGE_HISTORY_CAP; + self.daily_challenge_history.drain(0..excess); + } + } + if self.daily_challenge_streak > self.daily_challenge_longest_streak { + self.daily_challenge_longest_streak = self.daily_challenge_streak; + } self.last_modified = Utc::now(); true } @@ -320,4 +356,85 @@ mod tests { p.record_daily_completion(date(2026, 4, 22)); // skip the 21st assert_eq!(p.daily_challenge_streak, 1, "gap must reset streak"); } + + // ----------------------------------------------------------------------- + // record_daily_completion — history + longest-streak side effects + // ----------------------------------------------------------------------- + + #[test] + fn record_daily_completion_appends_to_history_in_chronological_order() { + let mut p = PlayerProgress::default(); + assert!(p.daily_challenge_history.is_empty()); + p.record_daily_completion(date(2026, 4, 20)); + p.record_daily_completion(date(2026, 4, 21)); + p.record_daily_completion(date(2026, 4, 22)); + assert_eq!( + p.daily_challenge_history, + vec![ + date(2026, 4, 20), + date(2026, 4, 21), + date(2026, 4, 22), + ], + "history should hold all three completions in ascending order" + ); + } + + #[test] + fn record_daily_completion_same_day_does_not_duplicate_history() { + let mut p = PlayerProgress::default(); + p.record_daily_completion(date(2026, 4, 20)); + p.record_daily_completion(date(2026, 4, 20)); + assert_eq!( + p.daily_challenge_history, + vec![date(2026, 4, 20)], + "same-day completion is a no-op and must not duplicate history" + ); + } + + #[test] + fn record_daily_completion_updates_longest_streak() { + let mut p = PlayerProgress::default(); + // Three-day streak: longest jumps from 0 → 3. + p.record_daily_completion(date(2026, 4, 20)); + p.record_daily_completion(date(2026, 4, 21)); + p.record_daily_completion(date(2026, 4, 22)); + assert_eq!(p.daily_challenge_streak, 3); + assert_eq!(p.daily_challenge_longest_streak, 3); + + // Gap resets the current streak — longest must NOT regress. + p.record_daily_completion(date(2026, 4, 25)); + assert_eq!(p.daily_challenge_streak, 1); + assert_eq!( + p.daily_challenge_longest_streak, 3, + "longest_streak must never regress after a gap" + ); + + // Two-day streak — still below longest, so longest stays at 3. + p.record_daily_completion(date(2026, 4, 26)); + assert_eq!(p.daily_challenge_streak, 2); + assert_eq!(p.daily_challenge_longest_streak, 3); + } + + #[test] + fn daily_challenge_history_is_capped_at_max() { + // Push DAILY_CHALLENGE_HISTORY_CAP + 5 consecutive days; the + // earliest five must be evicted and the most recent CAP retained. + let mut p = PlayerProgress::default(); + let start = date(2024, 1, 1); + let total = DAILY_CHALLENGE_HISTORY_CAP + 5; + for offset in 0..total { + p.record_daily_completion(start + Duration::days(offset as i64)); + } + assert_eq!(p.daily_challenge_history.len(), DAILY_CHALLENGE_HISTORY_CAP); + // Oldest retained is `start + 5` (we dropped the first 5). + assert_eq!( + p.daily_challenge_history.first().copied(), + Some(start + Duration::days(5)) + ); + // Newest retained is the last date pushed. + assert_eq!( + p.daily_challenge_history.last().copied(), + Some(start + Duration::days(total as i64 - 1)) + ); + } }