diff --git a/front_end/messages/cs.json b/front_end/messages/cs.json
index 450b590084..288063a1a5 100644
--- a/front_end/messages/cs.json
+++ b/front_end/messages/cs.json
@@ -1785,5 +1785,37 @@
"impersonationBannerText": "Momentálně si prohlížíte Metaculus jako váš bot.",
"stopImpersonating": "Přepnout zpět na můj účet",
"editedOnDate": "Upraveno dne {date}",
+ "tournamentsHeroLiveTitle": "Předpovídejte klíčová témata,
šplhejte po žebříčku, vyhrajte ceny.",
+ "tournamentsHeroLiveShown": "{count, plural, one {# turnaj zobrazen} other {# turnajů zobrazeno}}",
+ "tournamentsHeroSeriesTitle": "Předpovídejte klíčová témata,
procvičujte a budujte si záznamy.",
+ "tournamentsHeroSeriesShown": "{count, plural, one {# série otázek zobrazena} other {# sérií otázek zobrazeno}}",
+ "tournamentsHeroIndexesTitle": "Objevte složitá témata,
sledujte jejich vývoj.",
+ "tournamentsHeroIndexesShown": "{count, plural, one {# index zobrazen} other {# indexů zobrazeno}}",
+ "tournamentsInfoAria": "Informace o turnaji",
+ "tournamentsInfoCta": "Přihlaste se k soutěži",
+ "tournamentPrizePool": "CENOVÝ FOND",
+ "tournamentNoPrizePool": "ŽÁDNÝ CENOVÝ FOND",
+ "tournamentTimelineOngoing": "Probíhá",
+ "tournamentTimelineJustStarted": "Právě začalo",
+ "tournamentTimelineStarts": "Začíná {when}",
+ "tournamentTimelineEnds": "Končí {when}",
+ "tournamentTimelineAllResolved": "Všechny otázky vyřešeny",
+ "tournamentRelativeSoon": "brzy",
+ "tournamentRelativeUnderMinute": "za méně než minutu",
+ "tournamentRelativeFarFuture": "v daleké budoucnosti",
+ "tournamentRelativeFromNow": "za {n} {unit}",
+ "tournamentUnit": "{unit, select, minute {minuta} hour {hodina} day {den} week {týden} month {měsíc} year {rok} other {den}}",
+ "tournamentUnitPlural": "{unit, select, minute {minut} hour {hodin} day {dní} week {týdnů} month {měsíců} year {let} other {dní}}",
+ "tournamentQuestionsCount": "{count, plural, one {# otázka} other {# otázek}}",
+ "tournamentQuestionsCountUpper": "{count} OTÁZKY",
+ "tournamentsEmptySearchTitle": "Nebyly nalezeny žádné výsledky",
+ "tournamentsEmptySearchBody": "Zkuste jiný vyhledávací výraz nebo vymažte vyhledávání.",
+ "tournamentsEmptyDefaultTitle": "Zobrazeno {count} turnajů",
+ "tournamentsEmptyDefaultBody": "Zkontrolujte později nebo vyzkoušejte jinou kartu.",
+ "tournamentsTabLive": "Živé turnaje",
+ "tournamentsTabSeries": "Série otázek",
+ "tournamentsTabIndexes": "Indexy",
+ "tournamentsTabArchived": "Archivováno",
+ "tournamentTimelineClosed": "Čekání na vyřešení",
"othersCount": "Ostatní ({count})"
}
diff --git a/front_end/messages/en.json b/front_end/messages/en.json
index cc8d4c170e..c36a7c5484 100644
--- a/front_end/messages/en.json
+++ b/front_end/messages/en.json
@@ -1779,5 +1779,40 @@
"switchToBotAccount": "Switch to Bot Account",
"impersonationBannerText": "You are currently viewing Metaculus as your bot.",
"stopImpersonating": "Switch back to my account",
+ "tournamentsHeroLiveTitle": "Forecast key topics,
climb the leaderboards, win prizes.",
+ "tournamentsHeroLiveShown": "{count, plural, one {# tournament shown} other {# tournaments shown}}",
+ "tournamentsHeroSeriesTitle": "Forecast key topics,
practice and build a track record.",
+ "tournamentsHeroSeriesShown": "{count, plural, one {# question series shown} other {# question series shown}}",
+ "tournamentsHeroIndexesTitle": "Discover complex topics,
monitor their progress.",
+ "tournamentsHeroIndexesShown": "{count, plural, one {# index shown} other {# indexes shown}}",
+ "tournamentsInfoAria": "Tournament info",
+ "tournamentsInfoTitle": "We are not a prediction market. You can participate for free and win cash prizes for being accurate.",
+ "tournamentsInfoScoringLink": "What are forecasting scores?",
+ "tournamentsInfoPrizesLink": "How are prizes distributed?",
+ "tournamentsInfoCta": "Sign up to compete",
+ "tournamentPrizePool": "PRIZE POOL",
+ "tournamentNoPrizePool": "NO PRIZE POOL",
+ "tournamentTimelineOngoing": "Ongoing",
+ "tournamentTimelineJustStarted": "Just started",
+ "tournamentTimelineStarts": "Starts {when}",
+ "tournamentTimelineEnds": "Ends {when}",
+ "tournamentTimelineClosed": "Waiting resolutions",
+ "tournamentTimelineAllResolved": "All questions resolved",
+ "tournamentRelativeSoon": "soon",
+ "tournamentRelativeUnderMinute": "in under a minute",
+ "tournamentRelativeFarFuture": "in the far future",
+ "tournamentRelativeFromNow": "{n} {unit} from now",
+ "tournamentUnit": "{unit, select, minute {minute} hour {hour} day {day} week {week} month {month} year {year} other {day}}",
+ "tournamentUnitPlural": "{unit, select, minute {minutes} hour {hours} day {days} week {weeks} month {months} year {years} other {days}}",
+ "tournamentQuestionsCount": "{count, plural, one {# question} other {# questions}}",
+ "tournamentQuestionsCountUpper": "{count} QUESTIONS",
+ "tournamentsEmptySearchTitle": "No results found",
+ "tournamentsEmptySearchBody": "Try a different search term, or clear the search.",
+ "tournamentsEmptyDefaultTitle": "{count} tournaments shown",
+ "tournamentsEmptyDefaultBody": "Check back later or try another tab.",
+ "tournamentsTabLive": "Live Tournaments",
+ "tournamentsTabSeries": "Question Series",
+ "tournamentsTabIndexes": "Indexes",
+ "tournamentsTabArchived": "Archived",
"none": "none"
}
diff --git a/front_end/messages/es.json b/front_end/messages/es.json
index d24ff1c26d..9cadd0b07a 100644
--- a/front_end/messages/es.json
+++ b/front_end/messages/es.json
@@ -1785,5 +1785,37 @@
"impersonationBannerText": "Actualmente estás viendo Metaculus como tu bot.",
"stopImpersonating": "Volver a mi cuenta",
"editedOnDate": "Editado el {date}",
+ "tournamentsHeroLiveTitle": "Pronostica temas clave,
súbete al marcador, gana premios.",
+ "tournamentsHeroLiveShown": "{count, plural, one {# torneo mostrado} other {# torneos mostrados}}",
+ "tournamentsHeroSeriesTitle": "Pronostica temas clave,
practica y construye un historial.",
+ "tournamentsHeroSeriesShown": "{count, plural, one {# serie de preguntas mostrada} other {# series de preguntas mostradas}}",
+ "tournamentsHeroIndexesTitle": "Descubre temas complejos,
monitorea su progreso.",
+ "tournamentsHeroIndexesShown": "{count, plural, one {# índice mostrado} other {# índices mostrados}}",
+ "tournamentsInfoAria": "Información del torneo",
+ "tournamentsInfoCta": "Regístrate para competir",
+ "tournamentPrizePool": "PREMIO TOTAL",
+ "tournamentNoPrizePool": "SIN PREMIO TOTAL",
+ "tournamentTimelineOngoing": "En curso",
+ "tournamentTimelineJustStarted": "Acaba de comenzar",
+ "tournamentTimelineStarts": "Comienza {when}",
+ "tournamentTimelineEnds": "Termina {when}",
+ "tournamentTimelineAllResolved": "Todas las preguntas resueltas",
+ "tournamentRelativeSoon": "pronto",
+ "tournamentRelativeUnderMinute": "en menos de un minuto",
+ "tournamentRelativeFarFuture": "en el futuro lejano",
+ "tournamentRelativeFromNow": "{n} {unit} a partir de ahora",
+ "tournamentUnit": "{unit, select, minute {minuto} hour {hora} day {día} week {semana} month {mes} year {año} other {día}}",
+ "tournamentUnitPlural": "{unit, select, minute {minutos} hour {horas} day {días} week {semanas} month {meses} year {años} other {días}}",
+ "tournamentQuestionsCount": "{count, plural, one {# pregunta} other {# preguntas}}",
+ "tournamentQuestionsCountUpper": "{count} PREGUNTAS",
+ "tournamentsEmptySearchTitle": "No se encontraron resultados",
+ "tournamentsEmptySearchBody": "Prueba un término de búsqueda diferente o borra la búsqueda.",
+ "tournamentsEmptyDefaultTitle": "{count} torneos mostrados",
+ "tournamentsEmptyDefaultBody": "Vuelve más tarde o prueba otra pestaña.",
+ "tournamentsTabLive": "Torneos en Vivo",
+ "tournamentsTabSeries": "Series de Preguntas",
+ "tournamentsTabIndexes": "Índices",
+ "tournamentsTabArchived": "Archivado",
+ "tournamentTimelineClosed": "Esperando resoluciones",
"othersCount": "Otros ({count})"
}
diff --git a/front_end/messages/pt.json b/front_end/messages/pt.json
index f642e603c9..33c799cfcb 100644
--- a/front_end/messages/pt.json
+++ b/front_end/messages/pt.json
@@ -1783,5 +1783,37 @@
"impersonationBannerText": "Você está visualizando o Metaculus atualmente como seu bot.",
"stopImpersonating": "Voltar para minha conta",
"editedOnDate": "Editado em {date}",
+ "tournamentsHeroLiveTitle": "Preveja tópicos chave,
suba nas classificações, ganhe prêmios.",
+ "tournamentsHeroLiveShown": "{count, plural, one {# torneio mostrado} other {# torneios mostrados}}",
+ "tournamentsHeroSeriesTitle": "Preveja tópicos chave,
pratique e construa um histórico.",
+ "tournamentsHeroSeriesShown": "{count, plural, one {# série de perguntas mostrada} other {# séries de perguntas mostradas}}",
+ "tournamentsHeroIndexesTitle": "Descubra tópicos complexos,
monitore o progresso deles.",
+ "tournamentsHeroIndexesShown": "{count, plural, one {# índice mostrado} other {# índices mostrados}}",
+ "tournamentsInfoAria": "Informações do Torneio",
+ "tournamentsInfoCta": "Inscreva-se para competir",
+ "tournamentPrizePool": "PRÊMIO",
+ "tournamentNoPrizePool": "SEM PRÊMIO",
+ "tournamentTimelineOngoing": "Em andamento",
+ "tournamentTimelineJustStarted": "Acabou de começar",
+ "tournamentTimelineStarts": "Começa {when}",
+ "tournamentTimelineEnds": "Termina {when}",
+ "tournamentTimelineAllResolved": "Todas as perguntas resolvidas",
+ "tournamentRelativeSoon": "em breve",
+ "tournamentRelativeUnderMinute": "em menos de um minuto",
+ "tournamentRelativeFarFuture": "no futuro distante",
+ "tournamentRelativeFromNow": "em {n} {unit}",
+ "tournamentUnit": "{unit, select, minute {minuto} hour {hora} day {dia} week {semana} month {mês} year {ano} other {dia}}",
+ "tournamentUnitPlural": "{unit, select, minute {minutos} hour {horas} day {dias} week {semanas} month {meses} year {anos} other {dias}}",
+ "tournamentQuestionsCount": "{count, plural, one {# pergunta} other {# perguntas}}",
+ "tournamentQuestionsCountUpper": "{count} PERGUNTAS",
+ "tournamentsEmptySearchTitle": "Nenhum resultado encontrado",
+ "tournamentsEmptySearchBody": "Tente um termo de pesquisa diferente ou limpe a pesquisa.",
+ "tournamentsEmptyDefaultTitle": "{count} torneios mostrados",
+ "tournamentsEmptyDefaultBody": "Volte mais tarde ou tente outra aba.",
+ "tournamentsTabLive": "Torneios ao Vivo",
+ "tournamentsTabSeries": "Série de Perguntas",
+ "tournamentsTabIndexes": "Índices",
+ "tournamentsTabArchived": "Arquivado",
+ "tournamentTimelineClosed": "Aguardando resoluções",
"othersCount": "Outros ({count})"
}
diff --git a/front_end/messages/zh-TW.json b/front_end/messages/zh-TW.json
index bfdca86cdd..b994fd4759 100644
--- a/front_end/messages/zh-TW.json
+++ b/front_end/messages/zh-TW.json
@@ -1782,5 +1782,37 @@
"impersonationBannerText": "您目前正在以您的機器人帳戶查看 Metaculus。",
"stopImpersonating": "切換回我的帳戶",
"editedOnDate": "編輯於 {date}",
+ "tournamentsHeroLiveTitle": "預測關鍵議題,
攀登排行榜,贏得獎品。",
+ "tournamentsHeroLiveShown": "{count, plural, one {顯示 # 個錦標賽} other {顯示 # 個錦標賽}}",
+ "tournamentsHeroSeriesTitle": "預測關鍵議題,
練習並建立成果紀錄。",
+ "tournamentsHeroSeriesShown": "{count, plural, one {顯示 # 個問題系列} other {顯示 # 個問題系列}}",
+ "tournamentsHeroIndexesTitle": "探索複雜議題,
監控其進展。",
+ "tournamentsHeroIndexesShown": "{count, plural, one {顯示 # 個指數} other {顯示 # 個指數}}",
+ "tournamentsInfoAria": "錦標賽資訊",
+ "tournamentsInfoCta": "註冊參賽",
+ "tournamentPrizePool": "獎金池",
+ "tournamentNoPrizePool": "無獎金池",
+ "tournamentTimelineOngoing": "進行中",
+ "tournamentTimelineJustStarted": "剛剛開始",
+ "tournamentTimelineStarts": "{when} 開始",
+ "tournamentTimelineEnds": "{when} 結束",
+ "tournamentTimelineAllResolved": "所有問題已解決",
+ "tournamentRelativeSoon": "即將",
+ "tournamentRelativeUnderMinute": "在不到一分鐘內",
+ "tournamentRelativeFarFuture": "在遙遠的未來",
+ "tournamentRelativeFromNow": "{n} {unit} 後",
+ "tournamentUnit": "{unit, select, minute {分鐘} hour {小時} day {天} week {週} month {月} year {年} other {天}}",
+ "tournamentUnitPlural": "{unit, select, minute {分鐘} hour {小時} day {天} week {週} month {月} year {年} other {天}}",
+ "tournamentQuestionsCount": "{count, plural, one {# 個問題} other {# 個問題}}",
+ "tournamentQuestionsCountUpper": "{count} 題問題",
+ "tournamentsEmptySearchTitle": "找不到結果",
+ "tournamentsEmptySearchBody": "嘗試使用不同的搜索詞,或清除搜索。",
+ "tournamentsEmptyDefaultTitle": "顯示 {count} 個比賽",
+ "tournamentsEmptyDefaultBody": "稍後再查看或嘗試其他標籤。",
+ "tournamentsTabLive": "現場錦標賽",
+ "tournamentsTabSeries": "問答系列",
+ "tournamentsTabIndexes": "指數",
+ "tournamentsTabArchived": "已存檔",
+ "tournamentTimelineClosed": "等待裁定",
"withdrawAfterPercentSetting2": "問題總生命周期後撤回"
}
diff --git a/front_end/messages/zh.json b/front_end/messages/zh.json
index 6b3fa1773a..6d67cd2b35 100644
--- a/front_end/messages/zh.json
+++ b/front_end/messages/zh.json
@@ -1787,5 +1787,37 @@
"impersonationBannerText": "您当前正在以机器人身份查看 Metaculus。",
"stopImpersonating": "切换回我的账户",
"editedOnDate": "编辑于 {date}",
+ "tournamentsHeroLiveTitle": "预测关键话题,
登上排行榜,赢得奖品。",
+ "tournamentsHeroLiveShown": "{count, plural, one {显示#场锦标赛} other {显示#场锦标赛}}",
+ "tournamentsHeroSeriesTitle": "预测关键话题,
实践并建立记录。",
+ "tournamentsHeroSeriesShown": "{count, plural, one {显示#个问题系列} other {显示#个问题系列}}",
+ "tournamentsHeroIndexesTitle": "发现复杂话题,
监控其进展。",
+ "tournamentsHeroIndexesShown": "{count, plural, one {显示#个指数} other {显示#个指数}}",
+ "tournamentsInfoAria": "比赛信息",
+ "tournamentsInfoCta": "注册参赛",
+ "tournamentPrizePool": "奖金池",
+ "tournamentNoPrizePool": "无奖金池",
+ "tournamentTimelineOngoing": "进行中",
+ "tournamentTimelineJustStarted": "刚刚开始",
+ "tournamentTimelineStarts": "开始于{when}",
+ "tournamentTimelineEnds": "结束于{when}",
+ "tournamentTimelineAllResolved": "所有问题已解决",
+ "tournamentRelativeSoon": "很快",
+ "tournamentRelativeUnderMinute": "不到一分钟",
+ "tournamentRelativeFarFuture": "在遥远的未来",
+ "tournamentRelativeFromNow": "{n}{unit}后",
+ "tournamentUnit": "{unit, select, minute {分钟} hour {小时} day {天} week {周} month {个月} year {年} other {天}}",
+ "tournamentUnitPlural": "{unit, select, minute {分钟} hour {小时} day {天} week {周} month {个月} year {年} other {天}}",
+ "tournamentQuestionsCount": "{count, plural, one {# 个问题} other {# 个问题}}",
+ "tournamentQuestionsCountUpper": "{count} 题目",
+ "tournamentsEmptySearchTitle": "未找到结果",
+ "tournamentsEmptySearchBody": "尝试不同的搜索词,或清除搜索。",
+ "tournamentsEmptyDefaultTitle": "显示了 {count} 场比赛",
+ "tournamentsEmptyDefaultBody": "稍后再查看或尝试其他选项卡。",
+ "tournamentsTabLive": "直播锦标赛",
+ "tournamentsTabSeries": "问题系列",
+ "tournamentsTabIndexes": "索引",
+ "tournamentsTabArchived": "已归档",
+ "tournamentTimelineClosed": "等待解决",
"othersCount": "其他({count})"
}
diff --git a/front_end/src/app/(main)/(tournaments)/tournament/components/active_tournament_timeline.tsx b/front_end/src/app/(main)/(tournaments)/tournament/components/active_tournament_timeline.tsx
index 54cbb435ca..35089dbf24 100644
--- a/front_end/src/app/(main)/(tournaments)/tournament/components/active_tournament_timeline.tsx
+++ b/front_end/src/app/(main)/(tournaments)/tournament/components/active_tournament_timeline.tsx
@@ -8,6 +8,8 @@ import { BotLeaderboardStatus, Tournament } from "@/types/projects";
import cn from "@/utils/core/cn";
import { formatDate } from "@/utils/formatters/date";
+import GradientProgressLine from "./gradient_progress_line";
+
type Props = {
tournament: Tournament;
latestScheduledCloseTimestamp: number;
@@ -66,15 +68,13 @@ const ActiveTournamentTimeline: FC = async ({
{t("closes")}
-
- {!isUpcoming && (
-
-
-
+
+ {!isUpcoming ? (
+
+ ) : (
+
)}
+
{lastParticipationDayTimestamp && lastParticipationPosition && (
= ({ progressPercentage }) => (
-
-);
-
function calculateLastParticipationPosition(
lastParticipationDayTimestamp: number | null,
startDate: string,
diff --git a/front_end/src/app/(main)/(tournaments)/tournament/components/gradient_progress_line.tsx b/front_end/src/app/(main)/(tournaments)/tournament/components/gradient_progress_line.tsx
new file mode 100644
index 0000000000..5e7f544525
--- /dev/null
+++ b/front_end/src/app/(main)/(tournaments)/tournament/components/gradient_progress_line.tsx
@@ -0,0 +1,58 @@
+"use client";
+
+import React from "react";
+
+import cn from "@/utils/core/cn";
+
+type Props = {
+ pct: number;
+ className?: string;
+ trackClassName?: string;
+ fillClassName?: string;
+ dotClassName?: string;
+ edgeInsetPx?: number;
+};
+
+const GradientProgressLine: React.FC = ({
+ pct,
+ className,
+ trackClassName,
+ fillClassName,
+ dotClassName,
+ edgeInsetPx = 5,
+}) => {
+ const clamped = Math.max(0, Math.min(100, pct));
+ const left = `${clamped}%`;
+ const thumbLeft = `clamp(${edgeInsetPx}px, ${left}, calc(100% - ${edgeInsetPx}px))`;
+
+ return (
+
+ );
+};
+
+export default GradientProgressLine;
diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/archived/page.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/archived/page.tsx
new file mode 100644
index 0000000000..009aeac714
--- /dev/null
+++ b/front_end/src/app/(main)/(tournaments)/tournaments/archived/page.tsx
@@ -0,0 +1,20 @@
+import ServerProjectsApi from "@/services/api/projects/projects.server";
+
+import ArchivedTournamentsGrid from "../components/tournaments_grid/archived_tournaments_grid";
+import TournamentsScreen from "../components/tournaments_screen";
+
+const ArchivedPage: React.FC = async () => {
+ const tournaments = await ServerProjectsApi.getTournaments();
+ const nowTs = Date.now();
+ return (
+
+
+
+ );
+};
+
+export default ArchivedPage;
diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournament_filters.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournament_filters.tsx
deleted file mode 100644
index eaceabce0b..0000000000
--- a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournament_filters.tsx
+++ /dev/null
@@ -1,75 +0,0 @@
-"use client";
-import { useTranslations } from "next-intl";
-import { ChangeEvent, FC } from "react";
-
-import SearchInput from "@/components/search_input";
-import Listbox, { SelectOption } from "@/components/ui/listbox";
-import useSearchInputState from "@/hooks/use_search_input_state";
-import useSearchParams from "@/hooks/use_search_params";
-import { TournamentsSortBy } from "@/types/projects";
-
-import {
- TOURNAMENTS_SEARCH,
- TOURNAMENTS_SORT,
-} from "../constants/query_params";
-
-const TournamentFilters: FC = () => {
- const t = useTranslations();
- const { params, setParam, shallowNavigateToSearchParams } = useSearchParams();
-
- const [searchQuery, setSearchQuery] = useSearchInputState(
- TOURNAMENTS_SEARCH,
- { mode: "client", debounceTime: 300, modifySearchParams: true }
- );
-
- const handleSearchChange = (event: ChangeEvent) => {
- setSearchQuery(event.target.value);
- };
- const handleSearchErase = () => {
- setSearchQuery("");
- };
-
- const sortBy =
- (params.get(TOURNAMENTS_SORT) as TournamentsSortBy) ??
- TournamentsSortBy.StartDateDesc;
- const sortOptions: SelectOption[] = [
- {
- label: t("highestPrizePool"),
- value: TournamentsSortBy.PrizePoolDesc,
- },
- {
- label: t("endingSoon"),
- value: TournamentsSortBy.CloseDateAsc,
- },
- {
- label: t("newest"),
- value: TournamentsSortBy.StartDateDesc,
- },
- ];
- const handleSortByChange = (value: TournamentsSortBy) => {
- setParam(TOURNAMENTS_SORT, value, false);
- shallowNavigateToSearchParams();
- };
-
- return (
-
- );
-};
-
-export default TournamentFilters;
diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournament_tabs.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournament_tabs.tsx
new file mode 100644
index 0000000000..2c604cf9a5
--- /dev/null
+++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournament_tabs.tsx
@@ -0,0 +1,48 @@
+"use client";
+
+import { useTranslations } from "next-intl";
+import React from "react";
+
+import TournamentsTabsShell from "./tournaments-tabs-shell";
+import { Section, TournamentsSection } from "../types";
+
+type Props = { current: TournamentsSection };
+
+const TAB_KEYS = {
+ live: "tournamentsTabLive",
+ series: "tournamentsTabSeries",
+ indexes: "tournamentsTabIndexes",
+ archived: "tournamentsTabArchived",
+} as const satisfies Record;
+
+const TournamentsTabs: React.FC = ({ current }) => {
+ const t = useTranslations();
+ type PlainKey = Parameters[0];
+
+ const sections: Section[] = [
+ {
+ value: "live",
+ href: "/tournaments",
+ label: t(TAB_KEYS.live as PlainKey),
+ },
+ {
+ value: "series",
+ href: "/tournaments/question-series",
+ label: t(TAB_KEYS.series as PlainKey),
+ },
+ {
+ value: "indexes",
+ href: "/tournaments/indexes",
+ label: t(TAB_KEYS.indexes as PlainKey),
+ },
+ {
+ value: "archived",
+ href: "/tournaments/archived",
+ label: t(TAB_KEYS.archived as PlainKey),
+ },
+ ];
+
+ return ;
+};
+
+export default TournamentsTabs;
diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments-tabs-shell.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments-tabs-shell.tsx
new file mode 100644
index 0000000000..218086b073
--- /dev/null
+++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments-tabs-shell.tsx
@@ -0,0 +1,41 @@
+"use client";
+
+import React from "react";
+
+import { Tabs, TabsList, TabsTab } from "@/components/ui/tabs";
+import cn from "@/utils/core/cn";
+
+import { Section, TournamentsSection } from "../types";
+
+type Props = {
+ current: TournamentsSection;
+ sections: Section[];
+};
+
+const TournamentsTabsShell: React.FC = ({ current, sections }) => {
+ return (
+
+
+ {sections.map((tab) => (
+
+ !isActive
+ ? `hover:bg-blue-400 dark:hover:bg-blue-400-dark text-blue-800 dark:text-blue-800-dark ${tab.value === "archived" && "bg-transparent text-blue-600 dark:text-blue-600-dark lg:text-blue-800 lg:dark:text-blue-800-dark"}`
+ : ""
+ }
+ key={tab.value}
+ value={tab.value}
+ href={tab.href}
+ >
+ {tab.label}
+
+ ))}
+
+
+ );
+};
+
+export default TournamentsTabsShell;
diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_container.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_container.tsx
new file mode 100644
index 0000000000..470dc4a6d4
--- /dev/null
+++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_container.tsx
@@ -0,0 +1,14 @@
+import React, { PropsWithChildren } from "react";
+
+const TournamentsContainer: React.FC = ({ children }) => {
+ return (
+
+ {children}
+
+ );
+};
+
+export default TournamentsContainer;
diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_filter.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_filter.tsx
new file mode 100644
index 0000000000..b2d207e3a2
--- /dev/null
+++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_filter.tsx
@@ -0,0 +1,61 @@
+"use client";
+import { useTranslations } from "next-intl";
+import { useCallback } from "react";
+
+import Listbox, { SelectOption } from "@/components/ui/listbox";
+import { useBreakpoint } from "@/hooks/tailwind";
+import useSearchParams from "@/hooks/use_search_params";
+import { TournamentsSortBy } from "@/types/projects";
+
+import { useTournamentsSection } from "./tournaments_provider";
+import { TOURNAMENTS_SORT } from "../constants/query_params";
+
+const TournamentsFilter: React.FC = () => {
+ const t = useTranslations();
+ const { closeInfo } = useTournamentsSection();
+ const { params, setParam, shallowNavigateToSearchParams } = useSearchParams();
+ const sortBy =
+ (params.get(TOURNAMENTS_SORT) as TournamentsSortBy) ??
+ TournamentsSortBy.StartDateDesc;
+
+ const sortOptions: SelectOption[] = [
+ {
+ label: t("highestPrizePool"),
+ value: TournamentsSortBy.PrizePoolDesc,
+ },
+ {
+ label: t("endingSoon"),
+ value: TournamentsSortBy.CloseDateAsc,
+ },
+ {
+ label: t("newest"),
+ value: TournamentsSortBy.StartDateDesc,
+ },
+ ];
+ const handleSortByChange = (value: TournamentsSortBy) => {
+ setParam(TOURNAMENTS_SORT, value, false);
+ shallowNavigateToSearchParams();
+ };
+
+ const isLg = useBreakpoint("lg");
+
+ const handleOpenChange = useCallback(
+ (open: boolean) => {
+ if (open) closeInfo();
+ },
+ [closeInfo]
+ );
+
+ return (
+
+ );
+};
+
+export default TournamentsFilter;
diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/archived_tournaments_grid.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/archived_tournaments_grid.tsx
new file mode 100644
index 0000000000..c8bcc389b1
--- /dev/null
+++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/archived_tournaments_grid.tsx
@@ -0,0 +1,36 @@
+"use client";
+
+import React from "react";
+
+import { TournamentType } from "@/types/projects";
+
+import { useTournamentsSection } from "../tournaments_provider";
+import LiveTournamentCard from "./live_tournament_card";
+import QuestionSeriesCard from "./question_series_card";
+import TournamentsGrid from "./tournaments_grid";
+
+const ArchivedTournamentsGrid: React.FC = () => {
+ const { items, nowTs } = useTournamentsSection();
+
+ return (
+ {
+ if (item.type === TournamentType.QuestionSeries) {
+ return ;
+ }
+
+ return (
+
+ );
+ }}
+ />
+ );
+};
+
+export default ArchivedTournamentsGrid;
diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/index_tournament_card.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/index_tournament_card.tsx
new file mode 100644
index 0000000000..b7a2cf8de0
--- /dev/null
+++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/index_tournament_card.tsx
@@ -0,0 +1,160 @@
+"use client";
+
+import { faList } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { useTranslations } from "next-intl";
+import React, { useMemo } from "react";
+
+import { TournamentPreview } from "@/types/projects";
+import cn from "@/utils/core/cn";
+
+import TournamentCardShell from "./tournament_card_shell";
+
+type Props = {
+ item: TournamentPreview;
+};
+
+const IndexTournamentCard: React.FC = ({ item }) => {
+ const t = useTranslations();
+
+ const description = useMemo(() => {
+ return htmlBoldToText(item.description_preview || "");
+ }, [item.description_preview]);
+
+ return (
+
+
+
+
+ {item.header_image ? (
+ // eslint-disable-next-line @next/next/no-img-element
+

+ ) : (
+
+
+
+ )}
+
+
+
+
+ {item.name}
+
+
+
+ {t.rich("tournamentQuestionsCountUpper", {
+ count: item.questions_count ?? 0,
+ n: (chunks) => <>{chunks}>,
+ })}
+
+
+
+
+
+
+
+ {item.header_image ? (
+ // eslint-disable-next-line @next/next/no-img-element
+

+ ) : (
+
+
+
+ )}
+
+
+
+
+ {t.rich("tournamentQuestionsCountUpper", {
+ count: item.questions_count ?? 0,
+ n: (chunks) => (
+
+ {chunks}
+
+ ),
+ })}
+
+
+
+ {item.name}
+
+
+ {description ? (
+
+ {description}
+
+ ) : null}
+
+
+
+ );
+};
+
+function htmlBoldToText(html: string): string {
+ const raw = (html ?? "").trim();
+ if (!raw) return "";
+
+ if (typeof window !== "undefined" && "DOMParser" in window) {
+ const doc = new DOMParser().parseFromString(raw, "text/html");
+ const boldNodes = doc.querySelectorAll("b, strong");
+ const text = Array.from(boldNodes)
+ .map((n) => (n.textContent || "").trim())
+ .filter(Boolean)
+ .join(" ")
+ .replace(/\s+/g, " ")
+ .trim();
+
+ return text;
+ }
+
+ const matches = raw.match(/<(b|strong)[^>]*>(.*?)<\/\1>/gis) ?? [];
+ return matches
+ .map((m) =>
+ m
+ .replace(/<[^>]*>/g, " ")
+ .replace(/\s+/g, " ")
+ .trim()
+ )
+ .filter(Boolean)
+ .join(" ")
+ .trim();
+}
+
+export default IndexTournamentCard;
diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/index_tournaments_grid.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/index_tournaments_grid.tsx
new file mode 100644
index 0000000000..8ca7d8a87a
--- /dev/null
+++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/index_tournaments_grid.tsx
@@ -0,0 +1,21 @@
+"use client";
+
+import React from "react";
+
+import TournamentsGrid from "./tournaments_grid";
+import { useTournamentsSection } from "../tournaments_provider";
+import IndexTournamentCard from "./index_tournament_card";
+
+const IndexTournamentsGrid: React.FC = () => {
+ const { items } = useTournamentsSection();
+
+ return (
+ }
+ className="grid-cols-1 md:grid-cols-3 xl:grid-cols-3"
+ />
+ );
+};
+
+export default IndexTournamentsGrid;
diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/live_tournament_card.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/live_tournament_card.tsx
new file mode 100644
index 0000000000..0caba43854
--- /dev/null
+++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/live_tournament_card.tsx
@@ -0,0 +1,324 @@
+"use client";
+
+import { faList, faUsers } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { useTranslations } from "next-intl";
+import React, { useMemo } from "react";
+
+import { TournamentPreview, TournamentTimeline } from "@/types/projects";
+import cn from "@/utils/core/cn";
+import { bucketRelativeMs } from "@/utils/formatters/date";
+
+import TournamentCardShell from "./tournament_card_shell";
+import GradientProgressLine from "../../../tournament/components/gradient_progress_line";
+
+type Props = {
+ item: TournamentPreview;
+ nowTs?: number;
+ hideTimeline?: boolean;
+};
+
+const LiveTournamentCard: React.FC = ({
+ item,
+ nowTs = 0,
+ hideTimeline = false,
+}) => {
+ const t = useTranslations();
+ const prize = useMemo(
+ () => formatMoneyUSD(item.prize_pool),
+ [item.prize_pool]
+ );
+
+ return (
+
+
+ {item.header_image ? (
+ // eslint-disable-next-line @next/next/no-img-element
+

+ ) : (
+
+
+
+ )}
+
+
+
+
+ {prize && (
+
+ {prize}
+
+ )}
+ {prize ? ` ${t("tournamentPrizePool")}` : t("tournamentNoPrizePool")}
+
+
+
+ {item.forecasters_count ?? 0}
+
+
+
+
+
+
+ {item.name}
+
+
+ {!hideTimeline && (
+
+ )}
+
+
+ );
+};
+
+function TournamentTimelineBar({
+ nowTs,
+ timeline,
+ startDate,
+ forecastingEndDate,
+ closeDate,
+ isOngoing,
+}: {
+ nowTs: number | null;
+ timeline: TournamentTimeline | null;
+ startDate?: string | null;
+ forecastingEndDate?: string | null;
+ closeDate?: string | null;
+ isOngoing: boolean;
+}) {
+ const startTs = safeTs(startDate);
+ const closedTs = safeTs(forecastingEndDate ?? closeDate ?? null);
+ if (!startTs || !closedTs) return null;
+
+ const isClosed =
+ timeline?.all_questions_closed != null
+ ? Boolean(timeline.all_questions_closed)
+ : !isOngoing;
+
+ const isResolved = Boolean(timeline?.all_questions_resolved);
+
+ if (!isClosed) {
+ return ;
+ }
+
+ return (
+
+ );
+}
+
+function ActiveMiniBar({
+ nowTs,
+ startTs,
+ endTs,
+}: {
+ nowTs: number | null;
+ startTs: number;
+ endTs: number;
+}) {
+ const t = useTranslations();
+ let label = t("tournamentTimelineOngoing");
+ let p = 0;
+
+ if (nowTs == null) {
+ label = t("tournamentTimelineOngoing");
+ } else if (nowTs < startTs) {
+ label = t("tournamentTimelineStarts", {
+ when: formatRelative(t, startTs - nowTs),
+ });
+ } else {
+ const sinceStart = nowTs - startTs;
+ label =
+ sinceStart < JUST_STARTED_MS
+ ? t("tournamentTimelineJustStarted")
+ : t("tournamentTimelineEnds", {
+ when: formatRelative(t, endTs - nowTs),
+ });
+
+ const total = Math.max(1, endTs - startTs);
+ p = clamp01((nowTs - startTs) / total);
+ }
+
+ const pct = (p * 100).toFixed(4);
+
+ return (
+
+ );
+}
+
+function ClosedMiniBar({
+ nowTs,
+ isResolved,
+ timeline,
+ closeDate,
+}: {
+ nowTs: number | null;
+ isResolved: boolean;
+ timeline: TournamentTimeline | null;
+ closeDate: string | null;
+}) {
+ const t = useTranslations();
+ const label = isResolved
+ ? t("tournamentTimelineAllResolved")
+ : t("tournamentTimelineClosed");
+ let progress = isResolved ? 50 : 0;
+
+ if (nowTs != null) {
+ const resolvedTs = pickResolveTs(nowTs, timeline);
+ const winnersTs = pickWinnersTs(resolvedTs, closeDate);
+
+ if (resolvedTs && nowTs >= resolvedTs) progress = 50;
+ if (winnersTs && nowTs >= winnersTs) progress = 100;
+ if (isResolved) progress = Math.max(progress, 50);
+ }
+
+ return (
+
+
+ {label}
+
+
+
+
+
+
+
= 50} />
+ = 100} />
+
+
+ );
+}
+
+function ClosedChip({
+ left,
+ active,
+}: {
+ left: "0%" | "50%" | "100%";
+ active: boolean;
+}) {
+ return (
+
+ );
+}
+
+function safeTs(iso?: string | null): number | null {
+ if (!iso) return null;
+ const t = new Date(iso).getTime();
+ return Number.isFinite(t) ? t : null;
+}
+
+function clamp01(x: number) {
+ return Math.max(0, Math.min(1, x));
+}
+
+function formatRelative(
+ t: ReturnType,
+ deltaMs: number
+) {
+ const r = bucketRelativeMs(deltaMs);
+ if (r.kind === "soon") return t("tournamentRelativeSoon");
+ if (r.kind === "farFuture") return t("tournamentRelativeFarFuture");
+ if (r.kind === "underMinute") return t("tournamentRelativeUnderMinute");
+ const { n, unit } = r.value;
+
+ const unitLabel =
+ n === 1
+ ? t("tournamentUnit", { unit })
+ : t("tournamentUnitPlural", { unit });
+
+ return t("tournamentRelativeFromNow", { n, unit: unitLabel });
+}
+
+function formatMoneyUSD(amount: string | null | undefined) {
+ if (!amount) return null;
+ const n = Number(amount);
+ if (!Number.isFinite(n)) return null;
+ return n.toLocaleString("en-US", {
+ style: "currency",
+ currency: "USD",
+ currencyDisplay: "narrowSymbol",
+ maximumFractionDigits: 0,
+ });
+}
+
+function pickResolveTs(nowTs: number, timeline: TournamentTimeline | null) {
+ const scheduled = safeTs(timeline?.latest_scheduled_resolve_time);
+ const actual = safeTs(timeline?.latest_actual_resolve_time);
+ const isAllResolved = Boolean(timeline?.all_questions_resolved);
+ let effectiveScheduled = scheduled;
+ if (effectiveScheduled && nowTs >= effectiveScheduled && !isAllResolved) {
+ effectiveScheduled = nowTs + ONE_DAY_MS;
+ }
+
+ return (isAllResolved ? actual : null) ?? effectiveScheduled ?? null;
+}
+
+function pickWinnersTs(resolvedTs: number | null, closeDate: string | null) {
+ const closeTs = safeTs(closeDate);
+ if (closeTs) return closeTs;
+ return resolvedTs ? resolvedTs + TWO_WEEKS_MS : null;
+}
+
+const ONE_DAY_MS = 24 * 60 * 60 * 1000;
+const TWO_WEEKS_MS = 14 * ONE_DAY_MS;
+const JUST_STARTED_MS = 36 * 60 * 60 * 1000;
+
+export default LiveTournamentCard;
diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/live_tournaments_grid.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/live_tournaments_grid.tsx
new file mode 100644
index 0000000000..82c7129614
--- /dev/null
+++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/live_tournaments_grid.tsx
@@ -0,0 +1,22 @@
+"use client";
+
+import React from "react";
+
+import TournamentsGrid from "./tournaments_grid";
+import { useTournamentsSection } from "../tournaments_provider";
+import LiveTournamentCard from "./live_tournament_card";
+
+const LiveTournamentsGrid: React.FC = () => {
+ const { items, nowTs } = useTournamentsSection();
+
+ return (
+ (
+
+ )}
+ />
+ );
+};
+
+export default LiveTournamentsGrid;
diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/question_series_card.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/question_series_card.tsx
new file mode 100644
index 0000000000..30b5765f2b
--- /dev/null
+++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/question_series_card.tsx
@@ -0,0 +1,60 @@
+"use client";
+
+import { faList } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { useTranslations } from "next-intl";
+import React from "react";
+
+import { TournamentPreview } from "@/types/projects";
+
+import TournamentCardShell from "./tournament_card_shell";
+
+type Props = { item: TournamentPreview };
+
+const QuestionSeriesCard: React.FC = ({ item }) => {
+ const t = useTranslations();
+ const questionsCount = item.questions_count ?? 0;
+
+ return (
+
+
+ {item.header_image ? (
+ // eslint-disable-next-line @next/next/no-img-element
+

+ ) : (
+
+
+
+ )}
+
+
+
+
+ {t.rich("tournamentQuestionsCount", {
+ count: questionsCount,
+ num: (chunks) => (
+
+ {chunks}
+
+ ),
+ })}
+
+
+
+ {item.name}
+
+
+
+ );
+};
+
+export default QuestionSeriesCard;
diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/series_tournaments_grid.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/series_tournaments_grid.tsx
new file mode 100644
index 0000000000..47c5daf826
--- /dev/null
+++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/series_tournaments_grid.tsx
@@ -0,0 +1,20 @@
+"use client";
+
+import React from "react";
+
+import TournamentsGrid from "./tournaments_grid";
+import { useTournamentsSection } from "../tournaments_provider";
+import QuestionSeriesCard from "./question_series_card";
+
+const SeriesTournamentsGrid: React.FC = () => {
+ const { items } = useTournamentsSection();
+
+ return (
+ }
+ />
+ );
+};
+
+export default SeriesTournamentsGrid;
diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/tournament_card_shell.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/tournament_card_shell.tsx
new file mode 100644
index 0000000000..554001c6f5
--- /dev/null
+++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/tournament_card_shell.tsx
@@ -0,0 +1,39 @@
+"use client";
+
+import Link from "next/link";
+import React, { PropsWithChildren, useMemo } from "react";
+
+import { TournamentPreview } from "@/types/projects";
+import cn from "@/utils/core/cn";
+import { getProjectLink } from "@/utils/navigation";
+
+type Props = PropsWithChildren<{
+ item: TournamentPreview;
+ className?: string;
+}>;
+
+const TournamentCardShell: React.FC = ({
+ item,
+ className,
+ children,
+}) => {
+ const href = useMemo(() => getProjectLink(item), [item]);
+
+ return (
+
+ {children}
+
+ );
+};
+
+export default TournamentCardShell;
diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/tournaments_grid.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/tournaments_grid.tsx
new file mode 100644
index 0000000000..29c5363d41
--- /dev/null
+++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_grid/tournaments_grid.tsx
@@ -0,0 +1,33 @@
+"use client";
+
+import React from "react";
+
+import { TournamentPreview } from "@/types/projects";
+import cn from "@/utils/core/cn";
+
+type Props = {
+ items: TournamentPreview[];
+ renderItem?: (item: TournamentPreview) => React.ReactNode;
+ className?: string;
+};
+
+const TournamentsGrid: React.FC = ({ items, renderItem, className }) => {
+ return (
+
+ {items.map((item) =>
+ renderItem ? (
+ renderItem(item)
+ ) : (
+
+ )
+ )}
+
+ );
+};
+
+export default TournamentsGrid;
diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_header.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_header.tsx
new file mode 100644
index 0000000000..fae35dbc13
--- /dev/null
+++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_header.tsx
@@ -0,0 +1,114 @@
+"use client";
+
+import React, { useEffect, useRef, useState } from "react";
+
+import { useBreakpoint } from "@/hooks/tailwind";
+import cn from "@/utils/core/cn";
+
+import TournamentsTabs from "./tournament_tabs";
+import TournamentsFilter from "./tournaments_filter";
+import TournamentsInfoPopover from "./tournaments_popover/tournaments_info_popover";
+import { useTournamentsSection } from "./tournaments_provider";
+import TournamentsSearch from "./tournaments_search";
+import { useTournamentsInfoDismissed } from "../hooks/use_tournaments_info_dismissed";
+
+const STICKY_TOP = 48;
+const POPOVER_GAP = 10;
+
+const TournamentsHeader: React.FC = () => {
+ const { current, infoOpen, toggleInfo, closeInfo } = useTournamentsSection();
+ const {
+ dismissed,
+ dismiss: infoDismiss,
+ ready,
+ } = useTournamentsInfoDismissed();
+
+ const didInitDismissCheck = useRef(false);
+ useEffect(() => {
+ if (!ready) return;
+ if (didInitDismissCheck.current) return;
+ didInitDismissCheck.current = true;
+
+ if (dismissed && infoOpen) closeInfo();
+ }, [ready, dismissed, infoOpen, closeInfo]);
+
+ const sentinelRef = useRef(null);
+ const isLg = useBreakpoint("lg");
+ const [stuck, setStuck] = useState(!isLg);
+
+ useEffect(() => {
+ const el = sentinelRef.current;
+ if (!el || !isLg) return;
+
+ const obs = new IntersectionObserver(
+ ([entry]) => setStuck(!entry?.isIntersecting),
+ {
+ root: null,
+ threshold: 0,
+ rootMargin: `-${STICKY_TOP}px 0px 0px 0px`,
+ }
+ );
+
+ obs.observe(el);
+ return () => obs.disconnect();
+ }, [isLg]);
+
+ const showInfo = true;
+
+ return (
+ <>
+
+
+
+ >
+ );
+};
+
+const popoverSafeGlassClasses = cn(
+ "bg-white/70 dark:bg-slate-950/45",
+ "backdrop-blur-md supports-[backdrop-filter]:backdrop-blur-md",
+ "border-b border-blue-400/50 dark:border-blue-400-dark/50"
+);
+
+export default TournamentsHeader;
diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_hero.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_hero.tsx
new file mode 100644
index 0000000000..eec180eee4
--- /dev/null
+++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_hero.tsx
@@ -0,0 +1,57 @@
+"use client";
+
+import { useTranslations } from "next-intl";
+import React from "react";
+
+import { useBreakpoint } from "@/hooks/tailwind";
+
+import { useTournamentsSection } from "./tournaments_provider";
+import { TournamentsSection } from "../types";
+
+const HERO_KEYS = {
+ live: {
+ titleKey: "tournamentsHeroLiveTitle",
+ shownKey: "tournamentsHeroLiveShown",
+ },
+ series: {
+ titleKey: "tournamentsHeroSeriesTitle",
+ shownKey: "tournamentsHeroSeriesShown",
+ },
+ indexes: {
+ titleKey: "tournamentsHeroIndexesTitle",
+ shownKey: "tournamentsHeroIndexesShown",
+ },
+ archived: null,
+} as const satisfies Record<
+ TournamentsSection,
+ { titleKey: string; shownKey: string } | null
+>;
+
+const TournamentsHero: React.FC = () => {
+ const t = useTranslations();
+ const isLg = useBreakpoint("lg");
+ const { current, count } = useTournamentsSection();
+
+ const keys = HERO_KEYS[current];
+ if (!keys) return null;
+ if (!isLg && current === "live") return null;
+
+ type RichKey = Parameters[0];
+ type PlainKey = Parameters[0];
+
+ return (
+
+
+ {t.rich(keys.titleKey as RichKey, {
+ br: () =>
,
+ })}
+
+
+
+ {t(keys.shownKey as PlainKey, { count })}
+
+
+ );
+};
+
+export default TournamentsHero;
diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_list.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_list.tsx
deleted file mode 100644
index 42e52aea94..0000000000
--- a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_list.tsx
+++ /dev/null
@@ -1,145 +0,0 @@
-"use client";
-import { differenceInMilliseconds } from "date-fns";
-import { useTranslations } from "next-intl";
-import { FC, useEffect, useMemo, useState } from "react";
-
-import TournamentCard from "@/components/tournament_card";
-import Button from "@/components/ui/button";
-import useSearchParams from "@/hooks/use_search_params";
-import {
- TournamentPreview,
- TournamentsSortBy,
- TournamentType,
-} from "@/types/projects";
-import { getProjectLink } from "@/utils/navigation";
-
-import {
- TOURNAMENTS_SEARCH,
- TOURNAMENTS_SORT,
-} from "../constants/query_params";
-
-type Props = {
- items: TournamentPreview[];
- title: string;
- cardsPerPage: number;
- initialCardsCount?: number;
- withEmptyState?: boolean;
- disableClientSort?: boolean;
-};
-
-const TournamentsList: FC = ({
- items,
- title,
- cardsPerPage,
- initialCardsCount,
- withEmptyState,
- disableClientSort = false,
-}) => {
- const t = useTranslations();
- const { params } = useSearchParams();
-
- const searchString = params.get(TOURNAMENTS_SEARCH) ?? "";
- const sortBy: TournamentsSortBy | null = disableClientSort
- ? null
- : (params.get(TOURNAMENTS_SORT) as TournamentsSortBy | null) ??
- TournamentsSortBy.StartDateDesc;
-
- const filteredItems = useMemo(
- () => filterItems(items, decodeURIComponent(searchString), sortBy),
- [items, searchString, sortBy]
- );
-
- const [displayItemsCount, setDisplayItemsCount] = useState(
- initialCardsCount ?? cardsPerPage
- );
- const hasMoreItems = displayItemsCount < filteredItems.length;
- // reset pagination when filter applied
- useEffect(() => {
- setDisplayItemsCount(initialCardsCount ?? cardsPerPage);
- }, [cardsPerPage, filteredItems.length, initialCardsCount]);
-
- if (!withEmptyState && filteredItems.length === 0) {
- return null;
- }
-
- return (
- <>
- {title}
- {filteredItems.length === 0 && withEmptyState && (
- {t("noResults")}
- )}
-
-
- {filteredItems.slice(0, displayItemsCount).map((item) => (
-
- ))}
-
- {hasMoreItems && (
-
-
-
- )}
-
- >
- );
-};
-
-function filterItems(
- items: TournamentPreview[],
- searchString: string,
- sortBy: TournamentsSortBy | null
-) {
- let filteredItems;
-
- if (searchString) {
- const sanitizedSearchString = searchString.trim().toLowerCase();
- const words = sanitizedSearchString.split(/\s+/);
-
- filteredItems = items.filter((item) =>
- words.every((word) => item.name.toLowerCase().includes(word))
- );
- } else {
- filteredItems = items;
- }
-
- if (!sortBy) {
- return filteredItems;
- }
-
- return [...filteredItems].sort((a, b) => {
- switch (sortBy) {
- case TournamentsSortBy.PrizePoolDesc:
- return Number(b.prize_pool) - Number(a.prize_pool);
- case TournamentsSortBy.CloseDateAsc:
- return differenceInMilliseconds(
- new Date(a.close_date ?? 0),
- new Date(b.close_date ?? 0)
- );
- case TournamentsSortBy.StartDateDesc:
- return differenceInMilliseconds(
- new Date(b.start_date),
- new Date(a.start_date)
- );
- default:
- return 0;
- }
- });
-}
-
-export default TournamentsList;
diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_mobile_ctrl.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_mobile_ctrl.tsx
new file mode 100644
index 0000000000..d6e100668c
--- /dev/null
+++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_mobile_ctrl.tsx
@@ -0,0 +1,55 @@
+"use client";
+
+import React, { useEffect, useState } from "react";
+
+import TournamentsFilter from "./tournaments_filter";
+import TournamentsInfo from "./tournaments_popover/tournaments_info";
+import TournamentsInfoButton from "./tournaments_popover/tournaments_info_button";
+import { useTournamentsSection } from "./tournaments_provider";
+import TournamentsSearch from "./tournaments_search";
+import { useTournamentsInfoDismissed } from "../hooks/use_tournaments_info_dismissed";
+
+const TournamentsMobileCtrl: React.FC = () => {
+ const { current } = useTournamentsSection();
+ const { dismissed, dismiss, ready } = useTournamentsInfoDismissed();
+ const [isInfoOpen, setIsInfoOpen] = useState(false);
+
+ const showInfo = current === "live";
+
+ useEffect(() => {
+ if (!ready) return;
+ setIsInfoOpen(showInfo && !dismissed);
+ }, [ready, dismissed, showInfo]);
+
+ return (
+
+ {showInfo && isInfoOpen && (
+
{
+ dismiss();
+ setIsInfoOpen(false);
+ }}
+ />
+ )}
+
+
+
+
+
+
+
+ {showInfo && (
+
{
+ dismiss();
+ setIsInfoOpen((p) => !p);
+ }}
+ />
+ )}
+
+
+ );
+};
+
+export default TournamentsMobileCtrl;
diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_popover/tournaments_info.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_popover/tournaments_info.tsx
new file mode 100644
index 0000000000..7df2f7b9fa
--- /dev/null
+++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_popover/tournaments_info.tsx
@@ -0,0 +1,80 @@
+"use client";
+
+import { faXmark } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import Link from "next/link";
+import { useTranslations } from "next-intl";
+import React from "react";
+
+import Button from "@/components/ui/button";
+import { useAuth } from "@/contexts/auth_context";
+import { useModal } from "@/contexts/modal_context";
+
+type Props = {
+ onClose: () => void;
+};
+
+const TournamentsInfo: React.FC = ({ onClose }) => {
+ const t = useTranslations();
+ const { user } = useAuth();
+ const isLoggedOut = !user;
+ const { setCurrentModal } = useModal();
+ const handleSignup = () => setCurrentModal({ type: "signup", data: {} });
+
+ const title = t.rich("tournamentsInfoTitle", {
+ predmarket: (chunks) => (
+
+ {chunks}
+
+ ),
+ });
+
+ return (
+
+
+ {title}
+
+
+
+
+ {t("tournamentsInfoScoringLink")}
+
+
+ {t("tournamentsInfoPrizesLink")}
+
+
+
+ {isLoggedOut && (
+
+ )}
+
+
+
+ );
+};
+
+export default TournamentsInfo;
diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_popover/tournaments_info_button.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_popover/tournaments_info_button.tsx
new file mode 100644
index 0000000000..36efbc5caa
--- /dev/null
+++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_popover/tournaments_info_button.tsx
@@ -0,0 +1,43 @@
+import { useFloating } from "@floating-ui/react";
+import { useTranslations } from "next-intl";
+
+import Button from "@/components/ui/button";
+
+type Props = {
+ isOpen: boolean;
+ onClick?: () => void;
+ refs?: ReturnType["refs"];
+ getReferenceProps?: (
+ userProps?: React.HTMLProps
+ ) => Record;
+ disabled?: boolean;
+};
+
+const TournamentsInfoButton: React.FC = ({
+ isOpen,
+ onClick,
+ refs,
+ disabled,
+ getReferenceProps,
+}) => {
+ const t = useTranslations();
+
+ return (
+
+ );
+};
+
+export default TournamentsInfoButton;
diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_popover/tournaments_info_popover.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_popover/tournaments_info_popover.tsx
new file mode 100644
index 0000000000..70476eaaa4
--- /dev/null
+++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_popover/tournaments_info_popover.tsx
@@ -0,0 +1,114 @@
+"use client";
+
+import {
+ autoUpdate,
+ flip,
+ FloatingPortal,
+ offset,
+ shift,
+ useClick,
+ useDismiss,
+ useFloating,
+ useInteractions,
+ useRole,
+} from "@floating-ui/react";
+import React from "react";
+
+import cn from "@/utils/core/cn";
+
+import { useTournamentsSection } from "../tournaments_provider";
+import TournamentsInfo from "./tournaments_info";
+import TournamentsInfoButton from "./tournaments_info_button";
+
+type Props = {
+ open: boolean;
+ onOpenChange: (next: boolean) => void;
+ disabled?: boolean;
+ offsetPx?: number;
+ stickyTopPx?: number;
+};
+
+const TournamentsInfoPopover: React.FC = ({
+ open,
+ onOpenChange,
+ disabled,
+ offsetPx = 12,
+ stickyTopPx = 0,
+}) => {
+ const { current } = useTournamentsSection();
+ const { refs, floatingStyles, context, isPositioned } = useFloating({
+ open,
+ onOpenChange,
+ placement: "bottom-end",
+ strategy: "fixed",
+ whileElementsMounted: autoUpdate,
+ middleware: [
+ offset(({ rects }) => {
+ const header = document.getElementById("tournamentsStickyHeader");
+ if (!header) return offsetPx;
+
+ const headerBottom = header.getBoundingClientRect().bottom;
+
+ const referenceBottom = rects.reference.y + rects.reference.height;
+ const needed = headerBottom + offsetPx - referenceBottom;
+ return Math.max(offsetPx, needed);
+ }),
+ flip({ padding: 12 }),
+ shift({
+ padding: {
+ top: stickyTopPx + 8,
+ left: 12,
+ right: 12,
+ bottom: 12,
+ },
+ }),
+ ],
+ });
+
+ const click = useClick(context, { enabled: !disabled });
+ const dismiss = useDismiss(context, { outsidePress: false });
+ const role = useRole(context, { role: "dialog" });
+
+ const { getReferenceProps, getFloatingProps } = useInteractions([
+ click,
+ dismiss,
+ role,
+ ]);
+
+ if (current !== "live") {
+ return null;
+ }
+
+ return (
+ <>
+
+
+ {open ? (
+
+
+ {
+ onOpenChange(false);
+ }}
+ />
+
+
+ ) : null}
+ >
+ );
+};
+
+export default TournamentsInfoPopover;
diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_provider.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_provider.tsx
new file mode 100644
index 0000000000..410592fd3d
--- /dev/null
+++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_provider.tsx
@@ -0,0 +1,69 @@
+"use client";
+
+import React, { createContext, useContext, useMemo, useState } from "react";
+
+import { TournamentPreview } from "@/types/projects";
+
+import { selectTournamentsForSection } from "../helpers";
+import { useTournamentFilters } from "../hooks/use_tournament_filters";
+import { TournamentsSection } from "../types";
+
+type TournamentsSectionCtxValue = {
+ current: TournamentsSection;
+ items: TournamentPreview[];
+ count: number;
+ nowTs?: number;
+ infoOpen: boolean;
+ toggleInfo: () => void;
+ closeInfo: () => void;
+};
+
+const TournamentsSectionCtx = createContext(
+ null
+);
+
+export function TournamentsSectionProvider(props: {
+ tournaments: TournamentPreview[];
+ current: TournamentsSection;
+ children: React.ReactNode;
+ nowTs?: number;
+}) {
+ const { tournaments, current, children, nowTs } = props;
+ const [infoOpen, setInfoOpen] = useState(true);
+
+ const sectionItems = useMemo(
+ () => selectTournamentsForSection(tournaments, current),
+ [tournaments, current]
+ );
+
+ const { filtered } = useTournamentFilters(sectionItems);
+
+ const value = useMemo(
+ () => ({
+ current,
+ items: filtered,
+ count: filtered.length,
+ infoOpen,
+ nowTs,
+ toggleInfo: () => setInfoOpen((v) => !v),
+ closeInfo: () => setInfoOpen(false),
+ }),
+ [current, filtered, infoOpen, nowTs]
+ );
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useTournamentsSection() {
+ const ctx = useContext(TournamentsSectionCtx);
+ if (!ctx) {
+ throw new Error(
+ "useTournamentsSection must be used within TournamentsSectionProvider"
+ );
+ }
+ return ctx;
+}
diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_results.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_results.tsx
new file mode 100644
index 0000000000..f43dde271d
--- /dev/null
+++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_results.tsx
@@ -0,0 +1,60 @@
+"use client";
+
+import { useSearchParams } from "next/navigation";
+import { useTranslations } from "next-intl";
+import React from "react";
+
+import cn from "@/utils/core/cn";
+
+import { useTournamentsSection } from "./tournaments_provider";
+import { TOURNAMENTS_SEARCH } from "../constants/query_params";
+
+type Props = {
+ children: React.ReactNode;
+ className?: string;
+};
+
+const TournamentsResults: React.FC = ({ children, className }) => {
+ const t = useTranslations();
+ const { count } = useTournamentsSection();
+ const params = useSearchParams();
+
+ const q = (params.get(TOURNAMENTS_SEARCH) ?? "").trim();
+ const isSearching = q.length > 0;
+
+ type PlainKey = Parameters[0];
+
+ if (count > 0) {
+ return {children}
;
+ }
+
+ const titleKey = (
+ isSearching ? "tournamentsEmptySearchTitle" : "tournamentsEmptyDefaultTitle"
+ ) as PlainKey;
+
+ const bodyKey = (
+ isSearching ? "tournamentsEmptySearchBody" : "tournamentsEmptyDefaultBody"
+ ) as PlainKey;
+
+ return (
+
+
+
+ {t(titleKey, { count })}
+
+
+
+ {t(bodyKey)}
+
+
+
+ );
+};
+
+export default TournamentsResults;
diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_screen.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_screen.tsx
new file mode 100644
index 0000000000..dea3848dbf
--- /dev/null
+++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_screen.tsx
@@ -0,0 +1,46 @@
+import React from "react";
+
+import { TournamentPreview } from "@/types/projects";
+
+import TournamentsContainer from "./tournaments_container";
+import TournamentsHeader from "./tournaments_header";
+import TournamentsHero from "./tournaments_hero";
+import TournamentsMobileCtrl from "./tournaments_mobile_ctrl";
+import { TournamentsSectionProvider } from "./tournaments_provider";
+import { TournamentsSection } from "../types";
+import TournamentsResults from "./tournaments_results";
+
+type Props = {
+ current: TournamentsSection;
+ tournaments: TournamentPreview[];
+ children: React.ReactNode;
+ nowTs?: number;
+};
+
+const TournamentsScreen: React.FC = ({
+ current,
+ tournaments,
+ children,
+ nowTs,
+}) => {
+ return (
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+ );
+};
+
+export default TournamentsScreen;
diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_search.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_search.tsx
new file mode 100644
index 0000000000..605278efd6
--- /dev/null
+++ b/front_end/src/app/(main)/(tournaments)/tournaments/components/tournaments_search.tsx
@@ -0,0 +1,44 @@
+"use client";
+
+import { useTranslations } from "next-intl";
+import { ChangeEvent } from "react";
+
+import ExpandableSearchInput from "@/components/expandable_search_input";
+import useSearchInputState from "@/hooks/use_search_input_state";
+
+import { TOURNAMENTS_SEARCH } from "../constants/query_params";
+
+const TournamentsSearch: React.FC = () => {
+ const t = useTranslations();
+ const [searchQuery, setSearchQuery] = useSearchInputState(
+ TOURNAMENTS_SEARCH,
+ {
+ mode: "client",
+ debounceTime: 300,
+ modifySearchParams: true,
+ }
+ );
+
+ const handleSearchChange = (event: ChangeEvent) => {
+ setSearchQuery(event.target.value);
+ };
+
+ const handleSearchErase = () => {
+ setSearchQuery("");
+ };
+
+ return (
+
+
+
+ );
+};
+
+export default TournamentsSearch;
diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/helpers/index.ts b/front_end/src/app/(main)/(tournaments)/tournaments/helpers/index.ts
new file mode 100644
index 0000000000..d668c6ab5b
--- /dev/null
+++ b/front_end/src/app/(main)/(tournaments)/tournaments/helpers/index.ts
@@ -0,0 +1,39 @@
+import { isValid } from "date-fns";
+import { toDate } from "date-fns-tz";
+
+import { TournamentPreview, TournamentType } from "@/types/projects";
+
+import { TournamentsSection } from "../types";
+
+const archiveEndTs = (t: TournamentPreview) =>
+ [t.forecasting_end_date, t.close_date, t.start_date]
+ .map((s) => (s ? toDate(s.trim(), { timeZone: "UTC" }) : null))
+ .find((d) => d && isValid(d))
+ ?.getTime() ?? 0;
+
+export function selectTournamentsForSection(
+ tournaments: TournamentPreview[],
+ section: TournamentsSection
+): TournamentPreview[] {
+ if (section === "archived") {
+ const archived = tournaments.filter((t) => !t.is_ongoing);
+ archived.sort((a, b) => archiveEndTs(b) - archiveEndTs(a));
+ return archived;
+ }
+
+ const ongoing = tournaments.filter((t) => t.is_ongoing);
+
+ if (section === "series") {
+ return ongoing.filter((t) => t.type === TournamentType.QuestionSeries);
+ }
+
+ if (section === "indexes") {
+ return ongoing.filter((t) => t.type === TournamentType.Index);
+ }
+
+ return ongoing.filter(
+ (t) =>
+ t.type !== TournamentType.QuestionSeries &&
+ t.type !== TournamentType.Index
+ );
+}
diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/helpers/tournament_filters.ts b/front_end/src/app/(main)/(tournaments)/tournaments/helpers/tournament_filters.ts
new file mode 100644
index 0000000000..67a29a63ea
--- /dev/null
+++ b/front_end/src/app/(main)/(tournaments)/tournaments/helpers/tournament_filters.ts
@@ -0,0 +1,72 @@
+import { differenceInMilliseconds } from "date-fns";
+
+import { TournamentPreview, TournamentsSortBy } from "@/types/projects";
+
+import {
+ TOURNAMENTS_SEARCH,
+ TOURNAMENTS_SORT,
+} from "../constants/query_params";
+
+type ParamsLike = Pick;
+
+type Options = {
+ disableClientSort?: boolean;
+ defaultSort?: TournamentsSortBy;
+};
+
+export function filterTournamentsFromParams(
+ items: TournamentPreview[],
+ params: ParamsLike,
+ opts: Options = {}
+) {
+ const searchString = params.get(TOURNAMENTS_SEARCH) ?? "";
+
+ const sortBy: TournamentsSortBy | null = opts.disableClientSort
+ ? null
+ : (params.get(TOURNAMENTS_SORT) as TournamentsSortBy | null) ??
+ opts.defaultSort ??
+ TournamentsSortBy.StartDateDesc;
+
+ return filterTournaments(items, decodeURIComponent(searchString), sortBy);
+}
+
+export function filterTournaments(
+ items: TournamentPreview[],
+ searchString: string,
+ sortBy: TournamentsSortBy | null
+) {
+ let filtered = items;
+
+ if (searchString) {
+ const sanitized = searchString.trim().toLowerCase();
+ const words = sanitized.split(/\s+/);
+
+ filtered = items.filter((item) =>
+ words.every((word) => item.name.toLowerCase().includes(word))
+ );
+ }
+
+ if (!sortBy) return filtered;
+
+ return [...filtered].sort((a, b) => {
+ switch (sortBy) {
+ case TournamentsSortBy.PrizePoolDesc:
+ return Number(b.prize_pool) - Number(a.prize_pool);
+
+ case TournamentsSortBy.CloseDateAsc:
+ return differenceInMilliseconds(
+ new Date(a.close_date ?? 0),
+ new Date(b.close_date ?? 0)
+ );
+
+ case TournamentsSortBy.StartDateDesc:
+ return differenceInMilliseconds(
+ new Date(b.start_date),
+ new Date(a.start_date)
+ );
+
+ default:
+ return 0;
+ }
+ });
+}
diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/hooks/use_tournament_filters.ts b/front_end/src/app/(main)/(tournaments)/tournaments/hooks/use_tournament_filters.ts
new file mode 100644
index 0000000000..70d72b70a8
--- /dev/null
+++ b/front_end/src/app/(main)/(tournaments)/tournaments/hooks/use_tournament_filters.ts
@@ -0,0 +1,26 @@
+"use client";
+
+import { useMemo } from "react";
+
+import useSearchParams from "@/hooks/use_search_params";
+import { TournamentPreview } from "@/types/projects";
+
+import { filterTournamentsFromParams } from "../helpers/tournament_filters";
+
+type Options = {
+ disableClientSort?: boolean;
+};
+
+export function useTournamentFilters(
+ items: TournamentPreview[],
+ opts: Options = {}
+) {
+ const { params } = useSearchParams();
+
+ const filtered = useMemo(
+ () => filterTournamentsFromParams(items, params, opts),
+ [items, params, opts]
+ );
+
+ return { filtered };
+}
diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/hooks/use_tournaments_info_dismissed.ts b/front_end/src/app/(main)/(tournaments)/tournaments/hooks/use_tournaments_info_dismissed.ts
new file mode 100644
index 0000000000..fa014e592d
--- /dev/null
+++ b/front_end/src/app/(main)/(tournaments)/tournaments/hooks/use_tournaments_info_dismissed.ts
@@ -0,0 +1,36 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useState } from "react";
+
+import { useAuth } from "@/contexts/auth_context";
+
+const STORAGE_PREFIX = "tournamentsInfoDismissed:v1";
+
+export function useTournamentsInfoDismissed() {
+ const { user } = useAuth();
+
+ const key = useMemo(() => {
+ return `${STORAGE_PREFIX}:${user?.id ?? "anon"}`;
+ }, [user?.id]);
+
+ const [dismissed, setDismissed] = useState(false);
+ const [ready, setReady] = useState(false);
+
+ useEffect(() => {
+ try {
+ setDismissed(localStorage.getItem(key) === "1");
+ } catch {
+ } finally {
+ setReady(true);
+ }
+ }, [key]);
+
+ const dismiss = useCallback(() => {
+ setDismissed(true);
+ try {
+ localStorage.setItem(key, "1");
+ } catch {}
+ }, [key]);
+
+ return { dismissed, dismiss, ready };
+}
diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/indexes/page.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/indexes/page.tsx
new file mode 100644
index 0000000000..fd9b837adb
--- /dev/null
+++ b/front_end/src/app/(main)/(tournaments)/tournaments/indexes/page.tsx
@@ -0,0 +1,16 @@
+import ServerProjectsApi from "@/services/api/projects/projects.server";
+
+import IndexTournamentsGrid from "../components/tournaments_grid/index_tournaments_grid";
+import TournamentsScreen from "../components/tournaments_screen";
+
+const IndexesPage: React.FC = async () => {
+ const tournaments = await ServerProjectsApi.getTournaments();
+
+ return (
+
+
+
+ );
+};
+
+export default IndexesPage;
diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/page.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/page.tsx
index cd1ada1d90..889b2dbdb5 100644
--- a/front_end/src/app/(main)/(tournaments)/tournaments/page.tsx
+++ b/front_end/src/app/(main)/(tournaments)/tournaments/page.tsx
@@ -1,14 +1,7 @@
-import { isValid } from "date-fns";
-import { toDate } from "date-fns-tz";
-import Link from "next/link";
-import { getTranslations } from "next-intl/server";
-
import ServerProjectsApi from "@/services/api/projects/projects.server";
-import { TournamentPreview, TournamentType } from "@/types/projects";
-import { getPublicSettings } from "@/utils/public_settings.server";
-import TournamentFilters from "./components/tournament_filters";
-import TournamentsList from "./components/tournaments_list";
+import LiveTournamentsGrid from "./components/tournaments_grid/live_tournaments_grid";
+import TournamentsScreen from "./components/tournaments_screen";
export const metadata = {
title: "Tournaments | Metaculus",
@@ -16,106 +9,15 @@ export const metadata = {
"Help the global community tackle complex challenges in Metaculus Tournaments. Prove your forecasting abilities, support impactful policy decisions, and compete for cash prizes.",
};
-export default async function Tournaments() {
- const t = await getTranslations();
-
+const LiveTournamentsPage: React.FC = async () => {
const tournaments = await ServerProjectsApi.getTournaments();
- const { activeTournaments, archivedTournaments, questionSeries, indexes } =
- extractTournamentLists(tournaments);
-
- const { PUBLIC_MINIMAL_UI } = getPublicSettings();
+ const nowTs = Date.now();
return (
-
- {!PUBLIC_MINIMAL_UI && (
-
-
- {t("tournaments")}
-
-
{t("tournamentsHero1")}
-
{t("tournamentsHero2")}
-
- {t.rich("tournamentsHero3", {
- scores: (chunks) => (
- {chunks}
- ),
- })}
-
-
- {t.rich("tournamentsHero4", {
- email: (chunks) => (
- {chunks}
- ),
- })}
-
-
- )}
-
-
-
-
-
-
-
-
-
- {indexes.length > 0 && (
-
-
-
- )}
-
-
-
+
+
+
);
-}
-
-const archiveEndTs = (t: TournamentPreview) =>
- [t.forecasting_end_date, t.close_date, t.start_date]
- .map((s) => (s ? toDate(s.trim(), { timeZone: "UTC" }) : null))
- .find((d) => d && isValid(d))
- ?.getTime() ?? 0;
-
-function extractTournamentLists(tournaments: TournamentPreview[]) {
- const activeTournaments: TournamentPreview[] = [];
- const archivedTournaments: TournamentPreview[] = [];
- const questionSeries: TournamentPreview[] = [];
- const indexes: TournamentPreview[] = [];
-
- for (const t of tournaments) {
- if (t.is_ongoing) {
- if (t.type === TournamentType.QuestionSeries) {
- questionSeries.push(t);
- } else if (t.type === TournamentType.Index) {
- indexes.push(t);
- } else {
- activeTournaments.push(t);
- }
- } else {
- archivedTournaments.push(t);
- }
- }
+};
- archivedTournaments.sort((a, b) => archiveEndTs(b) - archiveEndTs(a));
- return { activeTournaments, archivedTournaments, questionSeries, indexes };
-}
+export default LiveTournamentsPage;
diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/question-series/page.tsx b/front_end/src/app/(main)/(tournaments)/tournaments/question-series/page.tsx
new file mode 100644
index 0000000000..c7a00455c0
--- /dev/null
+++ b/front_end/src/app/(main)/(tournaments)/tournaments/question-series/page.tsx
@@ -0,0 +1,16 @@
+import ServerProjectsApi from "@/services/api/projects/projects.server";
+
+import SeriesTournamentsGrid from "../components/tournaments_grid/series_tournaments_grid";
+import TournamentsScreen from "../components/tournaments_screen";
+
+const QuestionSeriesPage: React.FC = async () => {
+ const tournaments = await ServerProjectsApi.getTournaments();
+
+ return (
+
+
+
+ );
+};
+
+export default QuestionSeriesPage;
diff --git a/front_end/src/app/(main)/(tournaments)/tournaments/types/index.ts b/front_end/src/app/(main)/(tournaments)/tournaments/types/index.ts
new file mode 100644
index 0000000000..44bb9ee2ec
--- /dev/null
+++ b/front_end/src/app/(main)/(tournaments)/tournaments/types/index.ts
@@ -0,0 +1,6 @@
+export type TournamentsSection = "live" | "series" | "indexes" | "archived";
+export type Section = {
+ value: TournamentsSection;
+ href: string;
+ label: string;
+};
diff --git a/front_end/src/components/expandable_search_input.tsx b/front_end/src/components/expandable_search_input.tsx
new file mode 100644
index 0000000000..f09370f8cc
--- /dev/null
+++ b/front_end/src/components/expandable_search_input.tsx
@@ -0,0 +1,132 @@
+"use client";
+
+import { faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import React, {
+ ChangeEventHandler,
+ FC,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+
+import SearchInput from "@/components/search_input";
+import Button from "@/components/ui/button";
+import cn from "@/utils/core/cn";
+
+type Props = {
+ value: string;
+ onChange: ChangeEventHandler;
+ onErase: () => void;
+ placeholder?: string;
+ collapsedWidthClassName?: string;
+ expandedWidthClassName?: string;
+ keepOpenWhenHasValue?: boolean;
+ collapseOnBlur?: boolean;
+ collapseOnErase?: boolean;
+ className?: string;
+ buttonClassName?: string;
+ inputClassName?: string;
+};
+
+const ExpandableSearchInput: FC = ({
+ value,
+ onChange,
+ onErase,
+ placeholder = "search...",
+ collapsedWidthClassName = "w-9",
+ expandedWidthClassName = "w-[220px]",
+ keepOpenWhenHasValue = true,
+ collapseOnBlur = true,
+ collapseOnErase = true,
+ className,
+ buttonClassName,
+ inputClassName,
+}) => {
+ const [open, setOpen] = useState(false);
+ const rootRef = useRef(null);
+ const inputRef = useRef(null);
+ const isExpanded = useMemo(() => {
+ if (open) return true;
+ if (keepOpenWhenHasValue && value) return true;
+ return false;
+ }, [open, keepOpenWhenHasValue, value]);
+
+ useEffect(() => {
+ if (isExpanded) inputRef.current?.focus();
+ }, [isExpanded]);
+
+ const collapseIfAllowed = () => {
+ if (!collapseOnBlur) return;
+ if (keepOpenWhenHasValue && value) return;
+ setOpen(false);
+ };
+
+ const handleErase = () => {
+ onErase();
+ if (collapseOnErase) setOpen(false);
+ inputRef.current?.blur();
+ };
+
+ return (
+ {
+ const next = e.relatedTarget as Node | null;
+ if (next && rootRef.current?.contains(next)) return;
+ collapseIfAllowed();
+ }}
+ onKeyDownCapture={(e) => {
+ if (e.key === "Escape") {
+ if (!(keepOpenWhenHasValue && value)) setOpen(false);
+ (e.target as HTMLElement)?.blur?.();
+ }
+ }}
+ >
+ {!isExpanded ? (
+
+ ) : (
+
+ )}
+
+ );
+};
+
+export default ExpandableSearchInput;
diff --git a/front_end/src/components/search_input.tsx b/front_end/src/components/search_input.tsx
index 0957a6aa70..b1129689a0 100644
--- a/front_end/src/components/search_input.tsx
+++ b/front_end/src/components/search_input.tsx
@@ -1,12 +1,13 @@
import { faMagnifyingGlass, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Field, Input } from "@headlessui/react";
-import { ChangeEventHandler, FC, FormEvent } from "react";
+import React, { ChangeEventHandler, FC, FormEvent } from "react";
import Button from "@/components/ui/button";
import cn from "@/utils/core/cn";
type Size = "base" | "lg";
+type IconPosition = "right" | "left";
type Props = {
value: string;
@@ -20,6 +21,10 @@ type Props = {
eraseButtonClassName?: string;
submitButtonClassName?: string;
submitIconClassName?: string;
+ iconPosition?: IconPosition;
+ rightControlsClassName?: string;
+ rightButtonClassName?: string;
+ inputRef?: React.Ref;
};
const SearchInput: FC = ({
@@ -34,10 +39,17 @@ const SearchInput: FC = ({
eraseButtonClassName,
submitButtonClassName,
submitIconClassName,
+ iconPosition = "right",
+ rightControlsClassName,
+ rightButtonClassName,
+ inputRef,
}) => {
+ const isForm = !!onSubmit;
+ const isLeft = iconPosition === "left";
+
return (
= ({
onSubmit?.(value);
}}
>
+ {isLeft && (
+
+
+
+ )}
+
-
+
+
{!!value && (
)}
-
+
+ {!isLeft && (
+
+ )}
);
diff --git a/front_end/src/components/ui/listbox.tsx b/front_end/src/components/ui/listbox.tsx
index cf97026ad6..67e4df2abc 100644
--- a/front_end/src/components/ui/listbox.tsx
+++ b/front_end/src/components/ui/listbox.tsx
@@ -49,6 +49,7 @@ type Props = {
renderInPortal?: boolean;
preventParentScroll?: boolean;
menuMinWidthMatchesButton?: boolean;
+ onOpenChange?: (open: boolean) => void;
} & (SingleSelectProps | MultiSelectProps);
const Listbox = (props: Props) => {
@@ -95,6 +96,7 @@ const Listbox = (props: Props) => {
>
{({ open }) => (
<>
+
({
return {menu};
}
+function OpenStateReporter({
+ open,
+ onOpenChange,
+}: {
+ open: boolean;
+ onOpenChange?: (open: boolean) => void;
+}) {
+ useEffect(() => {
+ onOpenChange?.(open);
+ }, [open, onOpenChange]);
+
+ return null;
+}
+
export default Listbox;
diff --git a/front_end/src/hooks/use_search_params.ts b/front_end/src/hooks/use_search_params.ts
index c0a08784f8..f43a1382a7 100644
--- a/front_end/src/hooks/use_search_params.ts
+++ b/front_end/src/hooks/use_search_params.ts
@@ -21,8 +21,8 @@ const useSearchParams = () => {
// allows pushing search params to the url without page reload
const shallowNavigateToSearchParams = useCallback(() => {
- window.history.pushState(null, "", `?${params.toString()}`);
- }, [params]);
+ router.replace(pathname + "?" + params.toString(), { scroll: false });
+ }, [params, pathname, router]);
const setParam = useCallback(
(name: string, val: string | string[], withNavigation = true) => {
diff --git a/front_end/src/types/projects.ts b/front_end/src/types/projects.ts
index e97689858d..5291f4fa23 100644
--- a/front_end/src/types/projects.ts
+++ b/front_end/src/types/projects.ts
@@ -69,6 +69,8 @@ export type TournamentPreview = Project & {
default_permission: ProjectPermissions | null;
score_type: string;
followers_count?: number;
+ timeline: TournamentTimeline;
+ description_preview?: string;
};
export type TournamentTimeline = {
diff --git a/front_end/src/utils/formatters/date.ts b/front_end/src/utils/formatters/date.ts
index 2dc20cb5f9..f41dc76469 100644
--- a/front_end/src/utils/formatters/date.ts
+++ b/front_end/src/utils/formatters/date.ts
@@ -6,6 +6,82 @@ import {
} from "date-fns";
import { es, cs, pt, zhTW, zhCN, enUS } from "date-fns/locale";
+export const DURATION_KEYS = [
+ "years",
+ "months",
+ "weeks",
+ "days",
+ "hours",
+ "minutes",
+ "seconds",
+] as const;
+
+export type DurationKey = (typeof DURATION_KEYS)[number];
+
+const UNIT_MS: Record = {
+ seconds: 1_000,
+ minutes: 60_000,
+ hours: 3_600_000,
+ days: 86_400_000,
+ weeks: 604_800_000,
+ months: 2_592_000_000,
+ years: 31_536_000_000,
+} as const;
+
+type RelativeBucket = {
+ key: Exclude;
+ n: number;
+ unit: "minute" | "hour" | "day" | "week" | "month" | "year";
+};
+
+export function bucketRelativeMs(
+ deltaMs: number
+):
+ | { kind: "soon" }
+ | { kind: "underMinute" }
+ | { kind: "farFuture" }
+ | { kind: "bucket"; value: RelativeBucket } {
+ if (!Number.isFinite(deltaMs) || deltaMs <= 0) return { kind: "soon" };
+ if (deltaMs > 20 * UNIT_MS.years) return { kind: "farFuture" };
+ if (deltaMs < UNIT_MS.minutes) return { kind: "underMinute" };
+
+ const keys: RelativeBucket["key"][] = [
+ "minutes",
+ "hours",
+ "days",
+ "weeks",
+ "months",
+ "years",
+ ];
+
+ for (const key of keys) {
+ const unitMs = UNIT_MS[key];
+ const nextKey = keys[keys.indexOf(key) + 1];
+ const upper = nextKey ? UNIT_MS[nextKey] : Infinity;
+
+ if (deltaMs < upper) {
+ const n = Math.round(deltaMs / unitMs);
+ return {
+ kind: "bucket",
+ value: {
+ key,
+ n,
+ unit: key.replace(/s$/, "") as RelativeBucket["unit"],
+ },
+ };
+ }
+ }
+
+ return {
+ kind: "bucket",
+ value: {
+ key: "years",
+ n: Math.round(deltaMs / UNIT_MS.years),
+ unit: "year",
+ },
+ };
+}
+
export function formatDate(locale: string, date: Date) {
return intlFormat(
new Date(date),
@@ -65,29 +141,19 @@ export function formatRelativeDate(
export const truncateDuration = (
duration: Duration,
- truncateNumUnits: number = 1
+ truncateNumUnits = 1
): Duration => {
- const truncatedDuration: Duration = {};
+ const truncated: Duration = {};
let numUnits = 0;
- for (const key of [
- "years",
- "months",
- "weeks",
- "days",
- "hours",
- "minutes",
- "seconds",
- ]) {
- if (duration[key as keyof Duration] && numUnits < truncateNumUnits) {
- numUnits++;
- truncatedDuration[key as keyof Duration] =
- duration[key as keyof Duration];
- }
- if (numUnits >= truncateNumUnits) {
- return truncatedDuration;
+ for (const key of DURATION_KEYS) {
+ if (duration[key] && numUnits < truncateNumUnits) {
+ numUnits++;
+ truncated[key] = duration[key];
}
+ if (numUnits >= truncateNumUnits) return truncated;
}
+
return duration;
};
diff --git a/projects/serializers/common.py b/projects/serializers/common.py
index 428f3af1b6..f74dbbde06 100644
--- a/projects/serializers/common.py
+++ b/projects/serializers/common.py
@@ -1,16 +1,20 @@
+import logging
from collections import defaultdict
-from typing import Any, Callable
+from typing import Any, Callable, Iterable
-from django.db.models import Q, QuerySet
+from django.db.models import Q
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from projects.models import Project, ProjectUserPermission, ProjectIndex
from projects.serializers.communities import CommunitySerializer
from projects.services.cache import get_projects_questions_count_cached
+from projects.services.common import get_timeline_data_for_projects
from projects.services.indexes import get_multi_year_index_data, get_default_index_data
from users.serializers import UserPublicSerializer
+logger = logging.getLogger(__name__)
+
class ProjectSerializer(serializers.ModelSerializer):
class Meta:
@@ -74,6 +78,7 @@ class Meta:
class TournamentShortSerializer(serializers.ModelSerializer):
score_type = serializers.SerializerMethodField(read_only=True)
is_current_content_translated = serializers.SerializerMethodField(read_only=True)
+ description_preview = serializers.SerializerMethodField(read_only=True)
class Meta:
model = Project
@@ -97,8 +102,15 @@ class Meta:
"visibility",
"is_current_content_translated",
"bot_leaderboard_status",
+ "description_preview",
)
+ def get_description_preview(self, project: Project) -> str:
+ raw = (project.description or "").strip()
+ if not raw:
+ return ""
+ return raw[:140].rstrip()
+
def get_score_type(self, project: Project) -> str | None:
if not project.primary_leaderboard_id:
return None
@@ -253,18 +265,39 @@ def serialize_index_data(index: ProjectIndex):
def serialize_tournaments_with_counts(
- qs: QuerySet[Project], sort_key: Callable[[Project], Any]
+ projects: Iterable[Project],
+ sort_key: Callable[[dict], Any] = None,
+ with_timeline: bool = False,
) -> list[dict]:
- projects: list[Project] = list(qs.all())
+ projects = list(projects)
questions_count_map = get_projects_questions_count_cached([p.id for p in projects])
- data = []
+ projects_timeline_map = {}
+
+ if with_timeline:
+ try:
+ projects_timeline_map = get_timeline_data_for_projects(
+ [x.id for x in projects]
+ )
+ except Exception:
+ logger.exception("Failed to get projects timeline data")
+
+ data: list[dict] = []
for obj in projects:
serialized_tournament = TournamentShortSerializer(obj).data
- serialized_tournament["questions_count"] = questions_count_map.get(obj.id) or 0
- serialized_tournament["forecasts_count"] = obj.forecasts_count
- serialized_tournament["forecasters_count"] = obj.forecasters_count
+
+ serialized_tournament.update(
+ {
+ "questions_count": questions_count_map.get(obj.id) or 0,
+ "forecasts_count": obj.forecasts_count,
+ "forecasters_count": obj.forecasters_count,
+ "timeline": projects_timeline_map.get(obj.id),
+ }
+ )
+
data.append(serialized_tournament)
- data.sort(key=sort_key, reverse=True)
+ if sort_key:
+ data.sort(key=sort_key, reverse=True)
+
return data
diff --git a/projects/services/cache.py b/projects/services/cache.py
index 2200a84d71..eacc7aca88 100644
--- a/projects/services/cache.py
+++ b/projects/services/cache.py
@@ -5,6 +5,7 @@
QUESTIONS_COUNT_CACHE_PREFIX = "project_questions_count:v1"
QUESTIONS_COUNT_CACHE_TIMEOUT = 1 * 3600 # 3 hour
+PROJECT_TIMELINE_TTL_SECONDS = 5 * 360
def get_projects_questions_count_cache_key(project_id: int) -> str:
diff --git a/projects/services/common.py b/projects/services/common.py
index cbbae72497..ce8842a812 100644
--- a/projects/services/common.py
+++ b/projects/services/common.py
@@ -1,15 +1,19 @@
from collections import defaultdict
from datetime import datetime
+from itertools import chain
from typing import Iterable
from django.db import IntegrityError
+from django.db.models import F
from django.utils import timezone
from django.utils.timezone import make_aware
from posts.models import Post
from projects.models import Project, ProjectUserPermission
from projects.permissions import ObjectPermission
+from questions.models import Question
from users.models import User
+from utils.cache import cache_per_object
from utils.dtypes import generate_map_from_list
@@ -152,7 +156,7 @@ def move_project_forecasting_end_date(project: Project, post: Post):
project.save(update_fields=["forecasting_end_date"])
-def get_project_timeline_data(project: Project):
+def _calculate_timeline_data(project: Project, questions: Iterable[Question]) -> dict:
all_questions_resolved = True
all_questions_closed = True
@@ -160,44 +164,36 @@ def get_project_timeline_data(project: Project):
actual_resolve_times = []
scheduled_resolve_times = []
- posts = (
- Post.objects.filter_projects(project)
- .filter_questions()
- .prefetch_questions()
- .filter(curation_status=Post.CurationStatus.APPROVED)
- )
-
project_close_date = project.close_date or make_aware(datetime.max)
project_forecasting_end_date = project.forecasting_end_date or project_close_date
- for post in posts:
- for question in post.get_questions():
- if all_questions_resolved:
- all_questions_resolved = (
- question.actual_resolve_time
- # Or treat as resolved as scheduled resolution is in the future
- or question.scheduled_resolve_time > project_close_date
- )
-
- # Determine questions closure
- if all_questions_closed:
- close_time = question.actual_close_time or question.scheduled_close_time
- all_questions_closed = (
- close_time <= timezone.now()
- or close_time > project_forecasting_end_date
- )
-
- if question.cp_reveal_time:
- cp_reveal_times.append(question.cp_reveal_time)
-
- if question.actual_resolve_time:
- actual_resolve_times.append(question.actual_resolve_time)
-
- if question.scheduled_resolve_time:
- scheduled_resolve_time = (
- question.actual_resolve_time or question.scheduled_resolve_time
- )
- scheduled_resolve_times.append(scheduled_resolve_time)
+ for question in questions:
+ if all_questions_resolved:
+ all_questions_resolved = (
+ question.actual_resolve_time
+ # Or treat as resolved as scheduled resolution is in the future
+ or question.scheduled_resolve_time > project_close_date
+ )
+
+ # Determine questions closure
+ if all_questions_closed:
+ close_time = question.actual_close_time or question.scheduled_close_time
+ all_questions_closed = (
+ close_time <= timezone.now()
+ or close_time > project_forecasting_end_date
+ )
+
+ if question.cp_reveal_time:
+ cp_reveal_times.append(question.cp_reveal_time)
+
+ if question.actual_resolve_time:
+ actual_resolve_times.append(question.actual_resolve_time)
+
+ if question.scheduled_resolve_time:
+ scheduled_resolve_time = (
+ question.actual_resolve_time or question.scheduled_resolve_time
+ )
+ scheduled_resolve_times.append(scheduled_resolve_time)
def get_max(data: list):
return max([x for x in data if x <= project_close_date], default=None)
@@ -211,6 +207,71 @@ def get_max(data: list):
}
+def get_project_timeline_data(project: Project):
+ # Fetch questions directly as per new requirement
+ questions = Question.objects.filter(
+ related_posts__post__default_project=project,
+ related_posts__post__curation_status=Post.CurationStatus.APPROVED,
+ ).distinct("id")
+
+ return _calculate_timeline_data(project, questions)
+
+
+@cache_per_object(timeout=60 * 15)
+def get_timeline_data_for_projects(project_ids: list[int]) -> dict[int, dict]:
+ projects = Project.objects.in_bulk(project_ids)
+
+ # 1. Map Project -> Post IDs
+ project_posts = defaultdict(set)
+
+ # Default projects
+ qs_default = Post.objects.filter(
+ default_project_id__in=project_ids,
+ curation_status=Post.CurationStatus.APPROVED,
+ ).values_list("id", "default_project_id")
+
+ # M2M projects
+ qs_m2m = Post.projects.through.objects.filter(
+ project_id__in=project_ids,
+ post__curation_status=Post.CurationStatus.APPROVED,
+ ).values_list("post_id", "project_id")
+
+ for post_id, project_id in chain(qs_default, qs_m2m):
+ project_posts[project_id].add(post_id)
+
+ # 2. Fetch Questions
+ all_post_ids = set().union(*project_posts.values())
+
+ questions = (
+ Question.objects.filter(related_posts__post_id__in=all_post_ids)
+ .annotate(post_id=F("related_posts__post_id"))
+ .only(
+ "id",
+ "cp_reveal_time",
+ "actual_resolve_time",
+ "scheduled_resolve_time",
+ "actual_close_time",
+ "scheduled_close_time",
+ )
+ )
+
+ # Group by post
+ questions_by_post = defaultdict(list)
+ for q in questions:
+ questions_by_post[q.post_id].append(q)
+
+ # 3. Aggregate
+ return {
+ pid: _calculate_timeline_data(
+ project,
+ chain.from_iterable(
+ questions_by_post[post_id] for post_id in project_posts.get(pid, [])
+ ),
+ )
+ for pid, project in projects.items()
+ }
+
+
def get_questions_count_for_projects(project_ids: list[int]) -> dict[int, int]:
"""
Returns a dict mapping each project_id to its questions_count
diff --git a/projects/views/common.py b/projects/views/common.py
index 2ec16457b1..d212952f72 100644
--- a/projects/views/common.py
+++ b/projects/views/common.py
@@ -21,7 +21,9 @@
serialize_index_data,
serialize_tournaments_with_counts,
)
-from projects.services.cache import get_projects_questions_count_cached
+from projects.services.cache import (
+ get_projects_questions_count_cached,
+)
from projects.services.common import (
get_projects_qs,
get_project_permission_for_user,
@@ -132,12 +134,13 @@ def tournaments_list_api_view(request: Request):
)
.exclude(visibility=Project.Visibility.UNLISTED)
.filter_tournament()
- .prefetch_related("primary_leaderboard")
+ .select_related("primary_leaderboard")
)
-
+ projects = list(qs)
data = serialize_tournaments_with_counts(
- qs, sort_key=lambda x: x["questions_count"]
+ projects, sort_key=lambda r: r["questions_count"], with_timeline=True
)
+
return Response(data)
diff --git a/tests/unit/test_projects/test_services/test_timeline.py b/tests/unit/test_projects/test_services/test_timeline.py
new file mode 100644
index 0000000000..4e92bb1abc
--- /dev/null
+++ b/tests/unit/test_projects/test_services/test_timeline.py
@@ -0,0 +1,116 @@
+from datetime import timedelta
+
+from django.utils import timezone
+
+from posts.models import Post
+from projects.services.common import (
+ get_project_timeline_data,
+ get_timeline_data_for_projects,
+)
+from questions.models import Question
+from tests.unit.test_posts.factories import factory_post
+from tests.unit.test_projects.factories import factory_project
+from tests.unit.test_questions.factories import create_question
+
+
+def test_get_project_timeline_data(user1):
+ project = factory_project()
+ now = timezone.now()
+
+ # Create posts with questions
+ factory_post(
+ author=user1,
+ default_project=project,
+ curation_status=Post.CurationStatus.APPROVED,
+ question=create_question(
+ question_type=Question.QuestionType.BINARY,
+ actual_resolve_time=now - timedelta(days=5),
+ actual_close_time=now - timedelta(days=5),
+ ),
+ )
+ post2 = factory_post(
+ author=user1,
+ default_project=project,
+ curation_status=Post.CurationStatus.APPROVED,
+ question=create_question(
+ question_type=Question.QuestionType.BINARY,
+ actual_resolve_time=now - timedelta(days=2),
+ actual_close_time=now - timedelta(days=2),
+ ),
+ )
+
+ # Create a post that shouldn't be included (not approved)
+ factory_post(
+ author=user1,
+ default_project=project,
+ curation_status=Post.CurationStatus.DRAFT,
+ question=create_question(question_type=Question.QuestionType.BINARY),
+ )
+
+ data = get_project_timeline_data(project=project)
+
+ assert data["latest_actual_resolve_time"] == post2.question.actual_resolve_time
+ assert data["all_questions_resolved"]
+ assert data["all_questions_closed"]
+
+
+def test_get_timeline_data_for_projects(user1, django_assert_num_queries):
+ project1 = factory_project()
+ project2 = factory_project()
+ now = timezone.now()
+
+ # Project 1: One resolved question
+ factory_post(
+ author=user1,
+ default_project=project1,
+ curation_status=Post.CurationStatus.APPROVED,
+ question=create_question(
+ question_type=Question.QuestionType.BINARY,
+ actual_resolve_time=now - timedelta(days=10),
+ actual_close_time=now - timedelta(days=10),
+ ),
+ )
+
+ # Project 2: One open question
+ factory_post(
+ author=user1,
+ default_project=project2,
+ curation_status=Post.CurationStatus.APPROVED,
+ question=create_question(
+ question_type=Question.QuestionType.BINARY,
+ actual_resolve_time=None,
+ scheduled_resolve_time=now + timedelta(days=10),
+ scheduled_close_time=now + timedelta(days=5),
+ ),
+ )
+
+ # Shared Post: In Project 1 (default) and Project 2 (m2m)
+ # Resolved recently
+ post3 = factory_post(
+ author=user1,
+ default_project=project1,
+ projects=[project2],
+ curation_status=Post.CurationStatus.APPROVED,
+ question=create_question(
+ question_type=Question.QuestionType.BINARY,
+ actual_resolve_time=now - timedelta(days=1),
+ actual_close_time=now - timedelta(days=1),
+ ),
+ )
+
+ # Call function and ensure queries count
+ with django_assert_num_queries(4):
+ data = get_timeline_data_for_projects([project1.pk, project2.pk])
+
+ # Check Project 1 Data
+ # Should include post1 and post3
+ p1_data = data[project1.pk]
+ assert p1_data["latest_actual_resolve_time"] == post3.question.actual_resolve_time
+ assert p1_data["all_questions_resolved"]
+
+ # Check Project 2 Data
+ # Should include post2 and post3
+ p2_data = data[project2.pk]
+ # post3 is resolved, but post2 is not
+ assert not p2_data["all_questions_resolved"]
+ assert p2_data["latest_actual_resolve_time"] == post3.question.actual_resolve_time