diff --git a/front_end/messages/cs.json b/front_end/messages/cs.json index b5bd213d8b..a8d5674f40 100644 --- a/front_end/messages/cs.json +++ b/front_end/messages/cs.json @@ -2253,7 +2253,7 @@ "thousandsOfOpenQuestions": "20 000+ otevřených otázek", "midtermsHubMetaTitle": "Mezičasové volby USA 2026 | Metaculus", "midtermsHubMetaDescription": "Předpovědi komunity Metaculus v reálném čase k mezičasovým volbám USA v roce 2026.", - "midtermsHubChamberCurrent": "Aktuálně:", + "midtermsHubChamberToday": "Dnes:", "midtermsHubChamberForecast": "Předpověď:", "midtermsHubChamberTooltipBody": "Prognostici dávají {party} {pct}% šanci na získání nejvíce křesel v {chamber}.", "midtermsHubChamberTooltipDisclaimer": "Klikněte pro zobrazení předpovědní otázky", @@ -2296,6 +2296,8 @@ "midtermsHubScrollRight": "Posunout poznatky doprava", "midtermsHubDemocrat": "Demokraté", "midtermsHubRepublican": "Republikáni", + "midtermsHubRaceLeanSummary": "V {count} z {total} soubojů vedou {party}", + "midtermsHubCloseRacesSummary": "{count, plural, one {# vyrovnaný souboj} few {# vyrovnané souboje} other {# vyrovnaných soubojů}}", "midtermsHubNotContested": "Bez voleb", "midtermsHubDemPct": "{pct}% demokraté", "midtermsHubRepPct": "{pct}% republikáni", @@ -2317,11 +2319,32 @@ "midtermsHubDemSeatAdvantage": "Demokratická převaha mandátů", "midtermsHubRepSeatAdvantage": "Republikánská převaha mandátů", "midtermsHubEven": "REMÍZA", + "midtermsHubMedianLabel": "Medián:", + "midtermsHubMedianDem": "D +{count, plural, one {# mandát} few {# mandáty} other {# mandátů}}", + "midtermsHubMedianRep": "R +{count, plural, one {# mandát} few {# mandáty} other {# mandátů}}", + "midtermsHubMedianEven": "Vyrovnané rozdělení", "midtermsHubForecastUnavailable": "Předpověď nedostupná", "midtermsHubSeatAdvantageTooltip": "Náskok {count} mandátů", "midtermsHubSeatAdvantageOverTooltip": "Náskok více než {count} mandátů", "midtermsHubProbabilityTooltip": "{value}% pravděpodobnost", "midtermsHubEvenTooltip": "Remíza", + "midtermsHubTimelineUnavailable": "Historie předpovědí není dostupná", + "midtermsHubEngagementReachTitle": "Kontaktujte Metaculus", + "midtermsHubEngagementReachBody": "Máte postřehy, dotazy nebo jste našli něco nejasného? Vaše zpětná vazba nám pomáhá tento přehled zlepšovat.", + "midtermsHubEngagementViewServices": "Zobrazit služby", + "midtermsHubEngagementShareTitle": "Sdílejte přehled", + "midtermsHubEngagementShareBody": "Myslíte, že to pomůže i ostatním sledovat americké volby v polovině mandátu 2026? Sdílejte to se svou sítí.", + "midtermsHubEngagementShareOnX": "Sdílet na X", + "midtermsHubEngagementCopySuccess": "Odkaz byl zkopírován do schránky", + "midtermsHubEngagementCopyError": "Odkaz se nepodařilo zkopírovat. Zkuste to prosím znovu.", + "midtermsHubEngagementTweetText": "Podívejte se na přehled predikcí amerických voleb v polovině mandátu 2026 na Metaculu", + "midtermsHubEngagementNewsletterTitle": "Přihlaste se k odběru novinek", + "midtermsHubEngagementNewsletterBody": "Zaregistrujte se a dostanete upozornění, když se předpovědi změní a zveřejníme nové volební analýzy.", + "midtermsHubEngagementNewsletterSubmit": "Uložit", + "midtermsHubEngagementNewsletterSuccess": "Odběr je aktivní!", + "midtermsHubEngagementEmailPlaceholder": "E-mailová adresa", + "midtermsHubEngagementToastSuccess": "Odběr byl úspěšně zahájen!", + "midtermsHubEngagementToastError": "Odběr se nepodařilo nastavit. Zkuste to prosím znovu.", "nMore": "{count} více", "noHistogramData": "Žádná data histogramu nejsou k dispozici", "laborHubJobsBreadcrumb": "Všechna povolání", diff --git a/front_end/messages/en.json b/front_end/messages/en.json index 3b4b48b1fd..a742924847 100644 --- a/front_end/messages/en.json +++ b/front_end/messages/en.json @@ -2332,7 +2332,7 @@ "midtermsHubChamberGovernor": "Governor", "midtermsHubChamberControl": "Chamber control", "midtermsHubCongressForecast": "Congressional Control", - "midtermsHubChamberCurrent": "Current:", + "midtermsHubChamberToday": "Today:", "midtermsHubChamberForecast": "Forecast:", "midtermsHubChamberTooltipBody": "Forecasters give {party} a {pct}% chance of holding the most {chamber} seats.", "midtermsHubChamberTooltipDisclaimer": "Click to view forecasting question", @@ -2370,6 +2370,8 @@ "midtermsHubScrollRight": "Scroll insights right", "midtermsHubDemocrat": "Democrat", "midtermsHubRepublican": "Republican", + "midtermsHubRaceLeanSummary": "{count} of {total} races lean {party}", + "midtermsHubCloseRacesSummary": "{count} too close to call", "midtermsHubNotContested": "Not contested", "midtermsHubDemPct": "{pct}% Democrat", "midtermsHubRepPct": "{pct}% Republican", @@ -2391,9 +2393,30 @@ "midtermsHubDemSeatAdvantage": "Democratic Seat Advantage", "midtermsHubRepSeatAdvantage": "Republican Seat Advantage", "midtermsHubEven": "EVEN", + "midtermsHubMedianLabel": "Median:", + "midtermsHubMedianDem": "D +{count, plural, one {# seat} other {# seats}}", + "midtermsHubMedianRep": "R +{count, plural, one {# seat} other {# seats}}", + "midtermsHubMedianEven": "Even split", "midtermsHubForecastUnavailable": "Forecast unavailable", "midtermsHubSeatAdvantageTooltip": "{count} seat advantage", "midtermsHubSeatAdvantageOverTooltip": ">{count} seat advantage", "midtermsHubProbabilityTooltip": "{value}% probability", - "midtermsHubEvenTooltip": "Even" + "midtermsHubEvenTooltip": "Even", + "midtermsHubTimelineUnavailable": "Forecast history unavailable", + "midtermsHubEngagementReachTitle": "Reach out to Metaculus", + "midtermsHubEngagementReachBody": "Have thoughts, questions, or spotted something unclear? Your input helps us improve this dashboard.", + "midtermsHubEngagementViewServices": "View Services", + "midtermsHubEngagementShareTitle": "Share the dashboard", + "midtermsHubEngagementShareBody": "Think this could help others follow the 2026 midterms? Share it with your network.", + "midtermsHubEngagementShareOnX": "Share on X", + "midtermsHubEngagementCopySuccess": "Link copied to your clipboard", + "midtermsHubEngagementCopyError": "Couldn't copy the link. Please try again.", + "midtermsHubEngagementTweetText": "Check out the 2026 US Midterm Elections forecasting dashboard on Metaculus", + "midtermsHubEngagementNewsletterTitle": "Subscribe for updates", + "midtermsHubEngagementNewsletterBody": "Sign up to get notified as forecasts shift and we publish new midterms insights.", + "midtermsHubEngagementNewsletterSubmit": "Save", + "midtermsHubEngagementNewsletterSuccess": "You're subscribed!", + "midtermsHubEngagementEmailPlaceholder": "Email address", + "midtermsHubEngagementToastSuccess": "Subscribed successfully!", + "midtermsHubEngagementToastError": "Failed to subscribe. Please try again." } diff --git a/front_end/messages/es.json b/front_end/messages/es.json index a3d1f7276f..9c67698706 100644 --- a/front_end/messages/es.json +++ b/front_end/messages/es.json @@ -2253,7 +2253,7 @@ "thousandsOfOpenQuestions": "20,000+ preguntas abiertas", "midtermsHubMetaTitle": "Elecciones intermedias de EE. UU. de 2026 | Metaculus", "midtermsHubMetaDescription": "Pronósticos en tiempo real de la comunidad de Metaculus sobre las elecciones intermedias de EE. UU. de 2026.", - "midtermsHubChamberCurrent": "Actual:", + "midtermsHubChamberToday": "Hoy:", "midtermsHubChamberForecast": "Pronóstico:", "midtermsHubChamberTooltipBody": "Los pronosticadores dan a {party} un {pct}% de probabilidad de obtener la mayor cantidad de escaños en {chamber}.", "midtermsHubChamberTooltipDisclaimer": "Haz clic para ver la pregunta de pronóstico", @@ -2296,6 +2296,8 @@ "midtermsHubScrollRight": "Desplazar perspectivas a la derecha", "midtermsHubDemocrat": "Demócrata", "midtermsHubRepublican": "Republicano", + "midtermsHubRaceLeanSummary": "{count} de {total} contiendas favorecen al partido {party}", + "midtermsHubCloseRacesSummary": "{count, plural, one {# contienda muy reñida} other {# contiendas muy reñidas}}", "midtermsHubNotContested": "No disputado", "midtermsHubDemPct": "{pct}% Demócrata", "midtermsHubRepPct": "{pct}% Republicano", @@ -2317,11 +2319,32 @@ "midtermsHubDemSeatAdvantage": "Ventaja de escaños demócratas", "midtermsHubRepSeatAdvantage": "Ventaja de escaños republicanos", "midtermsHubEven": "EMPATE", + "midtermsHubMedianLabel": "Mediana:", + "midtermsHubMedianDem": "D +{count, plural, one {# escaño} other {# escaños}}", + "midtermsHubMedianRep": "R +{count, plural, one {# escaño} other {# escaños}}", + "midtermsHubMedianEven": "Reparto igualado", "midtermsHubForecastUnavailable": "Pronóstico no disponible", "midtermsHubSeatAdvantageTooltip": "{count} escaños de ventaja", "midtermsHubSeatAdvantageOverTooltip": ">{count} escaños de ventaja", "midtermsHubProbabilityTooltip": "{value}% de probabilidad", "midtermsHubEvenTooltip": "Empate", + "midtermsHubTimelineUnavailable": "Historial de previsiones no disponible", + "midtermsHubEngagementReachTitle": "Ponte en contacto con Metaculus", + "midtermsHubEngagementReachBody": "¿Tienes ideas, preguntas o has detectado algo confuso? Tus comentarios nos ayudan a mejorar este panel.", + "midtermsHubEngagementViewServices": "Ver servicios", + "midtermsHubEngagementShareTitle": "Comparte el panel", + "midtermsHubEngagementShareBody": "¿Crees que esto puede ayudar a otras personas a seguir las elecciones de medio mandato de 2026? Compártelo con tu red.", + "midtermsHubEngagementShareOnX": "Compartir en X", + "midtermsHubEngagementCopySuccess": "Enlace copiado al portapapeles", + "midtermsHubEngagementCopyError": "No se pudo copiar el enlace. Inténtalo de nuevo.", + "midtermsHubEngagementTweetText": "Echa un vistazo al panel de previsiones de las elecciones de medio mandato de EE. UU. de 2026 en Metaculus", + "midtermsHubEngagementNewsletterTitle": "Suscríbete para recibir novedades", + "midtermsHubEngagementNewsletterBody": "Regístrate para recibir avisos cuando cambien las previsiones y publiquemos nuevos análisis electorales.", + "midtermsHubEngagementNewsletterSubmit": "Guardar", + "midtermsHubEngagementNewsletterSuccess": "¡Ya estás suscrito!", + "midtermsHubEngagementEmailPlaceholder": "Correo electrónico", + "midtermsHubEngagementToastSuccess": "¡Suscripción realizada!", + "midtermsHubEngagementToastError": "No se pudo completar la suscripción. Inténtalo de nuevo.", "nMore": "{count} más", "noHistogramData": "No hay datos de histograma disponibles", "laborHubJobsBreadcrumb": "Todos los empleos", diff --git a/front_end/messages/pt.json b/front_end/messages/pt.json index da0b452e04..2a801a8848 100644 --- a/front_end/messages/pt.json +++ b/front_end/messages/pt.json @@ -2251,7 +2251,7 @@ "thousandsOfOpenQuestions": "20.000+ perguntas abertas", "midtermsHubMetaTitle": "Eleições de meio de mandato dos EUA 2026 | Metaculus", "midtermsHubMetaDescription": "Previsões em tempo real da comunidade Metaculus sobre as eleições de meio de mandato dos EUA de 2026.", - "midtermsHubChamberCurrent": "Atual:", + "midtermsHubChamberToday": "Hoje:", "midtermsHubChamberForecast": "Previsão:", "midtermsHubChamberTooltipBody": "Os analistas dão a {party} uma probabilidade de {pct}% de obter o maior número de cadeiras na {chamber}.", "midtermsHubChamberTooltipDisclaimer": "Clique para ver a pergunta de previsão", @@ -2294,6 +2294,8 @@ "midtermsHubScrollRight": "Rolar insights para a direita", "midtermsHubDemocrat": "Democrata", "midtermsHubRepublican": "Republicano", + "midtermsHubRaceLeanSummary": "{count} de {total} disputas favorecem o partido {party}", + "midtermsHubCloseRacesSummary": "{count, plural, one {# disputa muito acirrada} other {# disputas muito acirradas}}", "midtermsHubNotContested": "Sem disputa", "midtermsHubDemPct": "{pct}% Democrata", "midtermsHubRepPct": "{pct}% Republicano", @@ -2315,11 +2317,32 @@ "midtermsHubDemSeatAdvantage": "Vantagem de cadeiras democratas", "midtermsHubRepSeatAdvantage": "Vantagem de cadeiras republicanas", "midtermsHubEven": "EMPATE", + "midtermsHubMedianLabel": "Mediana:", + "midtermsHubMedianDem": "D +{count, plural, one {# cadeira} other {# cadeiras}}", + "midtermsHubMedianRep": "R +{count, plural, one {# cadeira} other {# cadeiras}}", + "midtermsHubMedianEven": "Divisão igualada", "midtermsHubForecastUnavailable": "Previsão indisponível", "midtermsHubSeatAdvantageTooltip": "{count} cadeiras de vantagem", "midtermsHubSeatAdvantageOverTooltip": ">{count} cadeiras de vantagem", "midtermsHubProbabilityTooltip": "{value}% de probabilidade", "midtermsHubEvenTooltip": "Empate", + "midtermsHubTimelineUnavailable": "Histórico de previsões indisponível", + "midtermsHubEngagementReachTitle": "Fale com a Metaculus", + "midtermsHubEngagementReachBody": "Tem ideias, dúvidas ou encontrou algo pouco claro? Sua opinião nos ajuda a melhorar este painel.", + "midtermsHubEngagementViewServices": "Ver serviços", + "midtermsHubEngagementShareTitle": "Compartilhe o painel", + "midtermsHubEngagementShareBody": "Acha que isso pode ajudar outras pessoas a acompanhar as eleições de meio de mandato de 2026? Compartilhe com sua rede.", + "midtermsHubEngagementShareOnX": "Compartilhar no X", + "midtermsHubEngagementCopySuccess": "Link copiado para a área de transferência", + "midtermsHubEngagementCopyError": "Não foi possível copiar o link. Tente novamente.", + "midtermsHubEngagementTweetText": "Confira o painel de previsões das eleições de meio de mandato dos EUA de 2026 na Metaculus", + "midtermsHubEngagementNewsletterTitle": "Assine para receber novidades", + "midtermsHubEngagementNewsletterBody": "Cadastre-se para ser avisado quando as previsões mudarem e publicarmos novas análises eleitorais.", + "midtermsHubEngagementNewsletterSubmit": "Salvar", + "midtermsHubEngagementNewsletterSuccess": "Você está inscrito!", + "midtermsHubEngagementEmailPlaceholder": "Endereço de e-mail", + "midtermsHubEngagementToastSuccess": "Inscrição realizada com sucesso!", + "midtermsHubEngagementToastError": "Não foi possível concluir a inscrição. Tente novamente.", "nMore": "{count} a mais", "noHistogramData": "Não há dados de histograma disponíveis", "laborHubJobsBreadcrumb": "Todas as profissões", diff --git a/front_end/messages/zh-TW.json b/front_end/messages/zh-TW.json index 11f18ee7bd..bb75317e5b 100644 --- a/front_end/messages/zh-TW.json +++ b/front_end/messages/zh-TW.json @@ -2250,7 +2250,7 @@ "thousandsOfOpenQuestions": "20,000+ 開放問題", "midtermsHubMetaTitle": "2026 年美國期中選舉 | Metaculus", "midtermsHubMetaDescription": "Metaculus 社群對 2026 年美國期中選舉的即時預測。", - "midtermsHubChamberCurrent": "目前:", + "midtermsHubChamberToday": "今日:", "midtermsHubChamberForecast": "預測:", "midtermsHubChamberTooltipBody": "預測者認為{party}有 {pct}% 的機率在{chamber}中贏得最多席位。", "midtermsHubChamberTooltipDisclaimer": "點擊查看預測問題", @@ -2293,6 +2293,8 @@ "midtermsHubScrollRight": "向右捲動觀點", "midtermsHubDemocrat": "民主黨", "midtermsHubRepublican": "共和黨", + "midtermsHubRaceLeanSummary": "在 {total} 場競選中,{count} 場傾向{party}", + "midtermsHubCloseRacesSummary": "{count} 場勢均力敵", "midtermsHubNotContested": "無競爭", "midtermsHubDemPct": "{pct}% 民主黨", "midtermsHubRepPct": "{pct}% 共和黨", @@ -2314,11 +2316,32 @@ "midtermsHubDemSeatAdvantage": "民主黨席位優勢", "midtermsHubRepSeatAdvantage": "共和黨席位優勢", "midtermsHubEven": "持平", + "midtermsHubMedianLabel": "中位數:", + "midtermsHubMedianDem": "民主黨 +{count} 席", + "midtermsHubMedianRep": "共和黨 +{count} 席", + "midtermsHubMedianEven": "席次均分", "midtermsHubForecastUnavailable": "暫無預測", "midtermsHubSeatAdvantageTooltip": "領先 {count} 席", "midtermsHubSeatAdvantageOverTooltip": "領先超過 {count} 席", "midtermsHubProbabilityTooltip": "{value}% 機率", "midtermsHubEvenTooltip": "持平", + "midtermsHubTimelineUnavailable": "暫無預測歷史", + "midtermsHubEngagementReachTitle": "聯絡 Metaculus", + "midtermsHubEngagementReachBody": "有想法、疑問,或發現了不清楚的地方?您的回饋能幫助我們改進這個面板。", + "midtermsHubEngagementViewServices": "查看服務", + "midtermsHubEngagementShareTitle": "分享這個面板", + "midtermsHubEngagementShareBody": "覺得這能幫助其他人了解 2026 年美國期中選舉嗎?分享給你的朋友。", + "midtermsHubEngagementShareOnX": "在 X 上分享", + "midtermsHubEngagementCopySuccess": "連結已複製到剪貼簿", + "midtermsHubEngagementCopyError": "複製連結失敗,請重試。", + "midtermsHubEngagementTweetText": "來看看 Metaculus 上的 2026 年美國期中選舉預測面板", + "midtermsHubEngagementNewsletterTitle": "訂閱更新", + "midtermsHubEngagementNewsletterBody": "註冊後,我們會在預測發生變化或發布新的期中選舉分析時通知您。", + "midtermsHubEngagementNewsletterSubmit": "儲存", + "midtermsHubEngagementNewsletterSuccess": "您已訂閱!", + "midtermsHubEngagementEmailPlaceholder": "電子郵件地址", + "midtermsHubEngagementToastSuccess": "訂閱成功!", + "midtermsHubEngagementToastError": "訂閱失敗,請重試。", "nMore": "還有 {count} 個", "noHistogramData": "沒有可用的直方圖資料", "laborHubJobsBreadcrumb": "所有職業", diff --git a/front_end/messages/zh.json b/front_end/messages/zh.json index d02e635d54..896c23bf5e 100644 --- a/front_end/messages/zh.json +++ b/front_end/messages/zh.json @@ -2254,7 +2254,7 @@ "thousandsOfOpenQuestions": "20,000+ 开放问题", "midtermsHubMetaTitle": "2026 年美国中期选举 | Metaculus", "midtermsHubMetaDescription": "Metaculus 社区对 2026 年美国中期选举的实时预测。", - "midtermsHubChamberCurrent": "当前:", + "midtermsHubChamberToday": "今天:", "midtermsHubChamberForecast": "预测:", "midtermsHubChamberTooltipBody": "预测者认为{party}有 {pct}% 的概率在{chamber}中赢得最多席位。", "midtermsHubChamberTooltipDisclaimer": "点击查看预测问题", @@ -2297,6 +2297,8 @@ "midtermsHubScrollRight": "向右滚动洞察", "midtermsHubDemocrat": "民主党", "midtermsHubRepublican": "共和党", + "midtermsHubRaceLeanSummary": "在 {total} 场竞选中,{count} 场倾向{party}", + "midtermsHubCloseRacesSummary": "{count} 场势均力敌", "midtermsHubNotContested": "无竞争", "midtermsHubDemPct": "{pct}% 民主党", "midtermsHubRepPct": "{pct}% 共和党", @@ -2318,11 +2320,32 @@ "midtermsHubDemSeatAdvantage": "民主党席位优势", "midtermsHubRepSeatAdvantage": "共和党席位优势", "midtermsHubEven": "持平", + "midtermsHubMedianLabel": "中位数:", + "midtermsHubMedianDem": "民主党 +{count} 席", + "midtermsHubMedianRep": "共和党 +{count} 席", + "midtermsHubMedianEven": "席位均分", "midtermsHubForecastUnavailable": "暂无预测", "midtermsHubSeatAdvantageTooltip": "领先 {count} 席", "midtermsHubSeatAdvantageOverTooltip": "领先超过 {count} 席", "midtermsHubProbabilityTooltip": "{value}% 概率", "midtermsHubEvenTooltip": "持平", + "midtermsHubTimelineUnavailable": "暂无预测历史", + "midtermsHubEngagementReachTitle": "联系 Metaculus", + "midtermsHubEngagementReachBody": "有想法、疑问,或发现了不清楚的地方?您的反馈能帮助我们改进这个面板。", + "midtermsHubEngagementViewServices": "查看服务", + "midtermsHubEngagementShareTitle": "分享这个面板", + "midtermsHubEngagementShareBody": "觉得这能帮助其他人了解 2026 年美国中期选举吗?分享给你的朋友。", + "midtermsHubEngagementShareOnX": "在 X 上分享", + "midtermsHubEngagementCopySuccess": "链接已复制到剪贴板", + "midtermsHubEngagementCopyError": "复制链接失败,请重试。", + "midtermsHubEngagementTweetText": "来看看 Metaculus 上的 2026 年美国中期选举预测面板", + "midtermsHubEngagementNewsletterTitle": "订阅更新", + "midtermsHubEngagementNewsletterBody": "注册后,我们会在预测发生变化或发布新的中期选举分析时通知您。", + "midtermsHubEngagementNewsletterSubmit": "保存", + "midtermsHubEngagementNewsletterSuccess": "您已订阅!", + "midtermsHubEngagementEmailPlaceholder": "电子邮件地址", + "midtermsHubEngagementToastSuccess": "订阅成功!", + "midtermsHubEngagementToastError": "订阅失败,请重试。", "nMore": "{count} 更多", "noHistogramData": "没有可用的直方图数据", "laborHubJobsBreadcrumb": "所有职业", diff --git a/front_end/src/app/(main)/actions.ts b/front_end/src/app/(main)/actions.ts index 4fdd40edf4..9bcc729f98 100644 --- a/front_end/src/app/(main)/actions.ts +++ b/front_end/src/app/(main)/actions.ts @@ -18,8 +18,14 @@ export async function dismissFeedTile(id: string) { return await serverMiscApi.dismissFeedTile(id); } +// Mailjet contact list per hub. A listKey that isn't in here resolves to +// undefined and then falls back to MAILJET_NEWSLETTER_ID downstream (see +// misc.server.ts), so a missing or misspelled entry silently subscribes people to +// the general newsletter instead of erroring — always add the key here alongside +// the hub that passes it. const NEWSLETTER_LIST_IDS: Record = { labor: "10617959", + midterms: "10628608", }; export async function subscribeToNewsletter(email: string, listKey?: string) { diff --git a/front_end/src/app/(main)/labor-hub/components/newsletter_subscribe_card.tsx b/front_end/src/app/(main)/labor-hub/components/newsletter_subscribe_card.tsx index 6d188ba9ce..2b2fa02913 100644 --- a/front_end/src/app/(main)/labor-hub/components/newsletter_subscribe_card.tsx +++ b/front_end/src/app/(main)/labor-hub/components/newsletter_subscribe_card.tsx @@ -10,21 +10,48 @@ import cn from "@/utils/core/cn"; import { subscribeToNewsletter } from "../../actions"; +export type NewsletterCopy = { + title: string; + description: string; + placeholder: string; + submit: string; + success: string; + toastSuccess: string; + toastError: string; +}; + +// The Labor Hub's original hardcoded English. Callers that are translated (e.g. +// the Midterms hub) pass localized strings via `copy`; omitting it keeps this +// card rendering exactly as it always has. +const DEFAULT_COPY: NewsletterCopy = { + title: "Subscribe for updates", + description: + "Sign up to get notified when we publish new forecasts, insights, and improvements.", + placeholder: "Email address", + submit: "Save", + success: "You're subscribed!", + toastSuccess: "Subscribed successfully!", + toastError: "Failed to subscribe. Please try again.", +}; + type Props = { listKey?: string; className?: string; formClassName?: string; + copy?: Partial; }; export function NewsletterSubscribeCard({ listKey, className, formClassName, + copy, }: Props) { const { user } = useAuth(); const [email, setEmail] = useState(user?.email ?? ""); const [isLoading, setIsLoading] = useState(false); const [isSuccess, setIsSuccess] = useState(false); + const text = { ...DEFAULT_COPY, ...copy }; const handleSubmit = async (e: FormEvent) => { e.preventDefault(); @@ -35,11 +62,11 @@ export function NewsletterSubscribeCard({ await subscribeToNewsletter(email, listKey); setIsSuccess(true); setEmail(""); - toast("Subscribed successfully!", { + toast(text.toastSuccess, { className: "dark:bg-blue-700-dark dark:text-gray-0-dark", }); } catch { - toast.error("Failed to subscribe. Please try again."); + toast.error(text.toastError); } finally { setIsLoading(false); } @@ -59,17 +86,16 @@ export function NewsletterSubscribeCard({ className="text-xl text-purple-800 dark:text-purple-800-dark lg:text-[22px]" />

