feat(leaderboard): opt-out support — server endpoint, client method, UI button

- Server: DELETE /api/leaderboard/opt-in sets leaderboard_opt_in=0,
  hiding the player without deleting their row (scores preserved for re-opt-in)
- SyncProvider trait: opt_out_leaderboard() default no-op method + blanket impl
- SolitaireServerClient: implements opt_out_leaderboard via DELETE request with JWT refresh
- Leaderboard UI: "Opt Out" button (dark red) alongside existing "Opt In" button
- Server integration test: opt-out hides, opt-in restores (round-trip verified)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-04-27 02:01:20 +00:00
parent 15b9b5477b
commit 648c5c18d9
6 changed files with 205 additions and 21 deletions
+8
View File
@@ -46,6 +46,11 @@ pub trait SyncProvider: Send + Sync {
async fn opt_in_leaderboard(&self, _display_name: &str) -> Result<(), SyncError> {
Ok(())
}
/// Remove the authenticated player from the leaderboard.
/// No-op for backends that don't support leaderboards.
async fn opt_out_leaderboard(&self) -> Result<(), SyncError> {
Ok(())
}
}
/// Blanket impl so `Box<dyn SyncProvider + Send + Sync>` (returned by
@@ -76,6 +81,9 @@ impl SyncProvider for Box<dyn SyncProvider + Send + Sync> {
async fn opt_in_leaderboard(&self, display_name: &str) -> Result<(), SyncError> {
(**self).opt_in_leaderboard(display_name).await
}
async fn opt_out_leaderboard(&self) -> Result<(), SyncError> {
(**self).opt_out_leaderboard().await
}
}
pub mod stats;
+34
View File
@@ -261,6 +261,40 @@ impl SyncProvider for SolitaireServerClient {
Ok(())
}
async fn opt_out_leaderboard(&self) -> Result<(), SyncError> {
let token = self.access_token()?;
let url = format!("{}/api/leaderboard/opt-in", self.base_url);
let resp = self
.client
.delete(&url)
.bearer_auth(&token)
.send()
.await
.map_err(|e| SyncError::Network(e.to_string()))?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
self.refresh_token().await?;
let new_token = self.access_token()?;
let resp = self
.client
.delete(&url)
.bearer_auth(new_token)
.send()
.await
.map_err(|e| SyncError::Network(e.to_string()))?;
if !resp.status().is_success() {
return Err(SyncError::Auth(format!("opt-out failed: {}", resp.status())));
}
return Ok(());
}
if !resp.status().is_success() {
return Err(SyncError::Auth(format!("opt-out failed: {}", resp.status())));
}
Ok(())
}
async fn fetch_leaderboard(&self) -> Result<Vec<LeaderboardEntry>, SyncError> {
let token = self.access_token()?;
let url = format!("{}/api/leaderboard", self.base_url);