feat(engine): Today's Event callout on the Home Daily card

Phase B step 1 of the MSSC-inspired Home rework — surfaces today's
daily-challenge metadata on the Daily card so the picker reads as
"there's something fresh waiting" rather than a generic mode label.

- Date line "Today, May 6" pulled from DailyChallengeResource. Reads
  in STATE_INFO blue while the run is still open.
- Server-fetched goal (when SyncPlugin is wired) appears underneath
  as "Goal: Win in under 5 minutes", matching the toast that already
  fires when the player presses C.
- Once the player has recorded today's completion, the date flips
  to "Today, May 6 \u{2022} Done" in ACCENT_PRIMARY so the picker
  reads as a reward state rather than a TODO.

Headless tests omit DailyChallengePlugin, so HomeContext.daily_today
defaults to None and the card falls back to its baseline layout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-06 16:28:59 +00:00
parent ae40a1db7a
commit b73d246b4c
+75
View File
@@ -19,6 +19,7 @@ use solitaire_core::game_state::DrawMode;
use solitaire_data::save_settings_to; use solitaire_data::save_settings_to;
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL; use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
use crate::daily_challenge_plugin::DailyChallengeResource;
use crate::events::{ use crate::events::{
InfoToastEvent, NewGameRequestEvent, StartChallengeRequestEvent, InfoToastEvent, NewGameRequestEvent, StartChallengeRequestEvent,
StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent, StartZenRequestEvent, StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent, StartZenRequestEvent,
@@ -258,6 +259,7 @@ fn spawn_home_on_launch(
progress: Option<Res<ProgressResource>>, progress: Option<Res<ProgressResource>>,
stats: Option<Res<StatsResource>>, stats: Option<Res<StatsResource>>,
settings: Option<Res<SettingsResource>>, settings: Option<Res<SettingsResource>>,
daily: Option<Res<DailyChallengeResource>>,
font_res: Option<Res<FontResource>>, font_res: Option<Res<FontResource>>,
) { ) {
if shown.0 if shown.0
@@ -275,6 +277,7 @@ fn spawn_home_on_launch(
progress.as_deref(), progress.as_deref(),
stats.as_deref(), stats.as_deref(),
settings.as_deref(), settings.as_deref(),
daily.as_deref(),
font_res.as_deref(), font_res.as_deref(),
), ),
); );
@@ -285,12 +288,14 @@ fn spawn_home_on_launch(
// M-key toggle // M-key toggle
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
#[allow(clippy::too_many_arguments)]
fn toggle_home_screen( fn toggle_home_screen(
mut commands: Commands, mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
progress: Option<Res<ProgressResource>>, progress: Option<Res<ProgressResource>>,
stats: Option<Res<StatsResource>>, stats: Option<Res<StatsResource>>,
settings: Option<Res<SettingsResource>>, settings: Option<Res<SettingsResource>>,
daily: Option<Res<DailyChallengeResource>>,
font_res: Option<Res<FontResource>>, font_res: Option<Res<FontResource>>,
screens: Query<Entity, With<HomeScreen>>, screens: Query<Entity, With<HomeScreen>>,
) { ) {
@@ -306,6 +311,7 @@ fn toggle_home_screen(
progress.as_deref(), progress.as_deref(),
stats.as_deref(), stats.as_deref(),
settings.as_deref(), settings.as_deref(),
daily.as_deref(),
font_res.as_deref(), font_res.as_deref(),
), ),
); );
@@ -320,8 +326,20 @@ fn build_home_context<'a>(
progress: Option<&ProgressResource>, progress: Option<&ProgressResource>,
stats: Option<&StatsResource>, stats: Option<&StatsResource>,
settings: Option<&SettingsResource>, settings: Option<&SettingsResource>,
daily: Option<&DailyChallengeResource>,
font_res: Option<&'a FontResource>, font_res: Option<&'a FontResource>,
) -> HomeContext<'a> { ) -> HomeContext<'a> {
let daily_today = daily.map(|d| {
let completed_today = progress
.and_then(|p| p.0.daily_challenge_last_completed)
.is_some_and(|d_last| d_last == d.date);
DailyToday {
date_label: d.date.format("%b %-d").to_string(),
goal: d.goal_description.clone(),
completed_today,
}
});
HomeContext { HomeContext {
level: progress.map_or(0, |p| p.0.level), level: progress.map_or(0, |p| p.0.level),
total_xp: progress.map_or(0, |p| p.0.total_xp), total_xp: progress.map_or(0, |p| p.0.total_xp),
@@ -330,6 +348,7 @@ fn build_home_context<'a>(
classic_best: stats.map_or(0, |s| s.0.classic_best_score), classic_best: stats.map_or(0, |s| s.0.classic_best_score),
zen_best: stats.map_or(0, |s| s.0.zen_best_score), zen_best: stats.map_or(0, |s| s.0.zen_best_score),
challenge_best: stats.map_or(0, |s| s.0.challenge_best_score), challenge_best: stats.map_or(0, |s| s.0.challenge_best_score),
daily_today,
draw_mode: settings draw_mode: settings
.map(|s| s.0.draw_mode.clone()) .map(|s| s.0.draw_mode.clone())
.unwrap_or(DrawMode::DrawOne), .unwrap_or(DrawMode::DrawOne),
@@ -458,6 +477,7 @@ fn handle_home_draw_mode_buttons(
mut changed: MessageWriter<SettingsChangedEvent>, mut changed: MessageWriter<SettingsChangedEvent>,
progress: Option<Res<ProgressResource>>, progress: Option<Res<ProgressResource>>,
stats: Option<Res<StatsResource>>, stats: Option<Res<StatsResource>>,
daily: Option<Res<DailyChallengeResource>>,
font_res: Option<Res<FontResource>>, font_res: Option<Res<FontResource>>,
) { ) {
if screens.is_empty() { if screens.is_empty() {
@@ -500,6 +520,7 @@ fn handle_home_draw_mode_buttons(
progress.as_deref(), progress.as_deref(),
stats.as_deref(), stats.as_deref(),
Some(settings), Some(settings),
daily.as_deref(),
font_res.as_deref(), font_res.as_deref(),
), ),
); );
@@ -615,10 +636,28 @@ struct HomeContext<'a> {
zen_best: u32, zen_best: u32,
challenge_best: u32, challenge_best: u32,
daily_streak: u32, daily_streak: u32,
daily_today: Option<DailyToday>,
draw_mode: DrawMode, draw_mode: DrawMode,
font_res: Option<&'a FontResource>, font_res: Option<&'a FontResource>,
} }
/// Today's daily-challenge metadata as the Home picker needs it. Only
/// populated when both [`DailyChallengeResource`] is present (the
/// plugin is wired) and we have something useful to show — otherwise
/// the Daily card falls back to its baseline description without a
/// dated callout.
struct DailyToday {
/// Short calendar label, e.g. `"May 6"`. Always populated.
date_label: String,
/// Server-supplied goal copy ("Win in under 5 minutes"). `None`
/// when no server backend is wired or the fetch hasn't returned.
goal: Option<String>,
/// `true` when the player has already recorded today's daily.
/// Surfaces a "Done" badge so the picker reads as reward-state
/// rather than "you still owe today's run".
completed_today: bool,
}
/// Spawns the Home modal with the player-stats header strip, draw-mode /// Spawns the Home modal with the player-stats header strip, draw-mode
/// row, five mode cards, and a Cancel button. /// row, five mode cards, and a Cancel button.
fn spawn_home_screen(commands: &mut Commands, ctx: HomeContext<'_>) { fn spawn_home_screen(commands: &mut Commands, ctx: HomeContext<'_>) {
@@ -1029,6 +1068,42 @@ fn spawn_mode_card(
)); ));
} }
// Daily-only "Today's Event" caption — date, optional
// server goal, and a "Done" badge once the player has
// already recorded today's completion. Only renders for
// the Daily card when DailyChallengeResource is present.
if matches!(mode, HomeMode::Daily)
&& unlocked
&& let Some(today) = ctx.daily_today.as_ref()
{
let date_text = if today.completed_today {
format!("Today, {} \u{2022} Done", today.date_label)
} else {
format!("Today, {}", today.date_label)
};
let date_color = if today.completed_today {
ACCENT_PRIMARY
} else {
STATE_INFO
};
c.spawn((
Text::new(date_text),
font_chip.clone(),
TextColor(date_color),
Node {
margin: UiRect::top(VAL_SPACE_1),
..default()
},
));
if let Some(goal) = today.goal.as_ref() {
c.spawn((
Text::new(format!("Goal: {goal}")),
font_chip.clone(),
TextColor(TEXT_SECONDARY),
));
}
}
// Locked footnote — explicit copy so the gate is unambiguous. // Locked footnote — explicit copy so the gate is unambiguous.
if !unlocked { if !unlocked {
c.spawn(( c.spawn((