diff --git a/contracts/oracle-integration/src/lib.rs b/contracts/oracle-integration/src/lib.rs index ee9568c6..8dc2cf55 100644 --- a/contracts/oracle-integration/src/lib.rs +++ b/contracts/oracle-integration/src/lib.rs @@ -2,7 +2,7 @@ use soroban_sdk::{ contract, contracterror, contractevent, contractimpl, contracttype, Address, Bytes, BytesN, - Env, Vec, + Env, Symbol, Vec, }; #[contract] @@ -38,6 +38,29 @@ pub struct LatestPriceData { pub updated_ledger: u32, } +/// Snapshot of the configured oracle source addresses at read time. +/// Returns `None` from `source_config_snapshot` when the contract has not been initialized. +#[derive(Clone)] +#[contracttype] +pub struct OracleSourceSnapshot { + /// Whitelisted oracle addresses that may fulfill data requests. + pub sources: Vec
, + /// Number of configured sources (convenience field for clients). + pub source_count: u32, +} + +/// Summary of the update cadence and staleness policy in effect. +/// Deterministic: the same value is returned on every call. +#[derive(Clone)] +#[contracttype] +pub struct UpdatePolicySummary { + /// Number of ledgers after which a price is considered stale. + pub stale_threshold_ledgers: u32, + /// Describes when updates occur. `"on_request"` means data is fetched + /// per-request rather than on a fixed schedule. + pub cadence: Symbol, +} + #[derive(Clone)] #[contracttype] pub struct PriceFreshness { @@ -299,6 +322,43 @@ impl OracleIntegration { result } + // ───────── SOURCE CONFIG SNAPSHOT ───────── + + /// Returns a snapshot of the configured oracle source addresses. + /// + /// Returns `None` when the contract has not been initialized; callers should + /// treat a `None` result as "no sources configured" and not attempt data + /// requests until the contract is initialized. + pub fn source_config_snapshot(env: Env) -> Option { + let sources: Option> = env + .storage() + .instance() + .get(&DataKey::OracleSources); + + sources.map(|s| { + let source_count = s.len(); + OracleSourceSnapshot { + sources: s, + source_count, + } + }) + } + + // ───────── UPDATE POLICY SUMMARY ───────── + + /// Returns a deterministic summary of the staleness and update policy. + /// + /// The summary is safe to cache by clients: it does not change after + /// initialization and does not require any feed-specific parameters. + /// The `cadence` field is `"on_request"` — data is pulled per-request + /// rather than pushed on a fixed schedule. + pub fn update_policy_summary(env: Env) -> UpdatePolicySummary { + UpdatePolicySummary { + stale_threshold_ledgers: STALE_THRESHOLD_LEDGERS, + cadence: Symbol::new(&env, "on_request"), + } + } + pub fn last_price_freshness(env: Env, feed_id: BytesN<32>) -> PriceFreshness { let current_ledger = env.ledger().sequence(); let key = DataKey::Latest(feed_id); diff --git a/contracts/oracle-integration/src/test.rs b/contracts/oracle-integration/src/test.rs index d728b05e..fd992d15 100644 --- a/contracts/oracle-integration/src/test.rs +++ b/contracts/oracle-integration/src/test.rs @@ -443,3 +443,74 @@ fn last_price_freshness_handles_missing_prices() { assert_eq!(freshness.age_ledgers, 0); assert!(freshness.is_stale); } + +// --- source_config_snapshot --- + +#[test] +fn source_config_snapshot_returns_configured_sources() { + let env = Env::default(); + let contract_id = env.register(OracleIntegration, ()); + let client = OracleIntegrationClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let oracle1 = Address::generate(&env); + let oracle2 = Address::generate(&env); + + env.mock_all_auths(); + let sources = vec![&env, oracle1.clone(), oracle2.clone()]; + client.init(&admin, &sources); + + let snapshot = client.source_config_snapshot().expect("snapshot present after init"); + assert_eq!(snapshot.source_count, 2); + assert!(snapshot.sources.contains(&oracle1)); + assert!(snapshot.sources.contains(&oracle2)); +} + +#[test] +fn source_config_snapshot_returns_none_before_init() { + let env = Env::default(); + let contract_id = env.register(OracleIntegration, ()); + let client = OracleIntegrationClient::new(&env, &contract_id); + + assert!(client.source_config_snapshot().is_none()); +} + +#[test] +fn source_config_snapshot_count_matches_sources_length() { + let env = Env::default(); + let (client, _, _, _, _) = setup_initialized(&env); + + let snapshot = client.source_config_snapshot().expect("initialized"); + assert_eq!(snapshot.source_count, snapshot.sources.len()); +} + +// --- update_policy_summary --- + +#[test] +fn update_policy_summary_returns_correct_stale_threshold() { + let env = Env::default(); + let (client, _, _, _, _) = setup_initialized(&env); + + let summary = client.update_policy_summary(); + // Matches the STALE_THRESHOLD_LEDGERS constant (20). + assert_eq!(summary.stale_threshold_ledgers, 20); +} + +#[test] +fn update_policy_summary_cadence_is_on_request() { + let env = Env::default(); + let (client, _, _, _, _) = setup_initialized(&env); + + let summary = client.update_policy_summary(); + assert_eq!(summary.cadence, soroban_sdk::Symbol::new(&env, "on_request")); +} + +#[test] +fn update_policy_summary_is_deterministic_across_calls() { + let env = Env::default(); + let (client, _, _, _, _) = setup_initialized(&env); + + let s1 = client.update_policy_summary(); + let s2 = client.update_policy_summary(); + assert_eq!(s1.stale_threshold_ledgers, s2.stale_threshold_ledgers); + assert_eq!(s1.cadence, s2.cadence); +} diff --git a/contracts/tournament-system/src/lib.rs b/contracts/tournament-system/src/lib.rs index e608613f..702d7e82 100644 --- a/contracts/tournament-system/src/lib.rs +++ b/contracts/tournament-system/src/lib.rs @@ -64,6 +64,23 @@ pub struct BracketSummary { pub remaining_participants: u32, } +/// Compact summary of a participant's journey through the tournament bracket. +/// +/// Returned by `elimination_path`. Returns `Err(TournamentNotFound)` when the +/// tournament does not exist and `Err(PlayerNotJoined)` when the participant +/// has never joined. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EliminationPath { + /// Total number of rounds the participant appeared in. + pub rounds_played: u32, + /// The highest round number in which the participant was still active. + /// Zero when the player joined but no round data exists yet. + pub last_round_active: u32, + /// `true` when the participant is still alive in the current round. + pub is_active: bool, +} + #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct Matchup { @@ -383,6 +400,85 @@ impl TournamentSystem { Ok(matchups) } + /// Returns the number of matches remaining in the current round. + /// + /// Each match pairs two participants; a participant with no opponent receives + /// a bye and counts as half a match (rounded up). Returns + /// `Err(TournamentNotFound)` when the tournament does not exist. + pub fn remaining_match_count(env: Env, id: u64) -> Result { + if !env.storage().persistent().has(&DataKey::Tournament(id)) { + return Err(Error::TournamentNotFound); + } + + let round: u32 = env + .storage() + .persistent() + .get(&DataKey::CurrentRound(id)) + .ok_or(Error::TournamentNotFound)?; + + let participants: soroban_sdk::Vec
= env + .storage() + .persistent() + .get(&DataKey::RoundParticipants(id, round)) + .unwrap_or(soroban_sdk::Vec::new(&env)); + + // Ceiling division: an odd participant gets a bye and counts as one match. + let count = participants.len(); + Ok((count + 1) / 2) + } + + /// Returns a compact summary of a participant's elimination path. + /// + /// Walks every round from 1 to the current round and checks whether the + /// participant appeared in the `RoundParticipants` list. The result is + /// deterministic and does not require reconstructing the full bracket + /// off-chain. + /// + /// Returns `Err(TournamentNotFound)` when the tournament does not exist. + /// Returns `Err(PlayerNotJoined)` when the participant never joined. + pub fn elimination_path(env: Env, id: u64, player: Address) -> Result { + if !env.storage().persistent().has(&DataKey::Tournament(id)) { + return Err(Error::TournamentNotFound); + } + + if !env.storage().persistent().has(&DataKey::PlayerJoined(id, player.clone())) { + return Err(Error::PlayerNotJoined); + } + + let current_round: u32 = env + .storage() + .persistent() + .get(&DataKey::CurrentRound(id)) + .ok_or(Error::TournamentNotFound)?; + + let mut last_round_active: u32 = 0; + let mut rounds_played: u32 = 0; + let mut round: u32 = 1; + + while round <= current_round { + let participants: soroban_sdk::Vec
= env + .storage() + .persistent() + .get(&DataKey::RoundParticipants(id, round)) + .unwrap_or(soroban_sdk::Vec::new(&env)); + + if participants.contains(&player) { + last_round_active = round; + rounds_played = rounds_played.checked_add(1).ok_or(Error::Overflow)?; + } + + round += 1; + } + + let is_active = last_round_active == current_round && current_round > 0; + + Ok(EliminationPath { + rounds_played, + last_round_active, + is_active, + }) + } + pub fn advance_round(env: Env, admin: Address, id: u64) -> Result<(), Error> { require_admin(&env, &admin)?; @@ -656,4 +752,155 @@ mod test { let summary = client.get_bracket_summary(&id); assert_eq!(summary.current_round, 1); } + + // --- remaining_match_count --- + + #[test] + fn remaining_match_count_even_participants() { + let env = Env::default(); + let (client, admin, _, _) = setup(&env); + + let id = 200u64; + env.mock_all_auths(); + client.create_tournament(&admin, &id, &BytesN::from_array(&env, &[0u8; 32]), &0i128); + + let p1 = Address::generate(&env); + let p2 = Address::generate(&env); + let p3 = Address::generate(&env); + let p4 = Address::generate(&env); + client.join_tournament(&p1, &id); + client.join_tournament(&p2, &id); + client.join_tournament(&p3, &id); + client.join_tournament(&p4, &id); + + // 4 participants → 2 matches + assert_eq!(client.remaining_match_count(&id), Ok(2)); + } + + #[test] + fn remaining_match_count_odd_participants_gives_bye() { + let env = Env::default(); + let (client, admin, _, _) = setup(&env); + + let id = 201u64; + env.mock_all_auths(); + client.create_tournament(&admin, &id, &BytesN::from_array(&env, &[0u8; 32]), &0i128); + + let p1 = Address::generate(&env); + let p2 = Address::generate(&env); + let p3 = Address::generate(&env); + client.join_tournament(&p1, &id); + client.join_tournament(&p2, &id); + client.join_tournament(&p3, &id); + + // 3 participants → 2 matches (one bye) + assert_eq!(client.remaining_match_count(&id), Ok(2)); + } + + #[test] + fn remaining_match_count_single_participant() { + let env = Env::default(); + let (client, admin, _, _) = setup(&env); + + let id = 202u64; + env.mock_all_auths(); + client.create_tournament(&admin, &id, &BytesN::from_array(&env, &[0u8; 32]), &0i128); + + let p1 = Address::generate(&env); + client.join_tournament(&p1, &id); + + // 1 participant → 1 match (bye) + assert_eq!(client.remaining_match_count(&id), Ok(1)); + } + + #[test] + fn remaining_match_count_missing_tournament() { + let env = Env::default(); + let (client, _, _, _) = setup(&env); + + assert_eq!( + client.try_remaining_match_count(&9999u64), + Err(Ok(Error::TournamentNotFound)) + ); + } + + // --- elimination_path --- + + #[test] + fn elimination_path_active_participant_in_round_1() { + let env = Env::default(); + let (client, admin, _, _) = setup(&env); + + let id = 300u64; + env.mock_all_auths(); + client.create_tournament(&admin, &id, &BytesN::from_array(&env, &[0u8; 32]), &0i128); + + let player = Address::generate(&env); + client.join_tournament(&player, &id); + + let path = client.elimination_path(&id, &player).expect("path present"); + assert_eq!(path.rounds_played, 1); + assert_eq!(path.last_round_active, 1); + assert!(path.is_active); + } + + #[test] + fn elimination_path_eliminated_after_round_advance() { + let env = Env::default(); + let (client, admin, _, _) = setup(&env); + + let id = 301u64; + env.mock_all_auths(); + client.create_tournament(&admin, &id, &BytesN::from_array(&env, &[0u8; 32]), &0i128); + + let winner = Address::generate(&env); + let loser = Address::generate(&env); + client.join_tournament(&winner, &id); + client.join_tournament(&loser, &id); + + // Record so that winner beats loser + client.record_result(&admin, &id, &winner, &200u64); + client.record_result(&admin, &id, &loser, &50u64); + client.advance_round(&admin, &id); + + // loser was in round 1 but not round 2 + let path = client.elimination_path(&id, &loser).expect("path"); + assert_eq!(path.rounds_played, 1); + assert_eq!(path.last_round_active, 1); + assert!(!path.is_active); + + // winner is now in round 2 + let winner_path = client.elimination_path(&id, &winner).expect("winner path"); + assert_eq!(winner_path.rounds_played, 2); + assert_eq!(winner_path.last_round_active, 2); + assert!(winner_path.is_active); + } + + #[test] + fn elimination_path_missing_tournament() { + let env = Env::default(); + let (client, _, _, _) = setup(&env); + + let phantom = Address::generate(&env); + assert_eq!( + client.try_elimination_path(&9999u64, &phantom), + Err(Ok(Error::TournamentNotFound)) + ); + } + + #[test] + fn elimination_path_unjoined_player() { + let env = Env::default(); + let (client, admin, _, _) = setup(&env); + + let id = 302u64; + env.mock_all_auths(); + client.create_tournament(&admin, &id, &BytesN::from_array(&env, &[0u8; 32]), &0i128); + + let outsider = Address::generate(&env); + assert_eq!( + client.try_elimination_path(&id, &outsider), + Err(Ok(Error::PlayerNotJoined)) + ); + } } diff --git a/docs/contracts/oracle-integration.md b/docs/contracts/oracle-integration.md index 55646ee9..f00bcc70 100644 --- a/docs/contracts/oracle-integration.md +++ b/docs/contracts/oracle-integration.md @@ -1,5 +1,17 @@ # oracle-integration +Oracle consumers need a stable read surface for source configuration and update +policy details, not just latest-value freshness. The two accessor methods below +(`source_config_snapshot` and `update_policy_summary`) provide exactly that. + +## Missing-source behavior + +- `source_config_snapshot` returns `None` when the contract has not been + initialized. Callers should treat a `None` result as "no sources configured" + and must not attempt data requests until a non-`None` snapshot is available. +- `update_policy_summary` is always safe to call — it returns a compile-time + constant summary regardless of initialization state. + ## Public Methods ### `init` @@ -104,3 +116,39 @@ pub fn last_price_freshness(env: Env, feed_id: BytesN<32>) -> PriceFreshness `PriceFreshness` + +### `source_config_snapshot` +Returns a snapshot of the configured oracle source addresses. +Returns `None` when the contract has not been initialized (see **Missing-source behavior** above). +The snapshot is safe to expose in client tooling — it contains only whitelisted +`Address` values and a convenience count field. + +```rust +pub fn source_config_snapshot(env: Env) -> Option +``` + +#### Return Type + +`Option` + +| Field | Type | Description | +|-------|------|-------------| +| `sources` | `Vec
` | Whitelisted oracle addresses | +| `source_count` | `u32` | Number of configured sources | + +### `update_policy_summary` +Returns a deterministic summary of the staleness and update cadence policy. +The result is identical on every call and safe to cache by clients. + +```rust +pub fn update_policy_summary(env: Env) -> UpdatePolicySummary +``` + +#### Return Type + +`UpdatePolicySummary` + +| Field | Type | Description | +|-------|------|-------------| +| `stale_threshold_ledgers` | `u32` | Ledgers after which a price is stale | +| `cadence` | `Symbol` | `"on_request"` — data is fetched per-request | diff --git a/docs/contracts/tournament-system.md b/docs/contracts/tournament-system.md index 871245d6..fd21f582 100644 --- a/docs/contracts/tournament-system.md +++ b/docs/contracts/tournament-system.md @@ -1,5 +1,16 @@ # tournament-system +Tournament consumers benefit from a direct view of remaining match counts and +each participant's elimination-path context without reconstructing the full +bracket off-chain. + +## Missing-entity behavior + +- `remaining_match_count` returns `Err(TournamentNotFound)` when the tournament + does not exist. +- `elimination_path` returns `Err(TournamentNotFound)` when the tournament does + not exist, and `Err(PlayerNotJoined)` when the participant never joined. + ## Public Methods ### `init` @@ -201,3 +212,52 @@ pub fn advance_round(env: Env, admin: Address, id: u64) -> Result<(), Error> `Result<(), Error>` + +### `remaining_match_count` +Returns the number of matches remaining in the current round. +Each match pairs two participants; an odd participant receives a bye and counts +as one match (ceiling division). This accessor is deterministic and does not +require the caller to reconstruct the bracket. + +```rust +pub fn remaining_match_count(env: Env, id: u64) -> Result +``` + +#### Parameters + +| Name | Type | +|------|------| +| `env` | `Env` | +| `id` | `u64` | + +#### Return Type + +`Result` — count of matches, or `Err(TournamentNotFound)` + +### `elimination_path` +Returns a compact summary of a participant's journey through the bracket. +Walks every round from 1 to the current round, checking whether the participant +appeared in `RoundParticipants` for each. The result is UI-friendly and does +not require reconstructing the full bracket off-chain. + +```rust +pub fn elimination_path(env: Env, id: u64, player: Address) -> Result +``` + +#### Parameters + +| Name | Type | +|------|------| +| `env` | `Env` | +| `id` | `u64` | +| `player` | `Address` | + +#### Return Type + +`Result` + +| Field | Type | Description | +|-------|------|-------------| +| `rounds_played` | `u32` | Total rounds the participant appeared in | +| `last_round_active` | `u32` | Highest round the participant was still active | +| `is_active` | `bool` | `true` when participant is in the current round | diff --git a/frontend/src/components/v1/ContractEventFeed.tsx b/frontend/src/components/v1/ContractEventFeed.tsx index e2548bde..87713e0c 100644 --- a/frontend/src/components/v1/ContractEventFeed.tsx +++ b/frontend/src/components/v1/ContractEventFeed.tsx @@ -10,6 +10,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useContractEvents } from '../../hooks/v1/useContractEvents'; import { ErrorNotice } from './ErrorNotice'; import { EmptyStateBlock } from './EmptyStateBlock'; +import type { TimelineItemData } from './Timeline'; import { toAppError } from '../../utils/v1/errorMapper'; import { generateIdempotencyKey, @@ -58,6 +59,30 @@ const DEFAULT_VIRTUALIZATION_THRESHOLD = 120; const DEFAULT_VIRTUAL_ITEM_HEIGHT_PX = 42; const DEFAULT_VIRTUAL_OVERSCAN = 6; +/** + * Maps a ContractEvent to a TimelineItemData so callers can compose event + * history into any surface that accepts the shared Timeline API (e.g. audit + * views, transaction history panels). + */ +export function eventToTimelineItem(event: ContractEvent): TimelineItemData { + const timestamp = new Date(event.timestamp); + const timeLabel = Number.isNaN(timestamp.getTime()) + ? undefined + : timestamp.toLocaleTimeString(undefined, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + + return { + id: event.id, + label: event.type ?? 'unknown', + status: 'idle', + timestamp: timeLabel ?? null, + metadata: event.contractId ? event.contractId.slice(0, 10) : null, + }; +} + export function getEventSeverity( eventType: string | undefined, mapping: SeverityMapping = DEFAULT_SEVERITY_MAPPING, @@ -785,7 +810,7 @@ export const ContractEventFeed: React.FC = ({
    = ({ + item, + orientation, + compact, + testId, +}) => { + const entryId = testId ? `${testId}-item-${item.id}` : `sc-timeline-item-${item.id}`; + + return ( +
  1. +
  2. + ); +}; + +TimelineEntry.displayName = 'TimelineEntry'; + +/** + * Timeline + * + * Renders an ordered list of `TimelineItemData` entries. The `orientation` + * prop switches between a horizontal progress-step layout and a vertical + * chronological-history layout without changing the underlying semantics. + * + * Both layouts use `
      ` with `role="list"` to preserve accessibility for + * ordered historical content (WCAG 1.3.1). + */ +export const Timeline: React.FC = ({ + items, + orientation = 'vertical', + compact = false, + className = '', + testId = 'sc-timeline', +}) => { + const rootClasses = [ + 'sc-timeline', + `sc-timeline--${orientation}`, + compact ? 'sc-timeline--compact' : '', + className, + ] + .filter(Boolean) + .join(' '); + + return ( +
        + {items.map((item) => ( + + ))} +
      + ); +}; + +Timeline.displayName = 'Timeline'; + +export default Timeline; diff --git a/frontend/src/components/v1/TxStatusPanel.tsx b/frontend/src/components/v1/TxStatusPanel.tsx index 89634fca..73fa0922 100644 --- a/frontend/src/components/v1/TxStatusPanel.tsx +++ b/frontend/src/components/v1/TxStatusPanel.tsx @@ -2,6 +2,8 @@ import React, { useState, useCallback } from 'react'; import { TxPhase, TxStatusMeta, TxStatusError } from '../../types/tx-status'; import { EnvironmentBadge } from './EnvironmentBadge'; import { formatAddress, formatDate, formatTxTimestamp, truncateHash } from '../../utils/v1/formatters'; +import { Timeline } from './Timeline'; +import type { TimelineItemData, TimelineItemStatus } from './Timeline'; import './TxStatusPanel.css'; /** @@ -194,32 +196,43 @@ export const TxStatusPanel: React.FC = ({ className ].join(' '); - const renderStep = (label: string, activePhase: TxPhase | TxPhase[], stepIndex: number) => { - const phases = Array.isArray(activePhase) ? activePhase : [activePhase]; - const isActive = phases.includes(phase); - - // Logic for completion: if we are past this phase in the happy path - // SUBMITTED (1) -> PENDING (2) -> CONFIRMED (3) - const currentStepIndex = phase === TxPhase.IDLE ? 0 : - phase === TxPhase.SUBMITTED ? 1 : - phase === TxPhase.PENDING ? 2 : - phase === TxPhase.CONFIRMED ? 3 : - isFailed ? 2 : 0; // If failed during pending, we mark up to submitted - - const isCompleted = stepIndex < currentStepIndex && !isFailed; - const isError = isFailed && stepIndex === currentStepIndex; - - return ( -
      -
      - {label} -
      - ); + const currentStepIndex = + phase === TxPhase.IDLE ? 0 : + phase === TxPhase.SUBMITTED ? 1 : + phase === TxPhase.PENDING ? 2 : + phase === TxPhase.CONFIRMED ? 3 : + isFailed ? 2 : 0; + + const resolveStepStatus = (stepIndex: number): TimelineItemStatus => { + if (isFailed && stepIndex === currentStepIndex) return 'error'; + if (stepIndex < currentStepIndex && !isFailed) return 'completed'; + if ( + (phase === TxPhase.SUBMITTED && stepIndex === 1) || + (phase === TxPhase.PENDING && stepIndex === 2) + ) return 'active'; + return 'idle'; }; + const txTimelineItems: TimelineItemData[] = [ + { + id: 'submitted', + label: 'Submitted', + status: resolveStepStatus(1), + timestamp: meta?.submittedAt ? formatDate(meta.submittedAt, { timeStyle: 'short' }) : null, + }, + { + id: 'pending', + label: 'Pending', + status: resolveStepStatus(2), + }, + { + id: 'confirmed', + label: 'Confirmed', + status: resolveStepStatus(3), + timestamp: meta?.settledAt ? formatDate(meta.settledAt, { timeStyle: 'short' }) : null, + }, + ]; + const badgeClass = `tx-status-panel__badge tx-status-panel__badge--${phase.toLowerCase()}`; return ( @@ -238,9 +251,12 @@ export const TxStatusPanel: React.FC = ({ {!isIdle && (
      - {renderStep('Submitted', TxPhase.SUBMITTED, 1)} - {renderStep('Pending', TxPhase.PENDING, 2)} - {renderStep('Confirmed', TxPhase.CONFIRMED, 3)} +
      )} diff --git a/frontend/src/components/v1/index.ts b/frontend/src/components/v1/index.ts index 6503ec27..69bf4c6f 100644 --- a/frontend/src/components/v1/index.ts +++ b/frontend/src/components/v1/index.ts @@ -142,3 +142,5 @@ export { default as QuestWorkspaceHeaderDefault, } from "./QuestWorkspaceHeader"; export type { QuestWorkspaceHeaderProps } from "../../types/v1/quest"; +export { Timeline, default as TimelineDefault } from "./Timeline"; +export type { TimelineProps, TimelineItemData, TimelineItemStatus } from "./Timeline"; diff --git a/frontend/src/index.css b/frontend/src/index.css index 0cf32e16..32a17146 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -541,4 +541,227 @@ nav a:hover, nav a.active { .breadcrumb-nav:only-child, .breadcrumb-nav ol:empty { display: none; -} \ No newline at end of file +} +/* ─── Shared Timeline Component (sc-timeline) ─────────────────────────────── + * Used by TxStatusPanel (horizontal step view) and ContractEventFeed + * (vertical history view). Add sc-timeline--horizontal or + * sc-timeline--vertical to select the layout. + * ─────────────────────────────────────────────────────────────────────────── */ + +.sc-timeline { + --sc-tl-dot-size: 10px; + --sc-tl-dot-color: rgba(255, 255, 255, 0.2); + --sc-tl-dot-active: var(--accent, #00ffcc); + --sc-tl-dot-completed: #22c55e; + --sc-tl-dot-error: #f87171; + --sc-tl-connector: rgba(255, 255, 255, 0.1); + --sc-tl-text: rgba(255, 255, 255, 0.55); + --sc-tl-text-active: #ffffff; + + list-style: none; + margin: 0; + padding: 0; +} + +/* ── Horizontal layout (step progress) ── */ +.sc-timeline--horizontal { + display: flex; + align-items: center; + justify-content: space-between; + position: relative; + padding: 0.75rem 0; +} + +.sc-timeline--horizontal::before { + content: ''; + position: absolute; + top: 50%; + left: 0; + right: 0; + height: 2px; + background: var(--sc-tl-connector); + transform: translateY(-50%); + pointer-events: none; + z-index: 0; +} + +.sc-timeline--horizontal .sc-timeline__item { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.4rem; + flex: 1; + z-index: 1; +} + +/* ── Vertical layout (chronological history) ── */ +.sc-timeline--vertical { + display: flex; + flex-direction: column; + position: relative; +} + +.sc-timeline--vertical .sc-timeline__item { + display: grid; + grid-template-columns: var(--sc-tl-dot-size) 1fr auto; + align-items: center; + gap: 0.5rem; + padding: 0.4rem 0; + position: relative; +} + +.sc-timeline--vertical .sc-timeline__item + .sc-timeline__item::before { + content: ''; + position: absolute; + top: 0; + left: calc(var(--sc-tl-dot-size) / 2); + width: 1px; + height: 100%; + background: var(--sc-tl-connector); + transform: translateY(-50%); + z-index: 0; +} + +/* ── Dot ── */ +.sc-timeline__dot { + width: var(--sc-tl-dot-size); + height: var(--sc-tl-dot-size); + border-radius: 50%; + background: var(--sc-tl-dot-color); + border: 2px solid var(--sc-tl-dot-color); + flex-shrink: 0; + transition: background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease; +} + +.sc-timeline__item--active .sc-timeline__dot { + background: var(--sc-tl-dot-active); + border-color: var(--sc-tl-dot-active); + box-shadow: 0 0 8px var(--sc-tl-dot-active); +} + +.sc-timeline__item--completed .sc-timeline__dot { + background: var(--sc-tl-dot-completed); + border-color: var(--sc-tl-dot-completed); +} + +.sc-timeline__item--error .sc-timeline__dot { + background: var(--sc-tl-dot-error); + border-color: var(--sc-tl-dot-error); +} + +/* ── Label ── */ +.sc-timeline__label { + font-size: 0.7rem; + font-weight: 600; + color: var(--sc-tl-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.sc-timeline__item--active .sc-timeline__label, +.sc-timeline__item--completed .sc-timeline__label { + color: var(--sc-tl-text-active); +} + +/* ── Timestamp & metadata ── */ +.sc-timeline__timestamp, +.sc-timeline__metadata { + font-size: 0.65rem; + color: var(--sc-tl-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ── Compact modifier ── */ +.sc-timeline--compact { + --sc-tl-dot-size: 8px; +} + +.sc-timeline--compact .sc-timeline__label { + font-size: 0.62rem; +} + +/* ── Onboarding checklist ──────────────────────────────────────────────────── */ + +.onboarding-checklist { + background: var(--bg-card, rgba(255, 255, 255, 0.05)); + border: 1px solid var(--glass-border, rgba(255, 255, 255, 0.1)); + border-radius: 0.75rem; + padding: 1rem 1.25rem; + margin-bottom: 1.25rem; +} + +.onboarding-checklist__header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.75rem; +} + +.onboarding-checklist__title { + margin: 0; + font-size: 0.9rem; + font-weight: 700; + color: var(--accent, #00ffcc); + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.onboarding-checklist__dismiss { + background: transparent; + border: none; + color: var(--text-dim, #a0a0a0); + font-size: 1.25rem; + line-height: 1; + cursor: pointer; + padding: 0 0.25rem; + border-radius: 4px; + transition: color 0.15s ease; +} + +.onboarding-checklist__dismiss:hover { + color: var(--text-main, #ffffff); +} + +.onboarding-checklist__dismiss:focus-visible { + outline: 2px solid var(--accent, #00ffcc); + outline-offset: 2px; +} + +.onboarding-checklist__list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.onboarding-checklist__item { + display: flex; + align-items: center; +} + +.onboarding-checklist__label { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + color: var(--text-main, #ffffff); + cursor: pointer; +} + +.onboarding-checklist__checkbox { + accent-color: var(--accent, #00ffcc); + width: 1rem; + height: 1rem; + cursor: pointer; + flex-shrink: 0; +} + +.onboarding-checklist__text--done { + text-decoration: line-through; + color: var(--text-dim, #a0a0a0); +} diff --git a/frontend/src/pages/GameLobby.tsx b/frontend/src/pages/GameLobby.tsx index 6adca5ba..f861bcc9 100644 --- a/frontend/src/pages/GameLobby.tsx +++ b/frontend/src/pages/GameLobby.tsx @@ -7,9 +7,68 @@ import WalletStatusCard from '../components/v1/WalletStatusCard'; import PrizePoolStateCard from '../components/v1/PrizePoolStateCard'; import { isSupportedNetwork } from '../utils/v1/useNetworkGuard'; import { useWalletStatus } from '../hooks/v1/useWalletStatus'; -import GlobalStateStore from '../services/global-state-store'; +import GlobalStateStore, { ONBOARDING_CHECKLIST_DISMISSED_FLAG } from '../services/global-state-store'; import type { PendingTransactionSnapshot } from '../types/global-state'; +// ─── Onboarding Checklist ──────────────────────────────────────────────────── + +const CHECKLIST_ITEMS = [ + { id: 'connect-wallet', label: 'Connect your Stellar wallet' }, + { id: 'browse-games', label: 'Browse available games' }, + { id: 'place-wager', label: 'Place your first wager' }, +] as const; + +interface FirstTimeChecklistProps { + onDismiss: () => void; +} + +const FirstTimeChecklist: React.FC = ({ onDismiss }) => { + const [checked, setChecked] = useState>({}); + + const toggle = useCallback((id: string) => { + setChecked((prev) => ({ ...prev, [id]: !prev[id] })); + }, []); + + return ( + + ); +}; + function formatCompactAddress(address: string | null): string { if (!address) { return 'No wallet connected'; @@ -40,6 +99,18 @@ export const GameLobby: React.FC = () => { globalStoreRef.current = new GlobalStateStore(); } + const [checklistDismissed, setChecklistDismissed] = useState( + () => !!globalStoreRef.current?.selectFlag(ONBOARDING_CHECKLIST_DISMISSED_FLAG), + ); + + const handleDismissChecklist = useCallback(() => { + globalStoreRef.current?.dispatch({ + type: 'FLAGS_SET', + payload: { key: ONBOARDING_CHECKLIST_DISMISSED_FLAG, value: true }, + }); + setChecklistDismissed(true); + }, []); + const networkSupport = useMemo( () => isSupportedNetwork(wallet.network, { supportedNetworks: ['TESTNET', 'PUBLIC'] }), [wallet.network], @@ -210,6 +281,10 @@ export const GameLobby: React.FC = () => {
      + {!checklistDismissed && ( + + )} +
      {games.length === 0 ? (
      diff --git a/frontend/src/services/global-state-store.ts b/frontend/src/services/global-state-store.ts index 48487853..c6139547 100644 --- a/frontend/src/services/global-state-store.ts +++ b/frontend/src/services/global-state-store.ts @@ -22,6 +22,13 @@ const EVENT_FEED_FILTER_KEY_PREFIX = "stc_feed_filter_v1"; const FILTER_PRESET_STORAGE_KEY = "stc_feed_filter_presets_v1"; const FILTER_PRESET_VERSION = 1; +/** + * Flag key used to track whether the first-time onboarding checklist has been + * dismissed. Stored in the `flags` map so it persists across page reloads via + * localStorage. Dispatch `FLAGS_SET` with this key and `value: true` to dismiss. + */ +export const ONBOARDING_CHECKLIST_DISMISSED_FLAG = "onboarding_checklist_dismissed"; + export interface BannerDismissalEntry { identity: string; dismissedAt: number; diff --git a/frontend/tests/components/GameLobby.test.tsx b/frontend/tests/components/GameLobby.test.tsx index 1285a8ea..0e91f22a 100644 --- a/frontend/tests/components/GameLobby.test.tsx +++ b/frontend/tests/components/GameLobby.test.tsx @@ -1,7 +1,8 @@ -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; import { expect, test, vi, describe, it, beforeEach } from 'vitest'; import GameLobby from '../../src/pages/GameLobby'; import { ApiClient } from '../../src/services/typed-api-sdk'; +import { ONBOARDING_CHECKLIST_DISMISSED_FLAG } from '../../src/services/global-state-store'; vi.mock('../../src/services/typed-api-sdk'); vi.mock('../../src/hooks/v1/useWalletStatus', () => ({ @@ -172,6 +173,96 @@ describe('GameLobby two-column layout', () => { }); }); +describe('GameLobby - first-time onboarding checklist', () => { + it('renders the checklist for a first-time user (no dismissed flag)', async () => { + (ApiClient as any).prototype.getGames.mockResolvedValue({ success: true, data: [] }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('onboarding-checklist')).toBeInTheDocument(); + }); + }); + + it('does not render the checklist when the dismissed flag is set in localStorage', async () => { + const state = { + auth: { isAuthenticated: false }, + flags: { [ONBOARDING_CHECKLIST_DISMISSED_FLAG]: true }, + storedAt: Date.now(), + }; + localStorage.setItem('stc_global_state_v1', JSON.stringify(state)); + (ApiClient as any).prototype.getGames.mockResolvedValue({ success: true, data: [] }); + + render(); + + await waitFor(() => { + expect(screen.queryByTestId('onboarding-checklist')).not.toBeInTheDocument(); + }); + }); + + it('hides the checklist and persists the dismissal when dismiss button is clicked', async () => { + (ApiClient as any).prototype.getGames.mockResolvedValue({ success: true, data: [] }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('onboarding-checklist')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId('onboarding-checklist-dismiss')); + + expect(screen.queryByTestId('onboarding-checklist')).not.toBeInTheDocument(); + + // Flag must be written to localStorage so a page reload keeps it dismissed + const stored = JSON.parse(localStorage.getItem('stc_global_state_v1') ?? '{}'); + expect(stored.flags?.[ONBOARDING_CHECKLIST_DISMISSED_FLAG]).toBe(true); + }); + + it('checklist items can be toggled independently', async () => { + (ApiClient as any).prototype.getGames.mockResolvedValue({ success: true, data: [] }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('checklist-item-connect-wallet')).toBeInTheDocument(); + }); + + const checkbox = screen.getByTestId('checklist-item-connect-wallet') as HTMLInputElement; + expect(checkbox.checked).toBe(false); + fireEvent.click(checkbox); + expect(checkbox.checked).toBe(true); + }); + + it('checklist has accessible aria-label', async () => { + (ApiClient as any).prototype.getGames.mockResolvedValue({ success: true, data: [] }); + + render(); + + await waitFor(() => { + expect( + screen.getByRole('complementary', { name: /getting started checklist/i }), + ).toBeInTheDocument(); + }); + }); + + it('returning user: checklist absent when dismissal is persisted', async () => { + const state = { + auth: { isAuthenticated: false }, + flags: { [ONBOARDING_CHECKLIST_DISMISSED_FLAG]: true }, + storedAt: Date.now(), + }; + localStorage.setItem('stc_global_state_v1', JSON.stringify(state)); + (ApiClient as any).prototype.getGames.mockResolvedValue({ success: true, data: [] }); + + render(); + + await waitFor(() => { + expect(screen.queryByTestId('lobby-kpi-strip')).toBeInTheDocument(); + expect(screen.queryByTestId('onboarding-checklist')).not.toBeInTheDocument(); + }); + }); +}); + describe('GameLobby accessibility landmarks', () => { it('renders loading state with role="status" and aria-live', () => { (ApiClient as any).prototype.getGames.mockResolvedValue( diff --git a/frontend/tests/components/v1/ContractEventFeed.test.tsx b/frontend/tests/components/v1/ContractEventFeed.test.tsx index 31e4a405..f8ea2c53 100644 --- a/frontend/tests/components/v1/ContractEventFeed.test.tsx +++ b/frontend/tests/components/v1/ContractEventFeed.test.tsx @@ -1,5 +1,5 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -import { ContractEventFeed } from '@/components/v1/ContractEventFeed'; +import { ContractEventFeed, eventToTimelineItem } from '@/components/v1/ContractEventFeed'; import type { ContractEventFeedProps } from '@/components/v1/ContractEventFeed'; import type { ContractEvent } from '@/types/contracts/events'; import { @@ -8,6 +8,8 @@ import { clearEventFeedFilter, getSavedFilterPresets, } from '@/services/global-state-store'; +import { Timeline } from '@/components/v1/Timeline'; +import type { TimelineItemData } from '@/components/v1/Timeline'; const mockStart = vi.fn(); const mockStop = vi.fn(); @@ -695,3 +697,172 @@ describe('ContractEventFeed - snapshot', () => { expect(header).toMatchSnapshot(); }); }); + +// ── Timeline integration ─────────────────────────────────────────────────── + +describe('ContractEventFeed - timeline composition', () => { + it('event list carries sc-timeline--vertical class for timeline composition', () => { + mockEvents = [makeEvent({ id: 'tl-1' }), makeEvent({ id: 'tl-2' })]; + renderFeed(); + const list = screen.getByTestId('contract-event-feed-list'); + expect(list.classList.contains('sc-timeline')).toBe(true); + expect(list.classList.contains('sc-timeline--vertical')).toBe(true); + }); + + it('event list items render in chronological order (oldest first in ol[reversed])', () => { + const t1 = new Date('2025-06-01T10:00:00Z').toISOString(); + const t2 = new Date('2025-06-01T11:00:00Z').toISOString(); + mockEvents = [ + makeEvent({ id: 'older', timestamp: t1 }), + makeEvent({ id: 'newer', timestamp: t2 }), + ]; + renderFeed(); + const list = screen.getByTestId('contract-event-feed-list'); + const items = Array.from(list.querySelectorAll('li[data-event-id]')); + expect(items[0].getAttribute('data-event-id')).toBe('older'); + expect(items[1].getAttribute('data-event-id')).toBe('newer'); + }); +}); + +// ── Timeline component unit tests ───────────────────────────────────────── + +describe('Timeline component', () => { + const items: TimelineItemData[] = [ + { id: 'step-1', label: 'Submitted', status: 'completed', timestamp: '10:00:00' }, + { id: 'step-2', label: 'Pending', status: 'active' }, + { id: 'step-3', label: 'Confirmed', status: 'idle' }, + ]; + + it('renders all items', () => { + render(); + expect(screen.getByTestId('tl-item-step-1')).toBeInTheDocument(); + expect(screen.getByTestId('tl-item-step-2')).toBeInTheDocument(); + expect(screen.getByTestId('tl-item-step-3')).toBeInTheDocument(); + }); + + it('renders items in provided order', () => { + render(); + const list = screen.getByTestId('tl-order'); + const rendered = Array.from(list.querySelectorAll('[data-status]')); + expect(rendered[0].getAttribute('data-status')).toBe('completed'); + expect(rendered[1].getAttribute('data-status')).toBe('active'); + expect(rendered[2].getAttribute('data-status')).toBe('idle'); + }); + + it('applies correct status data attribute to each item', () => { + render(); + expect(screen.getByTestId('tl-status-item-step-1')).toHaveAttribute('data-status', 'completed'); + expect(screen.getByTestId('tl-status-item-step-2')).toHaveAttribute('data-status', 'active'); + }); + + it('renders timestamp slot when provided', () => { + render(); + expect(screen.getByTestId('tl-ts-item-step-1-timestamp')).toHaveTextContent('10:00:00'); + }); + + it('omits timestamp slot when not provided', () => { + render(); + expect(screen.queryByTestId('tl-nots-item-step-2-timestamp')).not.toBeInTheDocument(); + }); + + it('renders metadata slot when provided', () => { + const withMeta: TimelineItemData[] = [ + { id: 'm1', label: 'Event', status: 'idle', metadata: 'CABC1234' }, + ]; + render(); + expect(screen.getByTestId('tl-meta-item-m1-metadata')).toHaveTextContent('CABC1234'); + }); + + it('uses horizontal orientation class', () => { + render(); + expect(screen.getByTestId('tl-h').classList.contains('sc-timeline--horizontal')).toBe(true); + }); + + it('uses vertical orientation class by default', () => { + render(); + expect(screen.getByTestId('tl-v').classList.contains('sc-timeline--vertical')).toBe(true); + }); + + it('applies compact class when compact=true', () => { + render(); + expect(screen.getByTestId('tl-c').classList.contains('sc-timeline--compact')).toBe(true); + }); + + it('renders empty list without crashing when items is empty', () => { + render(); + expect(screen.getByTestId('tl-empty')).toBeInTheDocument(); + expect(screen.getByTestId('tl-empty').querySelectorAll('li').length).toBe(0); + }); +}); + +// ── eventToTimelineItem adapter ──────────────────────────────────────────── + +describe('eventToTimelineItem', () => { + it('maps event id to timeline item id', () => { + const event: ContractEvent = { + id: 'evt-xyz', + type: 'transfer', + contractId: 'CXYZ1234', + timestamp: new Date('2025-01-01T09:30:00Z').toISOString(), + data: null, + }; + const item = eventToTimelineItem(event); + expect(item.id).toBe('evt-xyz'); + }); + + it('maps event type to timeline label', () => { + const event: ContractEvent = { + id: 'e1', + type: 'game_end', + contractId: null, + timestamp: new Date().toISOString(), + data: null, + }; + expect(eventToTimelineItem(event).label).toBe('game_end'); + }); + + it('falls back to "unknown" label when event type is undefined', () => { + const event: ContractEvent = { + id: 'e2', + type: undefined as unknown as string, + contractId: null, + timestamp: new Date().toISOString(), + data: null, + }; + expect(eventToTimelineItem(event).label).toBe('unknown'); + }); + + it('sets timestamp to null when timestamp is invalid', () => { + const event: ContractEvent = { + id: 'e3', + type: 'win', + contractId: null, + timestamp: 'not-a-date', + data: null, + }; + expect(eventToTimelineItem(event).timestamp).toBeNull(); + }); + + it('includes truncated contractId as metadata', () => { + const event: ContractEvent = { + id: 'e4', + type: 'mint', + contractId: 'CABCDEFGHIJ1234567890', + timestamp: new Date().toISOString(), + data: null, + }; + const item = eventToTimelineItem(event); + expect(item.metadata).toBe('CABCDEFGHI'); + }); + + it('sets metadata to null when contractId is absent', () => { + const event: ContractEvent = { + id: 'e5', + type: 'burn', + contractId: null, + timestamp: new Date().toISOString(), + data: null, + }; + expect(eventToTimelineItem(event).metadata).toBeNull(); + }); +});