Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 44 additions & 3 deletions christmas/src/pages/home.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
use std::collections::HashSet;

use dioxus::prelude::*;

use super::current_year;
use crate::{
app::Route,
auth::Role,
components::CycleBoard,
model::{Exchange, Pool},
server,
server, storage,
};

#[component]
Expand Down Expand Up @@ -37,6 +40,30 @@ fn HomeBody(year: i32, pools: Vec<Pool>, draws: Vec<Exchange>) -> Element {
.max_by_key(|d| d.participants.len())
.cloned();

// Same rule as the pool page: a "revision 5" badge tells a viewer there are
// earlier draws they cannot see, which is exactly what hiding them is meant
// to avoid.
let role = use_resource(server::my_role);
let is_manager = matches!(&*role.read(), Some(Ok(Role::Manager)));

// Which draws this browser has already sat through.
//
// `None` until the browser has been consulted. Local storage cannot be read
// while rendering on the server, so the first paint must show no rings at
// all — otherwise the front page hands over every name before the pool
// page's gate ever gets the chance to offer the ceremony.
let mut watched = use_signal(|| None::<HashSet<i32>>);
let drawn: Vec<i32> = draws.iter().filter(|d| !d.pairings.is_empty()).map(|d| d.id).collect();

use_effect(move || {
let seen = drawn.iter().copied().filter(|id| storage::has_watched(*id)).collect();
watched.set(Some(seen));
});

// Reads the signal rather than caching a bool, so the rings appear as soon
// as the effect has run.
let is_revealed = move |id: i32| watched.read().as_ref().is_some_and(|seen| seen.contains(&id));

rsx! {
header { class: "hero",
div { class: "hero-copy",
Expand Down Expand Up @@ -116,12 +143,26 @@ fn HomeBody(year: i32, pools: Vec<Pool>, draws: Vec<Exchange>) -> Element {
section { key: "draw{draw.id}", class: "section",
div { class: "section-head",
h2 { "{draw.pool_name}" }
if draw.revision > 1 {
if is_manager && draw.revision > 1 {
span { class: "badge", "revision {draw.revision}" }
}
span { class: "count", "{draw.participants.len()} people" }
}
CycleBoard { cycles: draw.cycles(), letter: draw.letter }
if is_revealed(draw.id) {
CycleBoard { cycles: draw.cycles(), letter: draw.letter }
} else {
div { class: "empty-cta",
strong { "Not opened yet" }
"The {draw.year} draw is in. The names are waiting on the pool page."
div {
Link {
class: "reveal-cta",
to: Route::PoolPage { slug: draw.pool_slug.clone() },
"Watch the reveal →"
}
}
}
}
}
}
}
Expand Down
9 changes: 5 additions & 4 deletions christmas/src/pages/pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,11 @@ fn PoolBody(detail: PoolDetail) -> Element {
// during server rendering, so the first paint must not reveal anything —
// otherwise a returning visitor flashes the results before the check runs,
// and a new one has the whole draw spoiled.
// `None` until the browser has been consulted. Local storage is unavailable
// during server rendering, so the first paint must not reveal anything —
// otherwise a returning visitor flashes the gate, and a new one has the
// whole draw spoiled.
//
// This is a courtesy, not a boundary: `pool_detail` has already sent every
// pairing to the browser, so anyone who opens devtools can read them. Making
// it real would mean the server knowing who has watched what, and with one
// shared password there is no identity to hang that on — see `storage`.
let mut watched = use_signal(|| None::<bool>);
let mut ceremony_open = use_signal(|| false);

Expand Down
7 changes: 7 additions & 0 deletions christmas/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@
//! Local storage rather than anything server-side: with one shared password
//! there is no identity to hang it on, and "have I seen the 2026 Pets reveal"
//! is a property of the device, not the account.
//!
//! Consequently this holds back the *presentation* of a draw, not the data. The
//! pairings are already in the page the server sent, so a determined viewer can
//! always read them early. That is the right trade for a family exchange —
//! nobody is attacking it, they just want the reveal to land — but callers
//! should not mistake these helpers for access control. Real enforcement lives
//! in `auth`, which gates whole endpoints by role.

/// Storage key for one recorded draw.
#[cfg_attr(
Expand Down
Loading