From d872eb25f61ad6a1d199a43621b6f2c6965931ad Mon Sep 17 00:00:00 2001 From: Alexandre Moore Date: Sat, 18 Jul 2026 21:52:53 +0200 Subject: [PATCH 1/2] Add chance-to-win and time-remaining browse filters, persist filter state Backend: - New entries column on giveaways (+ migration); the scraper always parsed the entry count but the sync dropped it. Stored on create and refreshed on every scan update - GiveawayResponse exposes entries and a computed win_chance percent (copies/entries*100, capped at 100; 100 when no entries yet) - /giveaways/active and /giveaways/wishlist accept min_chance (0.01-100%) and ending_within (minutes), filtered in SQL alongside the existing min_score Frontend: - Giveaways page: stepped sliders on the Active and Wishlist tabs - Min Chance (Any, then 0.01%..100%) and Ending In (5min..24h, last notch Any); win-chance badge on cards with entry-count tooltip - Filters (tab, score, safety, chance, time) now persist across visits via a localStorage-backed zustand store; search stays session-only --- ...d4e7f2a8b1c5_add_giveaway_entries_count.py | 41 +++++++ backend/src/api/routers/giveaways.py | 12 +- backend/src/api/schemas/giveaway.py | 17 ++- backend/src/models/giveaway.py | 5 + backend/src/repositories/giveaway.py | 41 ++++++- backend/src/services/giveaway_query.py | 10 +- backend/src/services/giveaway_sync.py | 6 + .../tests/unit/test_repositories_giveaway.py | 76 ++++++++++++ backend/tests/unit/test_schemas_giveaway.py | 18 +++ frontend/e2e/mocks.ts | 2 + frontend/src/hooks/useGiveaways.test.tsx | 40 +++++++ frontend/src/hooks/useGiveaways.ts | 8 ++ frontend/src/pages/Giveaways.tsx | 112 ++++++++++++++++-- .../src/stores/giveawayFiltersStore.test.ts | 63 ++++++++++ frontend/src/stores/giveawayFiltersStore.ts | 41 +++++++ frontend/src/types/index.ts | 2 + 16 files changed, 478 insertions(+), 16 deletions(-) create mode 100644 backend/src/alembic/versions/d4e7f2a8b1c5_add_giveaway_entries_count.py create mode 100644 frontend/src/stores/giveawayFiltersStore.test.ts create mode 100644 frontend/src/stores/giveawayFiltersStore.ts diff --git a/backend/src/alembic/versions/d4e7f2a8b1c5_add_giveaway_entries_count.py b/backend/src/alembic/versions/d4e7f2a8b1c5_add_giveaway_entries_count.py new file mode 100644 index 0000000..55c8804 --- /dev/null +++ b/backend/src/alembic/versions/d4e7f2a8b1c5_add_giveaway_entries_count.py @@ -0,0 +1,41 @@ +"""add entries count to giveaways + +The scraper has always parsed the entry count from listing pages; store it so +the UI can show and filter by chance-to-win (copies / entries). + +Revision ID: d4e7f2a8b1c5 +Revises: c8d2e5f1a3b9 +Create Date: 2026-07-18 17:00:00.000000 + +""" +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = 'd4e7f2a8b1c5' +down_revision: str | Sequence[str] | None = 'c8d2e5f1a3b9' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + with op.batch_alter_table('giveaways', schema=None) as batch_op: + batch_op.add_column( + sa.Column( + 'entries', + sa.Integer(), + nullable=False, + server_default='0', + comment='Entry count as of the last scan (0 = none yet or unknown)', + ) + ) + + +def downgrade() -> None: + """Downgrade schema.""" + with op.batch_alter_table('giveaways', schema=None) as batch_op: + batch_op.drop_column('entries') diff --git a/backend/src/api/routers/giveaways.py b/backend/src/api/routers/giveaways.py index 35662ef..0b0adb3 100644 --- a/backend/src/api/routers/giveaways.py +++ b/backend/src/api/routers/giveaways.py @@ -102,6 +102,8 @@ async def get_active_giveaways( giveaway_service: GiveawayServiceDep, min_score: int | None = Query(default=None, ge=0, le=10, description="Minimum review score (0-10)"), is_safe: bool | None = Query(default=None, description="Filter by safety status (true=safe, false=unsafe)"), + min_chance: float | None = Query(default=None, ge=0.01, le=100, description="Minimum win chance in percent"), + ending_within: int | None = Query(default=None, ge=1, description="Only giveaways ending within this many minutes"), limit: int = Query(default=50, ge=1, le=200, description="Maximum results"), offset: int = Query(default=0, ge=0, description="Number of records to skip"), ) -> dict[str, Any]: @@ -112,7 +114,8 @@ async def get_active_giveaways( Success response with list of active giveaways """ giveaways = await giveaway_service.get_active_giveaways( - limit=limit, offset=offset, min_score=min_score, is_safe=is_safe + limit=limit, offset=offset, min_score=min_score, is_safe=is_safe, + min_chance=min_chance, ending_within_minutes=ending_within, ) # Enrich with game data (thumbnails, reviews) @@ -139,6 +142,8 @@ async def get_active_giveaways( ) async def get_wishlist_giveaways( giveaway_service: GiveawayServiceDep, + min_chance: float | None = Query(default=None, ge=0.01, le=100, description="Minimum win chance in percent"), + ending_within: int | None = Query(default=None, ge=1, description="Only giveaways ending within this many minutes"), limit: int = Query(default=50, ge=1, le=200, description="Maximum results"), offset: int = Query(default=0, ge=0, description="Number of records to skip"), ) -> dict[str, Any]: @@ -148,7 +153,10 @@ async def get_wishlist_giveaways( Returns: Success response with list of wishlist giveaways """ - giveaways = await giveaway_service.giveaway_repo.get_wishlist(limit=limit, offset=offset) + giveaways = await giveaway_service.giveaway_repo.get_wishlist( + limit=limit, offset=offset, + min_chance=min_chance, ending_within_minutes=ending_within, + ) # Enrich with game data (thumbnails, reviews) giveaways = await giveaway_service.enrich_giveaways_with_game_data(giveaways) diff --git a/backend/src/api/schemas/giveaway.py b/backend/src/api/schemas/giveaway.py index b92ab7f..ccc34c2 100644 --- a/backend/src/api/schemas/giveaway.py +++ b/backend/src/api/schemas/giveaway.py @@ -7,7 +7,7 @@ from datetime import datetime from typing import Any -from pydantic import BaseModel, Field, field_serializer +from pydantic import BaseModel, Field, computed_field, field_serializer class GiveawayBase(BaseModel): @@ -51,6 +51,12 @@ class GiveawayBase(BaseModel): ge=1, examples=[1], ) + entries: int = Field( + default=0, + description="Entry count as of the last scan (0 = none yet or unknown)", + ge=0, + examples=[250], + ) end_time: datetime | None = Field( default=None, description="When the giveaway ends (UTC)", @@ -112,6 +118,15 @@ class GiveawayResponse(GiveawayBase): description="Internal giveaway ID", examples=[123], ) + + @computed_field(description="Win chance in percent (copies/entries*100) as of the last scan") + @property + def win_chance(self) -> float: + """Chance to win in percent; 100 when nobody has entered yet.""" + if self.entries <= 0: + return 100.0 + return min(100.0, round(self.copies * 100.0 / self.entries, 2)) + discovered_at: datetime = Field( ..., description="When giveaway was first discovered (UTC)", diff --git a/backend/src/models/giveaway.py b/backend/src/models/giveaway.py index 4df3c09..e5a2e0c 100644 --- a/backend/src/models/giveaway.py +++ b/backend/src/models/giveaway.py @@ -113,6 +113,11 @@ class Giveaway(Base, TimestampMixin): default=1, comment="Number of copies available", ) + entries: Mapped[int] = mapped_column( + Integer, + default=0, + comment="Entry count as of the last scan (0 = none yet or unknown)", + ) end_time: Mapped[datetime | None] = mapped_column( DateTime, nullable=True, diff --git a/backend/src/repositories/giveaway.py b/backend/src/repositories/giveaway.py index 9a6e5b0..f5d9d7a 100644 --- a/backend/src/repositories/giveaway.py +++ b/backend/src/repositories/giveaway.py @@ -96,9 +96,35 @@ async def get_by_code(self, code: str) -> Giveaway | None: result = await self.session.execute(query) return result.scalar_one_or_none() + def _browse_filter_conditions( + self, now: datetime, min_chance: float | None, ending_within_minutes: int | None + ) -> list[Any]: + """Shared UI browse-filter conditions (chance to win, time remaining). + + A giveaway with no recorded entries yet always passes the chance + filter — its chance is effectively 100%. + """ + conditions: list[Any] = [] + + if min_chance is not None and min_chance > 0: + conditions.append( + or_( + self.model.entries == 0, + self.model.copies * 100.0 / self.model.entries >= min_chance, + ) + ) + + if ending_within_minutes is not None and ending_within_minutes > 0: + conditions.append( + self.model.end_time <= now + timedelta(minutes=ending_within_minutes) + ) + + return conditions + async def get_active( self, limit: int | None = None, offset: int = 0, min_score: int | None = None, - is_safe: bool | None = None + is_safe: bool | None = None, min_chance: float | None = None, + ending_within_minutes: int | None = None, ) -> list[Giveaway]: """ Get all active (non-expired) giveaways. @@ -111,6 +137,9 @@ async def get_active( offset: Number of records to skip (for pagination) min_score: Minimum review score (0-10) to filter by is_safe: Filter by safety status (True=safe only, False=unsafe only, None=all) + min_chance: Minimum win chance in percent (copies/entries*100); + giveaways with no recorded entries yet always pass + ending_within_minutes: Only giveaways ending within this many minutes Returns: List of active giveaways, ordered by end_time (soonest first) @@ -133,6 +162,10 @@ async def get_active( if is_safe is not None: conditions.append(self.model.is_safe == is_safe) # noqa: E712 + conditions.extend( + self._browse_filter_conditions(now, min_chance, ending_within_minutes) + ) + # If min_score is specified, join with Game table and filter # Games default to review_score=0 when unknown if min_score is not None and min_score > 0: @@ -365,7 +398,8 @@ async def get_entered( return list(result.scalars().all()) async def get_wishlist( - self, limit: int | None = None, offset: int | None = None + self, limit: int | None = None, offset: int | None = None, + min_chance: float | None = None, ending_within_minutes: int | None = None, ) -> list[Giveaway]: """ Get active wishlist giveaways. @@ -373,6 +407,8 @@ async def get_wishlist( Args: limit: Maximum number to return offset: Number of records to skip + min_chance: Minimum win chance in percent (copies/entries*100) + ending_within_minutes: Only giveaways ending within this many minutes Returns: List of wishlist giveaways that are still active (not expired) @@ -387,6 +423,7 @@ async def get_wishlist( self.model.is_wishlist == True, # noqa: E712 self.model.is_hidden == False, # noqa: E712 (self.model.end_time == None) | (self.model.end_time > now), # noqa: E711 + *self._browse_filter_conditions(now, min_chance, ending_within_minutes), ) .order_by(self.model.end_time.asc()) ) diff --git a/backend/src/services/giveaway_query.py b/backend/src/services/giveaway_query.py index 7542986..7d924f3 100644 --- a/backend/src/services/giveaway_query.py +++ b/backend/src/services/giveaway_query.py @@ -156,7 +156,8 @@ async def evaluate_and_get_eligible( async def get_active_giveaways( self, limit: int | None = None, offset: int = 0, min_score: int | None = None, - is_safe: bool | None = None + is_safe: bool | None = None, min_chance: float | None = None, + ending_within_minutes: int | None = None, ) -> list[Giveaway]: """ Get all active (non-expired) giveaways. @@ -166,6 +167,8 @@ async def get_active_giveaways( offset: Number of records to skip (for pagination) min_score: Minimum review score (0-10) to filter by is_safe: Filter by safety status (True=safe only, False=unsafe only, None=all) + min_chance: Minimum win chance in percent (copies/entries*100) + ending_within_minutes: Only giveaways ending within this many minutes Returns: List of active giveaways @@ -173,7 +176,10 @@ async def get_active_giveaways( Example: >>> active = await service.get_active_giveaways(limit=20, offset=40, min_score=7, is_safe=True) """ - return await self.giveaway_repo.get_active(limit=limit, offset=offset, min_score=min_score, is_safe=is_safe) + return await self.giveaway_repo.get_active( + limit=limit, offset=offset, min_score=min_score, is_safe=is_safe, + min_chance=min_chance, ending_within_minutes=ending_within_minutes, + ) async def get_all_giveaways( self, limit: int | None = None, offset: int = 0 diff --git a/backend/src/services/giveaway_sync.py b/backend/src/services/giveaway_sync.py index d5ecc88..3386419 100644 --- a/backend/src/services/giveaway_sync.py +++ b/backend/src/services/giveaway_sync.py @@ -240,6 +240,7 @@ async def sync_entered_giveaways(self, pages: int = 1) -> int: url=url, game_name=entry["game_name"], price=entry.get("price", 0), + entries=entry.get("entries", 0), game_id=entry.get("game_id"), end_time=entry.get("end_time"), is_entered=True, @@ -273,6 +274,7 @@ async def _create_giveaway(self, ga_data: dict) -> Giveaway: game_name=ga_data["game_name"], price=ga_data["price"], copies=ga_data.get("copies", 1), + entries=ga_data.get("entries", 0), end_time=ga_data.get("end_time"), game_id=ga_data.get("game_id"), is_wishlist=ga_data.get("is_wishlist", False), @@ -291,6 +293,10 @@ async def _update_giveaway(self, giveaway: Giveaway, ga_data: dict) -> None: # Update mutable fields giveaway.end_time = ga_data.get("end_time", giveaway.end_time) + # Entry counts move constantly; keep the latest scan's value. + if ga_data.get("entries") is not None: + giveaway.entries = ga_data["entries"] + # Update game_id if we found it and didn't have it before if ga_data.get("game_id") and not giveaway.game_id: giveaway.game_id = ga_data["game_id"] diff --git a/backend/tests/unit/test_repositories_giveaway.py b/backend/tests/unit/test_repositories_giveaway.py index 76621bd..acfb88e 100644 --- a/backend/tests/unit/test_repositories_giveaway.py +++ b/backend/tests/unit/test_repositories_giveaway.py @@ -91,6 +91,82 @@ async def test_get_active_returns_only_active(test_db): assert active[0].code == "ACTIVE1" +@pytest.mark.asyncio +async def test_get_active_min_chance_filter(test_db): + """min_chance keeps giveaways with copies/entries*100 >= threshold, + and always keeps ones with no recorded entries.""" + async with test_db() as session: + repo = GiveawayRepository(session) + now = utcnow() + future = now + timedelta(hours=24) + + # 1 copy / 50 entries = 2% + await repo.create(code="GOOD", game_name="A", price=10, + url="http://x/1", end_time=future, copies=1, entries=50) + # 1 copy / 10000 entries = 0.01% + await repo.create(code="LONGSHOT", game_name="B", price=10, + url="http://x/2", end_time=future, copies=1, entries=10000) + # No entries recorded yet -> always passes + await repo.create(code="FRESH", game_name="C", price=10, + url="http://x/3", end_time=future, copies=1, entries=0) + await session.commit() + + codes = {g.code for g in await repo.get_active(min_chance=1.0)} + assert codes == {"GOOD", "FRESH"} + + # Threshold at exactly 0.01 keeps the longshot too + codes = {g.code for g in await repo.get_active(min_chance=0.01)} + assert codes == {"GOOD", "LONGSHOT", "FRESH"} + + +@pytest.mark.asyncio +async def test_get_active_ending_within_filter(test_db): + """ending_within_minutes keeps only giveaways ending soon enough.""" + async with test_db() as session: + repo = GiveawayRepository(session) + now = utcnow() + + await repo.create(code="SOON", game_name="A", price=10, + url="http://x/1", end_time=now + timedelta(hours=2)) + await repo.create(code="LATER", game_name="B", price=10, + url="http://x/2", end_time=now + timedelta(hours=48)) + await session.commit() + + codes = {g.code for g in await repo.get_active(ending_within_minutes=360)} + assert codes == {"SOON"} + + codes = {g.code for g in await repo.get_active(ending_within_minutes=4320)} + assert codes == {"SOON", "LATER"} + + +@pytest.mark.asyncio +async def test_get_wishlist_browse_filters(test_db): + """The wishlist listing honors min_chance and ending_within_minutes.""" + async with test_db() as session: + repo = GiveawayRepository(session) + now = utcnow() + + await repo.create(code="WSOON", game_name="A", price=10, url="http://x/1", + end_time=now + timedelta(hours=2), is_wishlist=True, + copies=1, entries=10) # 10% + await repo.create(code="WCROWD", game_name="B", price=10, url="http://x/2", + end_time=now + timedelta(hours=2), is_wishlist=True, + copies=1, entries=5000) # 0.02% + await repo.create(code="WLATER", game_name="C", price=10, url="http://x/3", + end_time=now + timedelta(hours=48), is_wishlist=True, + copies=1, entries=10) + await session.commit() + + codes = {g.code for g in await repo.get_wishlist(min_chance=1.0)} + assert codes == {"WSOON", "WLATER"} + + codes = {g.code for g in await repo.get_wishlist(ending_within_minutes=360)} + assert codes == {"WSOON", "WCROWD"} + + codes = {g.code for g in await repo.get_wishlist(min_chance=1.0, ending_within_minutes=360)} + assert codes == {"WSOON"} + + @pytest.mark.asyncio async def test_get_active_excludes_hidden(test_db): """Test getting active giveaways excludes hidden ones.""" diff --git a/backend/tests/unit/test_schemas_giveaway.py b/backend/tests/unit/test_schemas_giveaway.py index e88386e..1767ca8 100644 --- a/backend/tests/unit/test_schemas_giveaway.py +++ b/backend/tests/unit/test_schemas_giveaway.py @@ -92,6 +92,24 @@ def test_giveaway_response(): assert giveaway.discovered_at is not None +def test_giveaway_response_win_chance(): + """win_chance = copies/entries*100, capped at 100, 100 when no entries.""" + def mk(copies, entries): + return GiveawayResponse( + id=1, code="GA1", url="test", game_name="G", price=50, + copies=copies, entries=entries, discovered_at=utcnow(), + ) + + assert mk(1, 0).win_chance == 100.0 # nobody entered yet + assert mk(1, 4).win_chance == 25.0 + assert mk(1, 10000).win_chance == 0.01 + assert mk(5, 2).win_chance == 100.0 # more copies than entries -> capped + assert mk(1, 3).win_chance == 33.33 # rounded to 2 decimals + + # win_chance is serialized in API payloads + assert mk(1, 4).model_dump()["win_chance"] == 25.0 + + def test_giveaway_list(): """Test GiveawayList.""" giveaway1 = GiveawayResponse( diff --git a/frontend/e2e/mocks.ts b/frontend/e2e/mocks.ts index fe0ea4e..4f8a4c0 100644 --- a/frontend/e2e/mocks.ts +++ b/frontend/e2e/mocks.ts @@ -66,6 +66,8 @@ function giveaway(id: number, over: Record = {}) { game_id: 400 + id, price: 25 + id, copies: 1, + entries: 100, + win_chance: 1.0, end_time: inHours(24 + id), discovered_at: inHours(-2), entered_at: null, diff --git a/frontend/src/hooks/useGiveaways.test.tsx b/frontend/src/hooks/useGiveaways.test.tsx index 875e745..f10d8a4 100644 --- a/frontend/src/hooks/useGiveaways.test.tsx +++ b/frontend/src/hooks/useGiveaways.test.tsx @@ -58,6 +58,8 @@ const mockGiveaway: Giveaway = { game_id: 12345, price: 5, copies: 1, + entries: 100, + win_chance: 1.0, end_time: '2024-01-02T00:00:00Z', discovered_at: '2024-01-01T00:00:00Z', entered_at: null, @@ -134,6 +136,44 @@ describe('useGiveaways', () => { ); }); + it('should map chance and time-remaining filters to query params', async () => { + mockApi.get.mockResolvedValueOnce({ + success: true, + data: { giveaways: [], count: 0 }, + }); + + const { result } = renderHook( + () => useGiveaways({ status: 'active', minChance: 0.5, endingWithin: 360 }), + { wrapper: createWrapper() } + ); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(mockApi.get).toHaveBeenCalledWith( + '/api/v1/giveaways/active?min_chance=0.5&ending_within=360&limit=20' + ); + }); + + it('should omit chance and time filters when zero/unset', async () => { + mockApi.get.mockResolvedValueOnce({ + success: true, + data: { giveaways: [], count: 0 }, + }); + + const { result } = renderHook( + () => useGiveaways({ status: 'wishlist', minChance: 0, endingWithin: 0 }), + { wrapper: createWrapper() } + ); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(mockApi.get).toHaveBeenCalledWith('/api/v1/giveaways/wishlist?limit=20'); + }); + it('should handle fetch error', async () => { mockApi.get.mockResolvedValueOnce({ success: false, diff --git a/frontend/src/hooks/useGiveaways.ts b/frontend/src/hooks/useGiveaways.ts index 10f7451..65c954f 100644 --- a/frontend/src/hooks/useGiveaways.ts +++ b/frontend/src/hooks/useGiveaways.ts @@ -26,6 +26,8 @@ export interface GiveawayFilters { limit?: number; minScore?: number; // Minimum review score (0-10) safetyFilter?: 'all' | 'safe' | 'unsafe'; // Filter by safety status + minChance?: number; // Minimum win chance in percent (0.01-100) + endingWithin?: number; // Only giveaways ending within this many minutes } /** @@ -92,6 +94,12 @@ function buildGiveawaysEndpoint( if (filters.safetyFilter && filters.safetyFilter !== 'all') { params.set('is_safe', filters.safetyFilter === 'safe' ? 'true' : 'false'); } + if (filters.minChance !== undefined && filters.minChance > 0) { + params.set('min_chance', String(filters.minChance)); + } + if (filters.endingWithin !== undefined && filters.endingWithin > 0) { + params.set('ending_within', String(filters.endingWithin)); + } params.set('limit', String(limit)); if (offset > 0) { diff --git a/frontend/src/pages/Giveaways.tsx b/frontend/src/pages/Giveaways.tsx index 882cf70..adfba56 100644 --- a/frontend/src/pages/Giveaways.tsx +++ b/frontend/src/pages/Giveaways.tsx @@ -1,20 +1,35 @@ import { useState, useEffect, useRef } from 'react'; -import { ExternalLink, Eye, EyeOff, Gift, Clock, AlertCircle, Loader2, X, Heart, Trophy, Star, Shield, ShieldAlert, EyeOff as HideIcon, MessageSquare } from 'lucide-react'; +import { ExternalLink, Eye, EyeOff, Gift, Clock, AlertCircle, Loader2, X, Heart, Trophy, Star, Shield, ShieldAlert, EyeOff as HideIcon, MessageSquare, Percent } from 'lucide-react'; import { SiSteam } from 'react-icons/si'; import { Card, Button, Badge, Input, CardSkeleton } from '@/components/common'; import { useInfiniteGiveaways, useEnterGiveaway, useHideGiveaway, useUnhideGiveaway, useRemoveEntry, useCheckGiveawaySafety, useHideOnSteamGifts, usePostComment, type GiveawayFilters } from '@/hooks'; import { showSuccess, showError } from '@/stores/uiStore'; +import { useGiveawayFiltersStore } from '@/stores/giveawayFiltersStore'; import type { Giveaway } from '@/types'; +// Stepped slider stops. Chance: first notch = Any, then 0.01% up to 100%. +const CHANCE_STOPS = [0, 0.01, 0.1, 0.25, 0.5, 1, 2, 5, 10, 25, 50, 100]; +// Time remaining (minutes): 5min up to 24h, last notch = Any. +const ENDING_STOPS = [5, 15, 30, 60, 120, 180, 360, 720, 1440, 0]; + +const formatChanceStop = (v: number) => (v === 0 ? 'Any' : `≥${v}%`); +const formatEndingStop = (v: number) => + v === 0 ? 'Any' : v < 60 ? `${v}min` : `${v / 60}h`; + +// Map a stored filter value back to its slider notch (fallback: Any). +const stopIndex = (stops: number[], value: number | undefined, fallback: number) => { + const idx = stops.indexOf(value ?? 0); + return idx === -1 ? fallback : idx; +}; + /** * Giveaways page * Browse, filter, and enter giveaways */ export function Giveaways() { - const [filters, setFilters] = useState>({ - status: 'active', - limit: 20, - }); + // Filters persist across visits (localStorage-backed store) + const filters = useGiveawayFiltersStore((s) => s.filters); + const setFilters = useGiveawayFiltersStore((s) => s.setFilters); const [searchInput, setSearchInput] = useState(''); const { @@ -66,15 +81,23 @@ export function Giveaways() { const handleSearch = (e: React.FormEvent) => { e.preventDefault(); - setFilters(prev => ({ ...prev, search: searchInput })); + setFilters({ search: searchInput }); }; const handleStatusFilter = (status: GiveawayFilters['status']) => { - setFilters(prev => ({ ...prev, status })); + setFilters({ status }); }; const handleScoreFilter = (score: number) => { - setFilters(prev => ({ ...prev, minScore: score })); + setFilters({ minScore: score }); + }; + + const handleChanceFilter = (minChance: number) => { + setFilters({ minChance }); + }; + + const handleEndingWithinFilter = (endingWithin: number) => { + setFilters({ endingWithin }); }; const handleEnter = async (giveaway: Giveaway) => { @@ -145,7 +168,7 @@ export function Giveaways() { }; const handleSafetyFilter = (safetyFilter: 'all' | 'safe' | 'unsafe') => { - setFilters(prev => ({ ...prev, safetyFilter })); + setFilters({ safetyFilter }); }; if (error) { @@ -240,6 +263,68 @@ export function Giveaways() { )} + {/* Win Chance Filter (slider: Any, then 0.01% .. 100%) */} + {(filters.status === 'active' || filters.status === 'wishlist') && ( +
+ + + Min Chance: + + handleChanceFilter(CHANCE_STOPS[Number(e.target.value)])} + className="w-24 h-2 bg-gray-300 dark:bg-gray-600 rounded-lg appearance-none cursor-pointer accent-primary-light" + /> + + {formatChanceStop(filters.minChance ?? 0)} + + {(filters.minChance ?? 0) > 0 && ( + + )} +
+ )} + + {/* Time Remaining Filter (slider: 5min .. 24h, last notch = Any) */} + {(filters.status === 'active' || filters.status === 'wishlist') && ( +
+ + + Ending in: + + handleEndingWithinFilter(ENDING_STOPS[Number(e.target.value)])} + className="w-24 h-2 bg-gray-300 dark:bg-gray-600 rounded-lg appearance-none cursor-pointer accent-primary-light" + /> + + {formatEndingStop(filters.endingWithin ?? 0)} + + {(filters.endingWithin ?? 0) > 0 && ( + + )} +
+ )} + {/* Safety Filter */} {filters.status === 'active' && (
@@ -434,6 +519,15 @@ function GiveawayCard({ giveaway, onEnter, onHide, onUnhide, onRemoveEntry, onCh {timeLeft} )} + {giveaway.entries > 0 && !isExpired && ( + 1 ? 'copies' : 'copy'} (as of last scan)`} + > + + {giveaway.win_chance}% + + )}
{/* Steam Reviews */} diff --git a/frontend/src/stores/giveawayFiltersStore.test.ts b/frontend/src/stores/giveawayFiltersStore.test.ts new file mode 100644 index 0000000..f7e21a7 --- /dev/null +++ b/frontend/src/stores/giveawayFiltersStore.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; +import { useGiveawayFiltersStore } from './giveawayFiltersStore'; + +// localStorage is a vi.fn() mock (see test/setup.ts); read what the store +// last wrote through the mock's call log. +function lastPersistedState() { + const calls = (localStorage.setItem as Mock).mock.calls.filter( + ([key]) => key === 'giveaway-filters' + ); + expect(calls.length).toBeGreaterThan(0); + return JSON.parse(calls[calls.length - 1][1]).state; +} + +describe('giveawayFiltersStore', () => { + beforeEach(() => { + vi.clearAllMocks(); + useGiveawayFiltersStore.getState().resetFilters(); + }); + + it('starts with default filters', () => { + const { filters } = useGiveawayFiltersStore.getState(); + expect(filters).toEqual({ status: 'active', limit: 20 }); + }); + + it('merges partial updates', () => { + useGiveawayFiltersStore.getState().setFilters({ minScore: 7 }); + useGiveawayFiltersStore.getState().setFilters({ minChance: 0.5, endingWithin: 6 }); + + const { filters } = useGiveawayFiltersStore.getState(); + expect(filters.status).toBe('active'); + expect(filters.minScore).toBe(7); + expect(filters.minChance).toBe(0.5); + expect(filters.endingWithin).toBe(6); + }); + + it('persists filters to localStorage', () => { + useGiveawayFiltersStore.getState().setFilters({ minScore: 8, status: 'wishlist' }); + + const persisted = lastPersistedState(); + expect(persisted.filters.minScore).toBe(8); + expect(persisted.filters.status).toBe('wishlist'); + }); + + it('does not persist the search query', () => { + useGiveawayFiltersStore.getState().setFilters({ search: 'portal', minScore: 5 }); + + const persisted = lastPersistedState(); + expect(persisted.filters.search).toBeUndefined(); + expect(persisted.filters.minScore).toBe(5); + // ...but it stays available in memory for the current session + expect(useGiveawayFiltersStore.getState().filters.search).toBe('portal'); + }); + + it('resetFilters restores defaults', () => { + useGiveawayFiltersStore.getState().setFilters({ minScore: 9, minChance: 10 }); + useGiveawayFiltersStore.getState().resetFilters(); + + expect(useGiveawayFiltersStore.getState().filters).toEqual({ + status: 'active', + limit: 20, + }); + }); +}); diff --git a/frontend/src/stores/giveawayFiltersStore.ts b/frontend/src/stores/giveawayFiltersStore.ts new file mode 100644 index 0000000..eaa02d2 --- /dev/null +++ b/frontend/src/stores/giveawayFiltersStore.ts @@ -0,0 +1,41 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import type { GiveawayFilters } from '@/hooks/useGiveaways'; + +type PersistedFilters = Omit; + +interface GiveawayFiltersState { + filters: PersistedFilters; + setFilters: (update: Partial) => void; + resetFilters: () => void; +} + +const DEFAULT_FILTERS: PersistedFilters = { + status: 'active', + limit: 20, +}; + +/** + * Giveaways page filter store with localStorage persistence. + * + * Keeps the selected tab, score/safety/chance/time filters across visits so + * they don't have to be re-applied every time the page is opened. The search + * query is deliberately NOT persisted (a stale search silently filtering the + * list is more confusing than helpful). + */ +export const useGiveawayFiltersStore = create()( + persist( + (set) => ({ + filters: DEFAULT_FILTERS, + setFilters: (update) => + set((state) => ({ filters: { ...state.filters, ...update } })), + resetFilters: () => set({ filters: DEFAULT_FILTERS }), + }), + { + name: 'giveaway-filters', + partialize: (state) => ({ + filters: { ...state.filters, search: undefined }, + }), + } + ) +); diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 0933090..48f50d1 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -14,6 +14,8 @@ export interface Giveaway { game_id: number | null; price: number; copies: number; + entries: number; + win_chance: number; end_time: string | null; discovered_at: string; entered_at: string | null; From e605955f7eb0f260b92bea6456c360bc735970ae Mon Sep 17 00:00:00 2001 From: Alexandre Moore Date: Sat, 18 Jul 2026 22:43:18 +0200 Subject: [PATCH 2/2] Fix entries parsing, refine win-chance precision, show entries on cards - The entries count never parsed: the links row on SteamGifts is + {/* Entries */} + {!isExpired && ( +
+ + {giveaway.entries > 0 ? ( + + {giveaway.entries.toLocaleString()} {giveaway.entries === 1 ? 'entry' : 'entries'} + {giveaway.copies > 1 && ` · ${giveaway.copies} copies`} + + ) : ( + No entries recorded yet + )} +
+ )} + {/* Steam Reviews */} {giveaway.game_review_summary && (