diff --git a/app/src/components/announcement-banner.tsx b/app/src/components/announcement-banner.tsx index 04af586..a1086f5 100644 --- a/app/src/components/announcement-banner.tsx +++ b/app/src/components/announcement-banner.tsx @@ -1,26 +1,19 @@ import { useState } from 'react' -import type React from 'react' import { useTranslation } from 'react-i18next' -import { ArrowRight, Sparkles, X } from 'lucide-react' +import { ArrowRight, Info, User, X } from 'lucide-react' import { CUTOFF_AT } from '@mandatoto/shared/types' import { storage } from '@/lib/storage' -const BANNER_ID = 'telex-tip-v1' +const BANNER_ID = 'election-started-v1' const CUTOFF_MS = new Date(CUTOFF_AT).getTime() -function navigateToTelex(e: React.MouseEvent) { - e.preventDefault() - window.location.hash = 'jatek' - // Give the router one tick to mount the ballot page before scrolling - setTimeout(() => { - document.getElementById('telex-section')?.scrollIntoView({ behavior: 'smooth', block: 'start' }) - }, 80) -} - export function AnnouncementBanner() { const { t } = useTranslation() + + const shareToken = storage.getDraft<{ shareToken?: string }>()?.shareToken ?? null + const [dismissed, setDismissed] = useState( - () => storage.getDismissedBanners().includes(BANNER_ID) || Date.now() >= CUTOFF_MS, + () => storage.getDismissedBanners().includes(BANNER_ID) || Date.now() < CUTOFF_MS, ) if (dismissed) return null @@ -33,22 +26,21 @@ export function AnnouncementBanner() { return (
- {/* On mobile the whole strip (minus X) is tappable */} - - -

- {t('banner.text')} -

-
- - - {t('banner.cta')} - - + +

+ {t('banner.text')} +

+ + {shareToken && ( + + + {t('banner.cta')} + + + )} - )} - {searchLoading && } -
- {currentToken && ( - - )} -
+ {!hideHeader && ( + <> +

+ + {t(RESULTS_AVAILABLE ? 'leaderboard.individualLeaderboard' : 'leaderboard.individualLeaderboardPreResult')} +

+

+ {t(RESULTS_AVAILABLE ? 'leaderboard.individualSubtitle' : 'leaderboard.individualSubtitlePreResult')} +

+ )} - {isSearchActive && displayedEntries.length === 0 && !searchLoading && ( + {isSearchActive && shown.length === 0 && !searchLoading && (

{t('leaderboard.noSearchResults')}

@@ -156,7 +64,7 @@ export function LeaderboardTable({ entries, loading, currentToken }: Leaderboard

{t('leaderboard.empty')}

- ) : displayedEntries.length > 0 ? ( + ) : shown.length > 0 ? (
@@ -170,12 +78,11 @@ export function LeaderboardTable({ entries, loading, currentToken }: Leaderboard - {displayedEntries.map((entry, idx) => { + {shown.map((entry, idx) => { const num = entry.rank ?? (entries.length - idx) - const isCurrentUserRow = - userShareToken != null - ? entry.shareToken === userShareToken - : false + const isCurrentUserRow = userShareToken != null + ? entry.shareToken === userShareToken + : false return ( ('overall') + + const [searchQuery, setSearchQuery] = useState('') + const [searchResults, setSearchResults] = useState(null) + const [searchLoading, setSearchLoading] = useState(false) + const debounceRef = useRef | null>(null) + + const [findMeLoading, setFindMeLoading] = useState(false) + const [userShareToken, setUserShareToken] = useState(null) + + const isSearchActive = searchQuery.length >= 2 + + const clientFiltered = useMemo(() => { + if (searchQuery.length < 2) return null + const q = searchQuery.toLowerCase() + const matches = entries.filter((e) => e.displayName.toLowerCase().includes(q)) + return matches.length > 0 ? matches : null + }, [searchQuery, entries]) + + const searchServerSide = useCallback((query: string) => { + if (debounceRef.current) clearTimeout(debounceRef.current) + if (query.length < 2) { + setSearchResults(null) + setSearchLoading(false) + return + } + setSearchLoading(true) + debounceRef.current = setTimeout(() => { + api.getLeaderboard(query) + .then((data) => setSearchResults(data)) + .catch(() => {}) + .finally(() => setSearchLoading(false)) + }, 350) + }, []) + + function handleSearchChange(value: string) { + setSearchQuery(value) + setSearchResults(null) + if (value.length < 2) { + if (debounceRef.current) clearTimeout(debounceRef.current) + setSearchLoading(false) + return + } + const q = value.toLowerCase() + const hasClientMatch = entries.some((e) => e.displayName.toLowerCase().includes(q)) + if (!hasClientMatch) { + searchServerSide(value) + } else { + if (debounceRef.current) clearTimeout(debounceRef.current) + setSearchLoading(false) + } + } + + function clearSearch() { + setSearchQuery('') + setSearchResults(null) + setSearchLoading(false) + if (debounceRef.current) clearTimeout(debounceRef.current) + } + + async function handleFindMe() { + if (!currentToken) return + setFindMeLoading(true) + try { + const data = await api.getPrediction(currentToken) + if (!data) return + setUserShareToken(data.shareToken ?? null) + const name = data.displayName as string + setSearchQuery(name) + setSearchResults(null) + const q = name.toLowerCase() + const hasClientMatch = entries.some((e) => e.displayName.toLowerCase().includes(q)) + if (!hasClientMatch) { + searchServerSide(name) + } + } catch { + // ignore + } finally { + setFindMeLoading(false) + } + } + + // For the overall tab: honour server-search results (may include entries outside top 100). + const overallDisplayed = clientFiltered ?? searchResults ?? entries + + if (!RESULTS_AVAILABLE) { + return + } + + const partyTabIds = PARTY_OPTIONS.map((p) => p.id as TabId) + + const categoryTitleMap: Record = { + overall: t('leaderboard.individualLeaderboard'), + mkkp: t('leaderboard.categoryTabTitle', { party: 'MKKP' }), + tisza: t('leaderboard.categoryTabTitle', { party: 'TISZA' }), + mi_hazank: t('leaderboard.categoryTabTitle', { party: 'Mi Hazánk' }), + dk: t('leaderboard.categoryTabTitle', { party: 'DK' }), + fidesz_kdnp: t('leaderboard.categoryTabTitle', { party: 'FIDESZ-KDNP' }), + nationalities: t('leaderboard.categoryTabTitleNationalities'), + participation: t('leaderboard.categoryTabTitleParticipation'), + } + + const tabLabels: Record = { + overall: t('leaderboard.tabOverall'), + mkkp: 'MKKP', + tisza: 'TISZA', + mi_hazank: 'Mi Hazánk', + dk: 'DK', + fidesz_kdnp: 'FIDESZ-KDNP', + nationalities: t('leaderboard.tabNationalities'), + participation: t('leaderboard.tabParticipation'), + } + + const tabOrder: TabId[] = ['overall', ...partyTabIds, 'nationalities', 'participation'] + + function renderContent() { + if (activeTab === 'overall') { + return ( + + ) + } + + const nameFilter = searchQuery.length >= 2 ? searchQuery : undefined + + if (activeTab === 'nationalities') { + return ( + e.pctNationalities} + referenceValue={REFERENCE_RESULT.pctNationalities} + nameFilter={nameFilter} + /> + ) + } + + if (activeTab === 'participation') { + return ( + e.participationRate} + referenceValue={REFERENCE_RESULT.participationRate} + nameFilter={nameFilter} + /> + ) + } + + const party = PARTY_OPTIONS.find((p) => p.id === activeTab) + if (party) { + const fieldKey = `pct${party.id.charAt(0).toUpperCase()}${party.id.slice(1).replace(/_([a-z])/g, (_, c: string) => c.toUpperCase())}` as keyof LeaderboardEntry + return ( + e[fieldKey] as number | null} + referenceValue={REFERENCE_RESULT.percentages[party.id]} + nameFilter={nameFilter} + /> + ) + } + + return null + } + + return ( +
+

+ + {categoryTitleMap[activeTab]} +

+

+ {activeTab === 'overall' + ? t('leaderboard.individualSubtitle') + : t('leaderboard.categorySubtitle')} +

+ +
+
+ + handleSearchChange(e.target.value)} + placeholder={t('leaderboard.searchPlaceholder')} + className="w-full bg-transparent text-xs text-zinc-900 placeholder:text-zinc-400 outline-none" + /> + {searchQuery && ( + + )} + {searchLoading && } +
+ {currentToken && ( + + )} +
+ +
+
+ {tabOrder.map((id) => ( + + ))} +
+
+ + {renderContent()} +
+ ) +} diff --git a/app/src/pages/leaderboard/results-summary.tsx b/app/src/pages/leaderboard/results-summary.tsx index 829229a..99ef90a 100644 --- a/app/src/pages/leaderboard/results-summary.tsx +++ b/app/src/pages/leaderboard/results-summary.tsx @@ -1,5 +1,5 @@ import { useTranslation } from 'react-i18next' -import { BarChart3, CheckCircle2 } from 'lucide-react' +import { CheckCircle2 } from 'lucide-react' import { T } from '@/components/trans' import { PARTY_OPTIONS, PARTY_SHORT } from '@/data/election-options' import { REFERENCE_RESULT, RESULTS_AVAILABLE, VOTE_PROCESSING_PCT } from '@mandatoto/shared/types' @@ -10,14 +10,7 @@ export function ResultsSummary() { return (
-

- - {t(RESULTS_AVAILABLE ? 'leaderboard.resultsTitle' : 'leaderboard.resultsTitlePreResult')} -

-

- {t(RESULTS_AVAILABLE ? 'leaderboard.resultsSubtitle' : 'leaderboard.resultsSubtitlePreResult')} -

-
+

{t('leaderboard.resultsListWinner')} @@ -77,36 +70,76 @@ export function ResultsSummary() {

+
+

+ {t('leaderboard.resultsMandates')} +

+
+ {PARTY_OPTIONS.map((p) => { + const count = RESULTS_AVAILABLE ? REFERENCE_RESULT.mandates[p.id] : null + const isMajority = count != null && count >= 133 + return ( + + {p.shortName}{' '} + + {count != null ? count : '—'} + + + ) + })} + + {t('stats.nationalities')}{' '} + + {RESULTS_AVAILABLE ? REFERENCE_RESULT.nationalitiesMandate : '—'} + + +
+
-
-

- {t('leaderboard.resultsProcessing')} -

-
- {RESULTS_AVAILABLE ? ( - VOTE_PROCESSING_PCT >= 100 ? ( - <> - -

{t('leaderboard.processingComplete')}

- +
+
+

+ {t('leaderboard.resultsProcessing')} +

+
+ {RESULTS_AVAILABLE ? ( + VOTE_PROCESSING_PCT >= 100 ? ( + <> + +

100%

+
+
+
+ + ) : ( + <> +

+ {VOTE_PROCESSING_PCT}% +

+
+
+
+ + ) ) : ( <> -
-
-
-

{VOTE_PROCESSING_PCT}%

+

0%

+
+

{t('leaderboard.processingAwaiting')}

- ) - ) : ( - <> -
-

0%

-

{t('leaderboard.processingAwaiting')}

- - )} + )} +
+
+
+

+ {t('leaderboard.resultsParticipation')} +

+

+ {RESULTS_AVAILABLE ? `${REFERENCE_RESULT.participationRate}%` : '—'} +

diff --git a/shared/types.ts b/shared/types.ts index 98245ab..db0e370 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -2,7 +2,7 @@ export type PartyId = 'mkkp' | 'tisza' | 'mi_hazank' | 'dk' | 'fidesz_kdnp' export const PARTY_IDS: PartyId[] = ['mkkp', 'tisza', 'mi_hazank', 'dk', 'fidesz_kdnp'] -export const TOTAL_ELIGIBLE_VOTERS = 7_618_706 +export const TOTAL_ELIGIBLE_VOTERS = 8_114_688 export type PredictionVisibility = 'public' | 'private' export type PredictionStatus = 'draft' | 'finalized' @@ -188,7 +188,7 @@ export type BestGroupEntry = { } export const RESULTS_AVAILABLE = false -export const VOTE_PROCESSING_PCT = 80 +export const VOTE_PROCESSING_PCT = 100 export const CUTOFF_AT = '2026-04-12T06:00:00+02:00' export const PARTY_LIST_THRESHOLD = 5 @@ -198,18 +198,28 @@ export type ElectionResult = { percentages: Record pctNationalities: number participationRate: number + mandates: Record + nationalitiesMandate: number } export const REFERENCE_RESULT: ElectionResult = { listWinnerId: 'tisza' as PartyId, pmWinnerId: 'tisza' as PartyId, percentages: { - mkkp: 2.8, - tisza: 44.6, - mi_hazank: 6.9, - dk: 4.3, - fidesz_kdnp: 35.2, + mkkp: 0.61, + tisza: 56.94, + mi_hazank: 4.89, + dk: 0.75, + fidesz_kdnp: 36.20, }, - pctNationalities: 0.44, - participationRate: 68.0, + pctNationalities: 0.61, + participationRate: 74.23, + mandates: { + mkkp: 0, + tisza: 133, + mi_hazank: 0, + dk: 0, + fidesz_kdnp: 65, + }, + nationalitiesMandate: 1, } diff --git a/worker/package.json b/worker/package.json index 935c5e1..1860112 100644 --- a/worker/package.json +++ b/worker/package.json @@ -30,6 +30,9 @@ "db:score": "tsx scripts/recalculate-scores.ts local", "db:score:dev": "tsx scripts/recalculate-scores.ts dev", "db:score:prod": "tsx scripts/recalculate-scores.ts prod", + "db:score:null": "tsx scripts/recalculate-scores.ts local --null-only", + "db:score:null:dev": "tsx scripts/recalculate-scores.ts dev --null-only", + "db:score:null:prod": "tsx scripts/recalculate-scores.ts prod --null-only", "telex:screenshot": "tsx scripts/test-telex-screenshot.ts", "telex:screenshot:mobile": "tsx scripts/test-telex-screenshot.ts --mobile" }, diff --git a/worker/scripts/recalculate-scores.ts b/worker/scripts/recalculate-scores.ts index ab859f9..338fe65 100644 --- a/worker/scripts/recalculate-scores.ts +++ b/worker/scripts/recalculate-scores.ts @@ -9,6 +9,7 @@ * * Flags: * --dry-run Print counts and first rows; no SQL writes + * --null-only Only process rows where score IS NULL (skip already-scored rows) * --confirm-prod Required when target is prod */ @@ -37,15 +38,17 @@ type D1Row = Record function parseArgs(argv: string[]) { let target: Target = 'local' let dryRun = false + let nullOnly = false let confirmProd = false const unknown: string[] = [] for (const a of argv) { if (a === '--dry-run') dryRun = true + else if (a === '--null-only') nullOnly = true else if (a === '--confirm-prod') confirmProd = true else if (a === 'local' || a === 'dev' || a === 'prod') target = a else unknown.push(a) } - return { target, dryRun, confirmProd, unknown } + return { target, dryRun, nullOnly, confirmProd, unknown } } function d1ExecuteJson(dbName: string, extraArgs: string[], command: string): D1Row[] { @@ -106,10 +109,10 @@ function rowToInput(row: D1Row) { } } -const SELECT_SQL = `SELECT token, list_winner_id, pm_winner_id, pct_mkkp, pct_tisza, pct_mi_hazank, pct_dk, pct_fidesz_kdnp, pct_nationalities, participation_rate, score FROM predictions WHERE status = 'finalized'` +const SELECT_COLS = `token, list_winner_id, pm_winner_id, pct_mkkp, pct_tisza, pct_mi_hazank, pct_dk, pct_fidesz_kdnp, pct_nationalities, participation_rate, score` function main() { - const { target, dryRun, confirmProd, unknown } = parseArgs(process.argv.slice(2)) + const { target, dryRun, nullOnly, confirmProd, unknown } = parseArgs(process.argv.slice(2)) if (unknown.length > 0) { console.error('Unknown arguments:', unknown.join(' ')) process.exit(1) @@ -122,9 +125,12 @@ function main() { const dbName = TARGET_DB[target] const wranglerDbArgs = target === 'local' ? (['--local'] as const) : (['--remote'] as const) - console.error(`Target: ${target} (${dbName})${dryRun ? ' [dry-run]' : ''}`) + const selectSql = `SELECT ${SELECT_COLS} FROM predictions WHERE status = 'finalized'${nullOnly ? ' AND score IS NULL' : ''}` - const rows = d1ExecuteJson(dbName, [...wranglerDbArgs], SELECT_SQL) + const flags = [dryRun && '[dry-run]', nullOnly && '[null-only]'].filter(Boolean).join(' ') + console.error(`Target: ${target} (${dbName})${flags ? ' ' + flags : ''}`) + + const rows = d1ExecuteJson(dbName, [...wranglerDbArgs], selectSql) console.error(`Finalized predictions: ${rows.length}`) const updates: { token: string; score: number; prev: number | null }[] = []