- Subscribe for updates + {text.title}

- Sign up to get notified when we publish new forecasts, insights, and - improvements. + {text.description}

{isSuccess ? (
- You're subscribed! + {text.success}
) : (
setEmail(e.target.value)} - aria-label="Email address" - placeholder="Email address" + aria-label={text.placeholder} + placeholder={text.placeholder} className="min-w-0 flex-1 rounded bg-gray-0 px-3 py-2 text-sm text-purple-900 placeholder:text-purple-600 focus:outline-none dark:bg-gray-0-dark dark:text-purple-900-dark dark:placeholder:text-purple-600-dark" required disabled={isLoading} @@ -94,7 +120,7 @@ export function NewsletterSubscribeCard({ disabled={isLoading} className="rounded bg-blue-800 px-3 py-2 text-sm text-gray-0 transition-colors hover:bg-blue-900 disabled:opacity-50 dark:bg-blue-800-dark dark:text-gray-0-dark dark:hover:bg-blue-900-dark" > - Save + {text.submit}
)} diff --git a/front_end/src/app/(main)/midterms-2026/components/balance_of_power_timelines.tsx b/front_end/src/app/(main)/midterms-2026/components/balance_of_power_timelines.tsx new file mode 100644 index 0000000000..de056b95a9 --- /dev/null +++ b/front_end/src/app/(main)/midterms-2026/components/balance_of_power_timelines.tsx @@ -0,0 +1,278 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { FC, ReactNode, useMemo, useState } from "react"; + +import GroupChart from "@/components/charts/group_chart"; +import { MULTIPLE_CHOICE_COLOR_SCALE } from "@/constants/colors"; +import { TimelineChartZoomOption } from "@/types/charts"; +import { PostWithForecasts } from "@/types/post"; +import { + QuestionType, + QuestionWithMultipleChoiceForecasts, +} from "@/types/question"; +import { ThemeColor } from "@/types/theme"; + +import { MIDTERMS_COLORS } from "../constants"; +import { CONGRESS_OUTCOME_LABELS } from "../data"; +import TimelineRangeToggle from "./timeline_range_toggle"; +import { + buildControlTimeline, + ControlTimeline, +} from "../helpers/build_control_timelines"; +import { useIsDark } from "../helpers/use_is_dark"; + +// Deliberately compact for the ~320px-wide sidebar column. Of this, PLOT_TOP +// (10px) and the axis band (20px) are chrome, so the trend itself gets ~72px — +// enough to read direction and crossings, which is all these are for. The +// precise numbers live in the legend, not the axes. +const CHART_HEIGHT = 102; + +const DEM: ThemeColor = { + DEFAULT: MIDTERMS_COLORS.demPrimary, + dark: MIDTERMS_COLORS.demPrimaryDark, +}; +const REP: ThemeColor = { + DEFAULT: MIDTERMS_COLORS.repPrimary, + dark: MIDTERMS_COLORS.repPrimaryDark, +}; + +// The four congress outcomes use the shared multiple-choice palette rather than +// party tints: two blues and two reds sit too close together to tell apart as +// four separate lines. +const NEUTRAL_FALLBACK: ThemeColor = { + DEFAULT: MIDTERMS_COLORS.spectrumNeutral, + dark: MIDTERMS_COLORS.spectrumNeutral, +}; +const MC = (i: number): ThemeColor => + MULTIPLE_CHOICE_COLOR_SCALE[i] ?? NEUTRAL_FALLBACK; + +type Props = { + /** The congress-control post (#34484) — the source for all three timelines. */ + post: PostWithForecasts | null; +}; + +const BalanceOfPowerTimelines: FC = ({ post }) => { + const t = useTranslations(); + const question = post?.question as + | QuestionWithMultipleChoiceForecasts + | undefined; + // One timeframe for all three charts — they answer the same question about the + // same window, so letting them drift apart would invite false comparisons. + // Opens on two months: the full history reaches back to Jan 2025 and its early + // flat stretch squashes the recent movement. ALL is a click away. + const [zoom, setZoom] = useState( + TimelineChartZoomOption.TwoMonths + ); + + const demLabel = t("midtermsHubPartyDemocrats"); + const repLabel = t("midtermsHubPartyRepublicans"); + + const timelines = useMemo(() => { + // Same option sums as chamber_control_card.tsx, so the last point of each + // series lands on the percentage the snapshot view shows. + const house = buildControlTimeline(question, [ + { + label: demLabel, + color: DEM, + optionLabels: [CONGRESS_OUTCOME_LABELS.DD, CONGRESS_OUTCOME_LABELS.RD], + }, + { + label: repLabel, + color: REP, + optionLabels: [CONGRESS_OUTCOME_LABELS.RR, CONGRESS_OUTCOME_LABELS.DR], + }, + ]); + const senate = buildControlTimeline(question, [ + { + label: demLabel, + color: DEM, + optionLabels: [CONGRESS_OUTCOME_LABELS.DD, CONGRESS_OUTCOME_LABELS.DR], + }, + { + label: repLabel, + color: REP, + optionLabels: [CONGRESS_OUTCOME_LABELS.RR, CONGRESS_OUTCOME_LABELS.RD], + }, + ]); + // The four outcomes read directly — no summing. + const congress = buildControlTimeline(question, [ + { + label: t("midtermsHubOutcomeRepRep"), + color: MC(0), + optionLabels: [CONGRESS_OUTCOME_LABELS.RR], + }, + { + label: t("midtermsHubOutcomeRepDem"), + color: MC(1), + optionLabels: [CONGRESS_OUTCOME_LABELS.RD], + }, + { + label: t("midtermsHubOutcomeDemRep"), + color: MC(2), + optionLabels: [CONGRESS_OUTCOME_LABELS.DR], + }, + { + label: t("midtermsHubOutcomeDemDem"), + color: MC(3), + optionLabels: [CONGRESS_OUTCOME_LABELS.DD], + }, + ]); + return { house, senate, congress }; + }, [question, demLabel, repLabel, t]); + + const unavailableLabel = t("midtermsHubTimelineUnavailable"); + const rangeToggle = ; + + return ( +
+ {/* md+: straddle the card's top edge at the panel's right. The negative + offset cancels the column's own padding so the pill's centre lands on + the border, and it tracks the padding step at lg. */} + {/* flex, not block: a block wrapper around the inline-flex pill picks up + ~8px of baseline descender space, which would offset the centring. */} +
+ {rangeToggle} +
+ {/* Spacing lives on its own wrapper: as a sibling of the absolute toggle, + the child selectors below can't catch it — previously it picked up py-2 + and stole :first-child from the House block. */} +
+ {rangeToggle}
} + /> + + +
+ + ); +}; + +const TimelineBlock: FC<{ + title: string; + timeline: ControlTimeline | null; + unavailableLabel: string; + zoom: TimelineChartZoomOption; + /** Optional control rendered on the title's row, right-aligned. */ + titleAccessory?: ReactNode; +}> = ({ title, timeline, unavailableLabel, zoom, titleAccessory }) => { + // Null = not hovering, so the legend shows the latest value. GroupChart reports + // the hovered timestamp here and we look each series up at that point. + const [cursorTimestamp, setCursorTimestamp] = useState(null); + + return ( +
+
+

+ {title} +

+ {titleAccessory} +
+ {timeline ? ( +
setCursorTimestamp(null)}> + + setCursorTimestamp(value)} + /> +
+ ) : ( +
+ {unavailableLabel} +
+ )} +
+ ); +}; + +const TimelineLegend: FC<{ + timeline: ControlTimeline; + cursorTimestamp: number | null; +}> = ({ timeline, cursorTimestamp }) => { + const isDark = useIsDark(); + const index = resolveIndex(timeline.timestamps, cursorTimestamp); + + return ( +
+ {timeline.choiceItems.map((item) => { + const value = item.aggregationValues[index]; + return ( + + + {item.choice} + + {value == null ? "—" : `${(value * 100).toFixed(1)}%`} + + + ); + })} +
+ ); +}; + +/** Index of the last point at or before the cursor; the latest point when idle. */ +function resolveIndex( + timestamps: number[], + cursorTimestamp: number | null +): number { + const lastIndex = timestamps.length - 1; + if (cursorTimestamp == null) return lastIndex; + for (let i = lastIndex; i >= 0; i--) { + if ((timestamps[i] ?? 0) <= cursorTimestamp) return i; + } + return 0; +} + +export default BalanceOfPowerTimelines; diff --git a/front_end/src/app/(main)/midterms-2026/components/chamber_control_card.tsx b/front_end/src/app/(main)/midterms-2026/components/chamber_control_card.tsx index 097da0b2fd..513bcb0fc5 100644 --- a/front_end/src/app/(main)/midterms-2026/components/chamber_control_card.tsx +++ b/front_end/src/app/(main)/midterms-2026/components/chamber_control_card.tsx @@ -55,7 +55,7 @@ export default async function ChamberControlCard({ data }: Props) { const labels = { forecast: t("midtermsHubChamberForecast"), - current: t("midtermsHubChamberCurrent"), + today: t("midtermsHubChamberToday"), democrats: t("midtermsHubPartyDemocrats"), republicans: t("midtermsHubPartyRepublicans"), }; @@ -126,7 +126,7 @@ export default async function ChamberControlCard({ data }: Props) { type Labels = { forecast: string; - current: string; + today: string; democrats: string; republicans: string; }; @@ -222,15 +222,14 @@ function ChamberRow({ )} -
- {labels.current} - - D {currentDem} - + {/* Pre-midterm baseline. Deliberately gray rather than party-colored (or + even the card's blue) so it reads as context and doesn't compete with + the live forecast above it. */} +
+ {labels.today} + D {currentDem} {slash} - - R {currentRep} - + R {currentRep}
); diff --git a/front_end/src/app/(main)/midterms-2026/components/geographic_map.tsx b/front_end/src/app/(main)/midterms-2026/components/geographic_map.tsx index b7cde07bf8..6510b77303 100644 --- a/front_end/src/app/(main)/midterms-2026/components/geographic_map.tsx +++ b/front_end/src/app/(main)/midterms-2026/components/geographic_map.tsx @@ -23,7 +23,6 @@ import type { } from "topojson-specification"; import { MIDTERMS_COLORS, STATE_NAMES } from "../constants"; -import MapLegend from "./map_legend"; import MapTooltipPortal from "./map_tooltip_portal"; import StateTooltipContent from "./state_tooltip"; import { SenateRaceWithQuestion } from "../helpers/post_utils"; @@ -88,8 +87,10 @@ const FIPS_TO_ABBR: Record = { type Props = { races: SenateRaceWithQuestion[]; - /** Tabs slot (rendered absolute top-left). */ + /** Tabs slot (rendered top-left of the header overlay). */ tabsSlot?: ReactNode; + /** Optional summary line, centered between the tabs and the legend. */ + summarySlot?: ReactNode; }; type HoverState = { @@ -111,7 +112,7 @@ const MAP_TRANSLATE: [number, number] = [400, 270]; const UNCONTESTED_OPACITY_DEFAULT = 0.75; const UNCONTESTED_OPACITY_HOVER = 1; -const GeographicMap: FC = ({ races, tabsSlot }) => { +const GeographicMap: FC = ({ races, tabsSlot, summarySlot }) => { const isDark = useIsDark(); const strokeColor = isDark @@ -232,13 +233,20 @@ const GeographicMap: FC = ({ races, tabsSlot }) => { ref={containerRef} className="geo-map-container relative h-full w-full" > - {tabsSlot && ( -
- {tabsSlot} + {/* Header overlay: tabs on the left, summary on the right. Absolutely + positioned as one row so a wrapping summary grows over the map instead + of pushing it down, while the gap keeps it clear of the tabs. + The summary occupies the corner the party legend used to; its own + alignment comes from the className the caller passes, since this cell + can't override a class the summary sets on itself. */} + {/* Flush to the container: the map column's own padding provides the inset, + so the summary clears the dividing rule by the same 40px as everything + else. */} +
+ {tabsSlot &&
{tabsSlot}
} +
+ {summarySlot}
- )} -
-
= ({ insight }) => { const t = useTranslations(); const { comment, sourcePost } = insight; + // Comments are fetched per post, so for the Senate / Governor group posts this + // is the umbrella question title rather than the individual state race. + const questionTitle = sourcePost.short_title || sourcePost.title; return ( -
- -

{extractCommentText(comment)}

-
-
+ + {/* Context strip: which question the comment is about. */} +
+ + {/* Wraps to a second line only when the title needs it — short titles + keep the strip to one line. */} + + {questionTitle} + +
+ + {/* The comment is the point of the card — largest type, all the room. + py-3 rather than py-3.5: six lines of 22.75px plus 24px of padding is + what still clears the 270px cap when the title above wraps to two + lines (54px strip + 53px footer), so the last line never gets shaved. */} +
+

+ {extractCommentText(comment)} +

+
+ +
+
+
+ {comment.author.username} +
+
+ {t("midtermsHubMetaculusUser")} +
+
+ {/* A plain arrow, not the open-in-new-window glyph: this navigates in + the current tab, and the external-link icon would promise otherwise. */} + +
+ ); }; function extractCommentText(comment: CommentType): string { - return stripMarkdown(comment.text).slice(0, 320); + return stripMarkdown(comment.text).slice(0, EXCERPT_CHARS); } export default InsightCard; diff --git a/front_end/src/app/(main)/midterms-2026/components/map_legend.tsx b/front_end/src/app/(main)/midterms-2026/components/map_legend.tsx deleted file mode 100644 index 038f8d2ca9..0000000000 --- a/front_end/src/app/(main)/midterms-2026/components/map_legend.tsx +++ /dev/null @@ -1,45 +0,0 @@ -"use client"; - -import { useTranslations } from "next-intl"; -import { FC } from "react"; - -import cn from "@/utils/core/cn"; - -import { MIDTERMS_COLORS } from "../constants"; - -type Props = { - className?: string; -}; - -const MapLegend: FC = ({ className }) => { - const t = useTranslations(); - - return ( -
- - - {t("midtermsHubDemocrat")} - - - - {t("midtermsHubRepublican")} - -
- ); -}; - -export default MapLegend; diff --git a/front_end/src/app/(main)/midterms-2026/components/race_lean_summary.tsx b/front_end/src/app/(main)/midterms-2026/components/race_lean_summary.tsx new file mode 100644 index 0000000000..b8b27e3f17 --- /dev/null +++ b/front_end/src/app/(main)/midterms-2026/components/race_lean_summary.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { FC } from "react"; + +import cn from "@/utils/core/cn"; + +import { MIDTERMS_COLORS } from "../constants"; +import { + SenateRaceWithQuestion, + summarizeRaceLeans, +} from "../helpers/post_utils"; +import { useIsDark } from "../helpers/use_is_dark"; + +type Props = { + races: SenateRaceWithQuestion[]; + /** Appends a toss-up count to the lean sentence. The Senate map uses it: with + * a third of the chamber up, which seats are still in play says more than the + * lean split alone. Omitted on the Governor map, where the count would be + * noise next to 19 mostly-settled races. */ + withCloseRaces?: boolean; + className?: string; +}; + +const RaceLeanSummary: FC = ({ races, withCloseRaces, className }) => { + const t = useTranslations(); + const isDark = useIsDark(); + const leans = summarizeRaceLeans(races); + if (!leans) return null; + + // Name the side ahead in more races; the remainder is implied. A tie resolves + // to the Democrats so the copy stays deterministic across renders. + const demLeads = leans.dem >= leans.rep; + + // Same light/dark pairing the chart strokes and CvBar use: the darker border + // tone reads on the white card, the brighter tone on the navy one. Follows the + // leading party, so this flips to red on its own if forecasts move. + const partyColor = demLeads + ? isDark + ? MIDTERMS_COLORS.demPrimaryDark + : MIDTERMS_COLORS.demBorder + : isDark + ? MIDTERMS_COLORS.repPrimaryDark + : MIDTERMS_COLORS.repBorder; + + return ( +

+ {t.rich("midtermsHubRaceLeanSummary", { + count: demLeads ? leans.dem : leans.rep, + total: leans.total, + party: demLeads ? t("midtermsHubDemocrat") : t("midtermsHubRepublican"), + b: (chunks) => ( + + {chunks} + + ), + })} + {/* The separator lives here rather than in either message so translators + don't inherit punctuation that only makes sense when both clauses are + present. Dropped entirely when nothing is close. */} + {withCloseRaces && leans.close > 0 && ( + <> + + · + + {t("midtermsHubCloseRacesSummary", { count: leans.close })} + + )} +

+ ); +}; + +export default RaceLeanSummary; diff --git a/front_end/src/app/(main)/midterms-2026/components/responsive_map.tsx b/front_end/src/app/(main)/midterms-2026/components/responsive_map.tsx index 552f3a6da3..8dbd51dab9 100644 --- a/front_end/src/app/(main)/midterms-2026/components/responsive_map.tsx +++ b/front_end/src/app/(main)/midterms-2026/components/responsive_map.tsx @@ -4,6 +4,7 @@ import { FC, useState } from "react"; import ChamberTabs, { ChamberView } from "./chamber_tabs"; import GeographicMap from "./geographic_map"; +import RaceLeanSummary from "./race_lean_summary"; import TileMap from "./tile_map"; import { SenateRaceWithQuestion } from "../helpers/post_utils"; @@ -15,6 +16,13 @@ type Props = { const ResponsiveMap: FC = ({ senateRaces, governorRaces }) => { const [view, setView] = useState("senate"); const races = view === "senate" ? senateRaces : governorRaces; + // Built per branch rather than shared: alignment differs between the two maps, + // and RaceLeanSummary sets a text-align class on itself, so only a className + // passed in can override it — a wrapper's class would lose. + const summaryProps = { + races, + withCloseRaces: view === "senate", + }; return ( <> @@ -25,13 +33,23 @@ const ResponsiveMap: FC = ({ senateRaces, governorRaces }) => { } + // Right-aligned: it sits in the corner the party legend used to hold, + // so it wraps leftwards into empty space instead of towards the tabs. + summarySlot={ + + } />
-
+
+ {/* empty:hidden so the gap collapses when the summary renders nothing + — it bails to null before any race is forecast. */} +
+ +
diff --git a/front_end/src/app/(main)/midterms-2026/components/seat_distribution_chart.tsx b/front_end/src/app/(main)/midterms-2026/components/seat_distribution_chart.tsx index d893799970..ab16c8028b 100644 --- a/front_end/src/app/(main)/midterms-2026/components/seat_distribution_chart.tsx +++ b/front_end/src/app/(main)/midterms-2026/components/seat_distribution_chart.tsx @@ -1,7 +1,7 @@ "use client"; import { useTranslations } from "next-intl"; -import { FC, useId, useMemo } from "react"; +import { FC, useCallback, useId, useMemo, useRef, useState } from "react"; import { VictoryArea, VictoryAxis, @@ -9,6 +9,7 @@ import { VictoryChart, VictoryLabel, VictoryLine, + VictoryScatter, VictoryTooltip, VictoryVoronoiContainer, } from "victory"; @@ -81,8 +82,65 @@ const HOVER_BAR_COLOR_DARK = "#E2E8F0"; const HOVER_BAR_WIDTH = 1; const HOVER_BAR_ACTIVE_OPACITY = 0.75; +// Shared flyout geometry for both the hover tooltip and the median callout. +const TOOLTIP_FLYOUT_PADDING = { top: 11, bottom: 10, left: 12, right: 12 }; +const TOOLTIP_CORNER_RADIUS = 6; +const TOOLTIP_POINTER_LENGTH = 10; +const HOVER_FLYOUT_OPACITY = 0.9; +// Per-line line-heights for the hover tooltip's two lines. The second value is +// bumped over the first to open ~2px more gap between them (line 2 is 14px, so +// 1.3 -> 1.44 moves its baseline 18.2px -> 20.2px). +const HOVER_TOOLTIP_LINE_HEIGHTS: [number, number] = [1.3, 1.44]; + +// Median callout. The flyout inverts against the page — black on light, white on +// dark — so the party inks have to invert too: the light page needs the *light* +// party swatch to read on its black flyout, and the dark page needs the darker +// one on white. That's the opposite of every other party color in this file, +// which is why these don't reuse demFill / demStroke. +const MEDIAN_INK_LIGHT = "#000000"; +const MEDIAN_INK_DARK = "#ffffff"; +const MEDIAN_FLYOUT_OPACITY = 0.8; +const MEDIAN_FONT_SIZE = 14; +const MEDIAN_MARKER_SIZE = 4; +const MEDIAN_MARKER_STROKE_WIDTH = 1.5; + type Point = { x: number; y: number }; +/** + * One-line, two-ink tooltip label: "Median:" in the flyout's foreground color + * followed by the seat value in its party color. VictoryLabel's array API only + * stacks lines vertically, so the two runs need to be sibling tspans here. + * VictoryTooltip still sizes its flyout from the concatenated `text` prop it was + * given, so auto-sizing keeps working. + */ +const MedianTooltipLabel: FC<{ + x?: number; + y?: number; + labelText: string; + valueText: string; + labelFill: string; + valueFill: string; +}> = ({ x, y, labelText, valueText, labelFill, valueFill }) => ( + + {/* Non-breaking space so the SVG renderer can't collapse the gap between + the two runs. */} + {`${labelText} `} + + {valueText} + + +); + const SeatDistributionChart: FC = ({ post, cdfOverride, @@ -101,6 +159,32 @@ const SeatDistributionChart: FC = ({ const reactId = useId().replace(/:/g, ""); const fillGradientId = `seat-fill-${reactId}`; const strokeGradientId = `seat-stroke-${reactId}`; + // The median callout is a default-state affordance: it yields the moment a bin + // is hovered so it never stacks with the hover tooltip. + const [isBinHovered, setIsBinHovered] = useState(false); + // Victory's voronoi handleMouseMove calls onActivated(newPoints) and then + // onDeactivated(previousPoints) back-to-back in the same tick whenever the + // active bin changes. Without this flag the paired onDeactivated would + // immediately undo every activation and the callout would never hide. Only the + // genuine clears — pointer out of bounds, or off the container — arrive + // without a preceding activation. + const justActivatedRef = useRef(false); + const handleBinActivated = useCallback((points: unknown[]) => { + const active = points.length > 0; + justActivatedRef.current = active; + setIsBinHovered(active); + }, []); + const handleBinDeactivated = useCallback(() => { + if (justActivatedRef.current) { + justActivatedRef.current = false; + return; + } + setIsBinHovered(false); + }, []); + const handlePointerLeave = useCallback(() => { + justActivatedRef.current = false; + setIsBinHovered(false); + }, []); const question = post.question as QuestionWithNumericForecasts | undefined; @@ -274,6 +358,43 @@ const SeatDistributionChart: FC = ({ const neutralFill = isDark ? EVEN_BAR_FILL_DARK : EVEN_BAR_FILL_LIGHT; const hoverBarColor = isDark ? HOVER_BAR_COLOR_DARK : HOVER_BAR_COLOR_LIGHT; const evenTextColor = isDark ? EVEN_TEXT_COLOR_DARK : EVEN_TEXT_COLOR_LIGHT; + const chartBg = isDark + ? MIDTERMS_COLORS.cardBgDark + : MIDTERMS_COLORS.cardBgLight; + + // Median callout. Both questions are Discrete, so the median lands on an + // integer bin center and the marker snaps to a bar rather than floating + // between two. Negative = Dem advantage, positive = Rep (see data.ts). + const medianInk = isDark ? MEDIAN_INK_DARK : MEDIAN_INK_LIGHT; + const medianLabelFill = isDark ? "#262f38" : "#ffffff"; + const medianSeats = Math.round(quartileXs.median); + // Zero needs its own wording: it belongs to neither party, and without a branch + // it falls through to the Republican string as "R +0 seats" in red. It lands on + // the EVEN bin, which does state a different thing (that bar is + // P(advantage = 0); the median is where the CDF crosses 50%) — the callout is + // shown at every median regardless. + const medianLabelText = t("midtermsHubMedianLabel"); + const medianValueText = + medianSeats === 0 + ? t("midtermsHubMedianEven") + : medianSeats < 0 + ? t("midtermsHubMedianDem", { count: Math.abs(medianSeats) }) + : t("midtermsHubMedianRep", { count: medianSeats }); + // Keyed off the flyout's own background, not the page: the light page's black + // flyout needs the *Dark* swatches (the pair designed to read on dark + // surfaces), and the dark page's white flyout needs the darker Border pair. + // Zero takes the label's own ink — party colors would assert a lean that isn't + // there. + const medianValueFill = + medianSeats === 0 + ? medianLabelFill + : medianSeats < 0 + ? isDark + ? MIDTERMS_COLORS.demBorder + : MIDTERMS_COLORS.demPrimaryDark + : isDark + ? MIDTERMS_COLORS.repBorder + : MIDTERMS_COLORS.repPrimaryDark; const BAR_FILL_OPACITY = 0.7; const BAR_FILL_OPACITY_HOVER = 1; @@ -385,10 +506,14 @@ const SeatDistributionChart: FC = ({ const tooltipComponent = ( } + cornerRadius={TOOLTIP_CORNER_RADIUS} + flyoutPadding={TOOLTIP_FLYOUT_PADDING} + flyoutStyle={{ + fill: tooltipBgFill, + fillOpacity: HOVER_FLYOUT_OPACITY, + stroke: "transparent", + }} + labelComponent={} style={[ { fill: tooltipTextFill, @@ -405,8 +530,40 @@ const SeatDistributionChart: FC = ({ fontVariantNumeric: "tabular-nums", }, ]} - pointerLength={10} + pointerLength={TOOLTIP_POINTER_LENGTH} + constrainToVisibleArea + /> + ); + + const medianTooltipComponent = ( + + } /> ); @@ -444,6 +601,9 @@ const SeatDistributionChart: FC = ({ className="relative w-full" ref={containerRef} aria-label={ariaTitle} + // Safety net: Victory's onDeactivated doesn't reliably fire when the + // pointer leaves the plot quickly, which would strand the median hidden. + onMouseLeave={handlePointerLeave} role="img" > {/* Hard-edge linear gradient that snaps from dem fill to rep fill at @@ -491,10 +651,17 @@ const SeatDistributionChart: FC = ({ labels={formatTooltipLabel} labelComponent={tooltipComponent} mouseFollowTooltips={false} + onActivated={handleBinActivated} + onDeactivated={handleBinDeactivated} voronoiBlacklist={ isDiscrete - ? ["even-connector", "oob-divider-l", "oob-divider-r"] - : ["area", "q-l", "q-m", "q-u"] + ? [ + "median-marker", + "even-connector", + "oob-divider-l", + "oob-divider-r", + ] + : ["median-marker", "area", "q-l", "q-m", "q-u"] } // Let the page scroll vertically through the chart on touch; the // chart only consumes horizontal moves (for the tooltip). Mirrors @@ -707,6 +874,29 @@ const SeatDistributionChart: FC = ({ /> )} + {/* Median marker — a dot on the x-axis at the median bin carrying an + always-on flyout. Rendered last so it paints above the bars, and + unmounted entirely while a bin is hovered so it never stacks with + the hover tooltip. */} + {!isBinHovered && ( + `${medianLabelText} ${medianValueText}`} + labelComponent={medianTooltipComponent} + /> + )} + {/* Chart title (SENATE / HOUSE) — rendered inside the SVG (not as an HTML overlay) so the voronoi tooltip paints above it. */} = ({ races }) => { /> )} - -
); }; diff --git a/front_end/src/app/(main)/midterms-2026/components/timeline_range_toggle.tsx b/front_end/src/app/(main)/midterms-2026/components/timeline_range_toggle.tsx new file mode 100644 index 0000000000..e522e57f7a --- /dev/null +++ b/front_end/src/app/(main)/midterms-2026/components/timeline_range_toggle.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { FC } from "react"; + +import { TimelineChartZoomOption } from "@/types/charts"; +import { getChartZoomOptions } from "@/utils/charts/helpers"; +import cn from "@/utils/core/cn"; + +type Props = { + value: TimelineChartZoomOption; + onChange: (value: TimelineChartZoomOption) => void; + className?: string; +}; + +/** + * Timeframe picker for the balance-of-power timelines. Same options and values as + * the zoom picker built into ChartContainer — sourced from the same + * `getChartZoomOptions` so the two can't drift — but fully rounded and hoisted + * out of the chart so a single control drives all three at once. + */ +const TimelineRangeToggle: FC = ({ value, onChange, className }) => { + const options = getChartZoomOptions(); + + return ( +
+ {options.map((option) => { + const active = option.value === value; + return ( + + ); + })} +
+ ); +}; + +export default TimelineRangeToggle; diff --git a/front_end/src/app/(main)/midterms-2026/helpers/build_control_timelines.ts b/front_end/src/app/(main)/midterms-2026/helpers/build_control_timelines.ts new file mode 100644 index 0000000000..9b5977b36f --- /dev/null +++ b/front_end/src/app/(main)/midterms-2026/helpers/build_control_timelines.ts @@ -0,0 +1,167 @@ +import { ChoiceItem } from "@/types/choices"; +import { + MultipleChoiceAggregateForecast, + QuestionWithMultipleChoiceForecasts, +} from "@/types/question"; +import { ThemeColor } from "@/types/theme"; + +export type ControlSeriesConfig = { + /** Option labels whose probabilities are summed into this one series. */ + optionLabels: string[]; + /** Legend / tooltip label. */ + label: string; + color: ThemeColor; +}; + +export type ControlTimeline = { + timestamps: number[]; + choiceItems: ChoiceItem[]; +}; + +/** + * Builds a community-prediction timeline from a multiple-choice question, + * summing one or more options into each series. + * + * Deliberately not built on `generateChoiceItemsFromMultipleChoiceForecast`: + * that routes through `getAllOptionsHistory`, which returns only the *last* + * entry of `question.options` when `options_history` is absent — so it can + * silently collapse a four-option question to a single series. Summing has no + * equivalent there either; the closest thing, `buildChoicesWithOthers`, only + * folds *inactive* options into a gray bucket. + * + * Option indices are matched by label, case-insensitively — the same convention + * `getMultipleChoiceOptionProbability` uses — so these are the same sums the + * snapshot cards display and the two views cannot drift apart. + * + * Client-only: the timestamp grid compares `end_time` against the current + * clock, so building this during SSR and again on the client would risk a + * hydration mismatch. + */ +export function buildControlTimeline( + question: QuestionWithMultipleChoiceForecasts | null | undefined, + series: ControlSeriesConfig[] +): ControlTimeline | null { + if (!question || !series.length) return null; + + const history = + question.aggregations?.[question.default_aggregation_method]?.history; + if (!history?.length) return null; + + const options = question.options ?? []; + const seriesIndices = series.map((s) => + s.optionLabels.map((label) => + options.findIndex((o) => o.toLowerCase() === label.toLowerCase()) + ) + ); + // Fail closed, like the snapshot cards do: if an option label no longer + // matches, we can't reproduce its number, and a silently understated line is + // worse than no chart. + if (seriesIndices.some((group) => group.some((i) => i < 0))) return null; + + const timestamps = collectTimestamps(history); + // A single point can't draw a trend. + if (timestamps.length < 2) return null; + + // Resolved once and shared by every series, rather than rescanning `history` + // for each timestamp of each series. + const coverage = mapTimestampsToForecasts(timestamps, history); + + const choiceItems: ChoiceItem[] = series.map((config, i) => { + const optionIndices = seriesIndices[i] ?? []; + const aggregationValues = coverage.map((entry) => + sumCenters(entry, optionIndices) + ); + const aggregationForecasterCounts = coverage.map( + (entry) => entry?.forecaster_count ?? 0 + ); + + return { + choice: config.label, + label: config.label, + color: config.color, + highlighted: false, + active: true, + resolution: null, + aggregationTimestamps: timestamps, + aggregationValues, + // No interval band: the CI around a sum of options is not the sum of + // their CIs, so synthesizing one would be a fabrication. MultipleChoiceChart + // never reads these anyway — its y1/y2 come from its own stacking pass. + aggregationMinValues: timestamps.map(() => null), + aggregationMaxValues: timestamps.map(() => null), + aggregationForecasterCounts, + userTimestamps: [], + userValues: [], + }; + }); + + return { timestamps, choiceItems }; +} + +/** + * Sorted, de-duplicated grid of every point where the aggregation changed. + * `end_time` only counts once it's in the past, matching how the shared + * `collectSortedTimestamps` treats still-open windows. + */ +function collectTimestamps( + history: MultipleChoiceAggregateForecast[] +): number[] { + const nowSeconds = Date.now() / 1000; + const unique = new Set(); + for (const forecast of history) { + unique.add(forecast.start_time); + if (forecast.end_time != null && forecast.end_time <= nowSeconds) { + unique.add(forecast.end_time); + } + } + return [...unique].sort((a, b) => a - b); +} + +/** + * The forecast window covering each timestamp, resolved in a single pass. + * + * Both sequences ascend — `timestamps` by construction, `history` sorted here — + * so one monotonic cursor replaces a scan per timestamp per series. Assumes + * non-overlapping aggregation windows, which is how CP history is built; were + * they to overlap, this picks the latest-starting match rather than the first. + * A timestamp no window covers maps to `undefined`, which `sumCenters` turns + * into a null and the chart renders as a gap. + */ +function mapTimestampsToForecasts( + timestamps: number[], + history: MultipleChoiceAggregateForecast[] +): Array { + const sorted = [...history].sort((a, b) => a.start_time - b.start_time); + let cursor = 0; + + return timestamps.map((timestamp) => { + while ( + cursor + 1 < sorted.length && + (sorted[cursor + 1]?.start_time ?? Infinity) <= timestamp + ) { + cursor++; + } + const candidate = sorted[cursor]; + const covers = + !!candidate && + candidate.start_time <= timestamp && + (candidate.end_time === null || candidate.end_time > timestamp); + return covers ? candidate : undefined; + }); +} + +/** Null if any contributing option is missing, so a gap stays a gap. */ +function sumCenters( + entry: MultipleChoiceAggregateForecast | undefined, + optionIndices: number[] +): number | null { + const centers = entry?.centers; + if (!centers) return null; + let total = 0; + for (const index of optionIndices) { + const value = centers[index]; + if (value == null) return null; + total += value; + } + return Number(total.toFixed(6)); +} diff --git a/front_end/src/app/(main)/midterms-2026/helpers/post_utils.ts b/front_end/src/app/(main)/midterms-2026/helpers/post_utils.ts index 88c83a9ec3..cc3201849a 100644 --- a/front_end/src/app/(main)/midterms-2026/helpers/post_utils.ts +++ b/front_end/src/app/(main)/midterms-2026/helpers/post_utils.ts @@ -94,3 +94,33 @@ export function getDemWinPct( if (prob == null) return null; return Math.round(prob * 100); } + +/** Half-width of the toss-up band, in points either side of 50. */ +const CLOSE_RACE_MARGIN = 10; + +/** + * Counts races by favored side, plus how many are close enough to be in play. + * The `>= 50` split matches the one `state_tooltip.tsx` uses, so a summary can + * never disagree with a tooltip. Races with no forecast are excluded from every + * count, and `total` is the sum of the two sides rather than `races.length` so + * the numbers always reconcile. Null when nothing is forecast yet. + * + * `close` deliberately overlaps `dem` and `rep` rather than partitioning them: a + * toss-up still leans one way, and pulling it out of its side's count would make + * the two figures contradict each other when read in one sentence. + */ +export function summarizeRaceLeans( + races: SenateRaceWithQuestion[] +): { dem: number; rep: number; close: number; total: number } | null { + let dem = 0; + let rep = 0; + let close = 0; + for (const race of races) { + if (race.demWinPct == null) continue; + if (race.demWinPct >= 50) dem += 1; + else rep += 1; + if (Math.abs(race.demWinPct - 50) <= CLOSE_RACE_MARGIN) close += 1; + } + const total = dem + rep; + return total > 0 ? { dem, rep, close, total } : null; +} diff --git a/front_end/src/app/(main)/midterms-2026/page.tsx b/front_end/src/app/(main)/midterms-2026/page.tsx index 4259e25aaf..5e5f362ed5 100644 --- a/front_end/src/app/(main)/midterms-2026/page.tsx +++ b/front_end/src/app/(main)/midterms-2026/page.tsx @@ -6,6 +6,7 @@ import { getPublicSettings } from "@/utils/public_settings.server"; import CommunityInsightsSection from "./sections/community_insights"; import ElectionsMapSection from "./sections/elections_map_section"; import ElectoralConsequencesSection from "./sections/electoral_consequences"; +import EngagementSection from "./sections/engagement_section"; import FooterSection from "./sections/footer"; import HeroSection from "./sections/hero"; import SeatDistributionsSection from "./sections/seat_distributions"; @@ -47,6 +48,7 @@ export default function MidtermsHubPage() { +
diff --git a/front_end/src/app/(main)/midterms-2026/sections/elections_map_section.tsx b/front_end/src/app/(main)/midterms-2026/sections/elections_map_section.tsx index 9776005efd..09ebcfab15 100644 --- a/front_end/src/app/(main)/midterms-2026/sections/elections_map_section.tsx +++ b/front_end/src/app/(main)/midterms-2026/sections/elections_map_section.tsx @@ -1,7 +1,6 @@ import { SectionCard } from "@/app/(main)/labor-hub/components/section"; -import ChamberControlCard from "../components/chamber_control_card"; -import CongressOutcomeCard from "../components/congress_outcome_card"; +import BalanceOfPowerTimelines from "../components/balance_of_power_timelines"; import ResponsiveMap from "../components/responsive_map"; import { fetchChamberData, @@ -17,22 +16,28 @@ export default async function ElectionsMapSection() { fetchChamberData(), ]); + // The card carries no overflow clip: that only ever existed to contain the + // geographic map's paths, and on the card it also cut off the balance-of-power + // panel's range toggle, which is meant to straddle the top edge. The clip now + // sits on the map column alone. return ( - -
- {/* Map column: edge-to-edge of the SectionCard on md+. */} -
+ +
+ {/* Map column. Padding matches the panel's, so the map is inset the same + 40px from the dividing rule as the panel's content is — and the tabs, + summary and legend overlay inherit it instead of setting their own. */} +
- {/* Sidebar column: capped at 30% of the container with its own - internal padding so the cards stay inset from the white card - edges. */} -
- - + {/* Balance-of-power column. One rule divides it from the map and runs the + full height — grid items stretch, so it meets both card edges. Below + md the layout is a single column, so the same rule becomes the + horizontal separator above the panel instead. */} +
+
diff --git a/front_end/src/app/(main)/midterms-2026/sections/engagement_section.tsx b/front_end/src/app/(main)/midterms-2026/sections/engagement_section.tsx new file mode 100644 index 0000000000..a3bee3ec16 --- /dev/null +++ b/front_end/src/app/(main)/midterms-2026/sections/engagement_section.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { faXTwitter } from "@fortawesome/free-brands-svg-icons"; +import { faCommentDots, faCopy } from "@fortawesome/free-regular-svg-icons"; +import { faChartBar } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import Link from "next/link"; +import { useTranslations } from "next-intl"; + +import { NewsletterSubscribeCard } from "@/app/(main)/labor-hub/components/newsletter_subscribe_card"; +import { useModal } from "@/contexts/modal_context"; +import { useCopyUrl, useShareOnTwitterLink } from "@/hooks/share"; + +// Mailjet contact list key for this hub — resolved in app/(main)/actions.ts. +const NEWSLETTER_LIST_KEY = "midterms"; + +function PartnerCard() { + const t = useTranslations(); + const { setCurrentModal } = useModal(); + + return ( +
+
+
+ +

+ {t("midtermsHubEngagementReachTitle")} +

+
+

+ {t("midtermsHubEngagementReachBody")} +

+
+
+ + {t("midtermsHubEngagementViewServices")} + + +
+
+ ); +} + +function ShareCard() { + const t = useTranslations(); + const copyUrl = useCopyUrl({ + includeHash: false, + successMessage: t("midtermsHubEngagementCopySuccess"), + errorMessage: t("midtermsHubEngagementCopyError"), + }); + const shareOnTwitterLink = useShareOnTwitterLink( + t("midtermsHubEngagementTweetText"), + { includeHash: false } + ); + + return ( +
+
+
+ +

+ {t("midtermsHubEngagementShareTitle")} +

+
+

+ {t("midtermsHubEngagementShareBody")} +

+
+ +
+ ); +} + +export default function EngagementSection() { + const t = useTranslations(); + + return ( +
+ + + +
+ ); +} diff --git a/front_end/src/components/charts/group_chart.tsx b/front_end/src/components/charts/group_chart.tsx index 6c1ff00901..1bc5d4fa24 100644 --- a/front_end/src/components/charts/group_chart.tsx +++ b/front_end/src/components/charts/group_chart.tsx @@ -16,6 +16,7 @@ import { DomainTuple, LineSegment, PointProps, + Tuple, VictoryArea, VictoryAxis, VictoryChart, @@ -85,6 +86,10 @@ type Props = { actualCloseTime?: number | null; choiceItems: ChoiceItem[]; defaultZoom?: TimelineChartZoomOption; + /** Controlled zoom. Omit to let the chart own it via `defaultZoom`. Lets one + * external picker drive several charts at once. */ + zoom?: TimelineChartZoomOption; + onZoomChange?: (zoom: TimelineChartZoomOption) => void; withZoomPicker?: boolean; height?: number; yLabel?: string; @@ -102,6 +107,13 @@ type Props = { cursorTimestamp?: number | null; forecastAvailability?: ForecastAvailability; forceShowLinePoints?: boolean; + /** Hide the y-axis tick labels and reclaim the space they reserve. Gridlines + * stay. For compact charts that surface their values elsewhere. */ + hideYAxis?: boolean; + /** Hide the x-axis tick labels permanently, not just while the cursor is + * active. Pair with `showCursorLabel` so the hovered date is the only x value + * ever shown. */ + hideXAxis?: boolean; forFeedPage?: boolean; isEmbedded?: boolean; chartTitle?: ReactNode; @@ -118,9 +130,22 @@ type Props = { withHighlightEndpoint?: boolean; headerLeft?: ReactNode; yDomainOptions?: TimelineYDomainOptions; + /** Let binary questions use the computed zoomed y-domain instead of the fixed + * [0, 1]. Off by default: platform-wide, a binary timeline is read against a + * full probability axis, and shrinking it silently would overstate movement. + * Opt in where the values are surfaced numerically elsewhere. */ + binaryYZoom?: boolean; + /** Floor on the zoomed domain's height, in probability units. Guards the + * degenerate case: `generateYDomain` falls back to a ±1pp window when the + * visible span is zero, which would draw sampling noise as a swing. */ + minYSpan?: number; }; const BOTTOM_PADDING = 20; +// Endpoint-dot clearance used in place of the y-label allowance when hideYAxis is +// set — matches the SCATTER_POINT_PADDING that getAxisRightPadding adds for the +// same dot, plus a pixel for its stroke. +const HIDDEN_Y_AXIS_RIGHT_PADDING = 6; const POINT_SIZE = 9; const USER_POINT_SIZE = 6; const USER_POINT_STROKE = CHART_STROKE_WIDTH.userPoint; @@ -131,6 +156,8 @@ const GroupChart: FC = ({ actualCloseTime, choiceItems, defaultZoom = TimelineChartZoomOption.All, + zoom: controlledZoom, + onZoomChange, withZoomPicker = false, height = 150, yLabel, @@ -148,6 +175,8 @@ const GroupChart: FC = ({ cursorTimestamp, forecastAvailability, forceShowLinePoints = false, + hideYAxis = false, + hideXAxis = false, forFeedPage, isEmbedded = false, chartTitle, @@ -164,6 +193,8 @@ const GroupChart: FC = ({ withHighlightEndpoint = false, headerLeft, yDomainOptions, + binaryYZoom = false, + minYSpan, }) => { const t = useTranslations(); const { @@ -191,7 +222,24 @@ const GroupChart: FC = ({ ); const [isCursorActive, setIsCursorActive] = useState(false); - const [zoom, setZoom] = useState(defaultZoom); + // Controlled/uncontrolled: an external picker can own the zoom, otherwise the + // chart keeps its own. + const [internalZoom, setInternalZoom] = + useState(defaultZoom); + // Mirror the controlled value so nothing jumps if a caller stops controlling + // mid-life. Without this, internal state only saw changes made through the + // chart's own picker, so a parent that drives zoom from elsewhere would leave + // it at `defaultZoom` and the chart would snap back to that on release. + useEffect(() => { + if (!isNil(controlledZoom)) { + setInternalZoom(controlledZoom); + } + }, [controlledZoom]); + const zoom = controlledZoom ?? internalZoom; + const handleZoomChange = (next: TimelineChartZoomOption) => { + setInternalZoom(next); + onZoomChange?.(next); + }; const [yDomainSource, setYDomainSource] = useState( yDomainOptions?.source ?? DEFAULT_TIMELINE_Y_DOMAIN_OPTIONS.source ); @@ -223,6 +271,8 @@ const GroupChart: FC = ({ ...yDomainOptions, source: yDomainSource, }, + binaryYZoom, + minYSpan, }), [ timestamps, @@ -241,6 +291,8 @@ const GroupChart: FC = ({ forFeedPage, yDomainOptions, yDomainSource, + binaryYZoom, + minYSpan, ] ); const [localCursorTimestamp, setLocalCursorTimestamp] = useState< @@ -279,8 +331,13 @@ const GroupChart: FC = ({ return getAxisRightPadding(yScale, tickLabelFontSize as number, yLabel); }, [yScale, tickLabelFontSize, yLabel]); const maxRightPadding = useMemo(() => { + // MIN_RIGHT_PADDING (35px) exists to fit y-axis tick labels. With them hidden + // that space is dead, and it reads as lopsided padding against a container + // whose own padding is symmetric. Keep only what the line's endpoint dot + // needs to sit fully inside the plot. + if (hideYAxis) return HIDDEN_Y_AXIS_RIGHT_PADDING; return Math.max(rightPadding, MIN_RIGHT_PADDING); - }, [rightPadding, MIN_RIGHT_PADDING]); + }, [hideYAxis, rightPadding, MIN_RIGHT_PADDING]); const chartPadding = useMemo( () => ({ left: leftPadding, @@ -387,7 +444,7 @@ const GroupChart: FC = ({ ref={chartContainerRef} height={height} zoom={withZoomPicker ? zoom : undefined} - onZoomChange={setZoom} + onZoomChange={handleZoomChange} yDomainSource={ withZoomPicker && questionType !== QuestionType.Binary ? yDomainSource @@ -597,7 +654,9 @@ const GroupChart: FC = ({ 4, textAnchor: "end", fontSize: tickLabelFontSize, - fill: getThemeColor(METAC_COLORS.gray["700"]), + fill: hideYAxis + ? "transparent" + : getThemeColor(METAC_COLORS.gray["700"]), }, axis: { stroke: "transparent", @@ -621,7 +680,7 @@ const GroupChart: FC = ({ "" : xScale.tickFormat } @@ -959,6 +1018,8 @@ function buildChartData({ forFeedPage, isEmbedded, yDomainOptions, + binaryYZoom, + minYSpan, }: { timestamps: number[]; actualCloseTime?: number | null; @@ -976,6 +1037,8 @@ function buildChartData({ forFeedPage?: boolean; isEmbedded?: boolean; yDomainOptions?: TimelineYDomainOptions; + binaryYZoom?: boolean; + minYSpan?: number; }): ChartData { const closeTimes = choiceItems .map(({ closeTime }) => closeTime) @@ -1305,10 +1368,12 @@ function buildChartData({ paddingRatio: effectiveYDomainOptions.paddingRatio, }); const { originalYDomain, tickCoverageDomain } = generatedYDomain; + // Binary questions default to the full [0, 1] axis regardless of how little the + // forecast moves; `binaryYZoom` opts a chart out of that. const zoomedYDomain = - questionType === QuestionType.Binary + questionType === QuestionType.Binary && !binaryYZoom ? originalYDomain - : generatedYDomain.zoomedYDomain; + : expandDomainToMinSpan(generatedYDomain.zoomedYDomain, minYSpan); const yScale = generateScale({ displayType: questionType, @@ -1328,6 +1393,39 @@ function buildChartData({ return { xScale, yScale: visibleYScale, graphs, xDomain, yDomain }; } +/** + * Widens a y-domain about its midpoint until it spans at least `minSpan`, so a + * barely-moving series can't zoom in far enough to render noise as movement. + * A no-op without `minSpan`, which is how every caller that doesn't opt in + * leaves it. + * + * When the widened window would run past 0 or 1 it slides back inside rather than + * being clipped: a clipped domain would still be shorter than `minSpan` and so + * still over-zoomed, which is the thing being guarded against. + */ +function expandDomainToMinSpan( + domain: Tuple, + minSpan?: number +): Tuple { + if (isNil(minSpan) || minSpan <= 0) return domain; + const [min, max] = domain; + if (max - min >= minSpan) return domain; + + const target = Math.min(minSpan, 1); + const mid = (min + max) / 2; + let lower = mid - target / 2; + let upper = mid + target / 2; + if (lower < 0) { + upper -= lower; + lower = 0; + } + if (upper > 1) { + lower -= upper - 1; + upper = 1; + } + return [Math.max(0, lower), Math.min(1, upper)]; +} + // Define a custom "X" symbol function type SymbolProps = PointProps & { size?: number; strokeWidth?: number }; const PredictionSymbol: React.FC = (props) => { diff --git a/front_end/src/hooks/share.ts b/front_end/src/hooks/share.ts index 5a836ac208..63a31f7cdb 100644 --- a/front_end/src/hooks/share.ts +++ b/front_end/src/hooks/share.ts @@ -66,22 +66,52 @@ const useCurrentUrl = ({ includeHash = true }: CurrentUrlOptions = {}) => { }, [PUBLIC_APP_URL, hash, includeHash, pathname, searchParams]); }; -export const useCopyUrl = (options: CurrentUrlOptions = {}) => { - const url = useCurrentUrl(options); +type CopyUrlOptions = CurrentUrlOptions & { + /** Localized success toast. Defaults to the original English string. */ + successMessage?: string; + /** Localized failure toast. Omit to keep the log-only behavior. */ + errorMessage?: string; +}; + +export const useCopyUrl = ({ + successMessage = "URL is now copied to your clipboard", + errorMessage, + ...urlOptions +}: CopyUrlOptions = {}) => { + const url = useCurrentUrl(urlOptions); return useCallback(() => { - if (url) { - navigator.clipboard - .writeText(url) - .then(() => { - toast("URL is now copied to your clipboard", { - className: "dark:bg-blue-700-dark dark:text-gray-0-dark", - }); - // Optionally, show a notification to the user that the link was copied. - }) - .catch((err) => console.error("Error copying link: ", err)); + if (!url) return; + + // `navigator.clipboard` is undefined outside a secure context — plain http + // on a LAN address, for instance — so reading `.writeText` off it throws + // synchronously, where the promise's catch below cannot see it and the + // click ends in an uncaught error instead of a toast. + if (!navigator.clipboard?.writeText) { + console.error("Error copying link: clipboard unavailable"); + if (errorMessage) { + toast.error(errorMessage); + } + return; } - }, [url]); + + navigator.clipboard + .writeText(url) + .then(() => { + toast(successMessage, { + className: "dark:bg-blue-700-dark dark:text-gray-0-dark", + }); + }) + .catch((err) => { + console.error("Error copying link: ", err); + // Clipboard writes fail for real reasons — denied permission, a + // non-secure context — and a click that silently does nothing reads as a + // broken button. Callers that pass a message get told. + if (errorMessage) { + toast.error(errorMessage); + } + }); + }, [url, successMessage, errorMessage]); }; export const useMetaImageUrl = (tagName: string) => {