Complete public brief locale switching

This commit is contained in:
2569718930@qq.com
2026-06-24 18:53:22 +08:00
parent 3439e0e47a
commit 578f547fa7
10 changed files with 477 additions and 112 deletions
+42 -9
View File
@@ -1,16 +1,37 @@
import type { Metadata } from "next";
import { cookies, headers } from "next/headers";
import { notFound } from "next/navigation";
import { MethodologyDetailPageView } from "@/components/public-content/PublicContentPages";
import {
LANDING_LOCALE_COOKIE,
LANDING_LOCALE_QUERY_PARAM,
pickLandingLocale,
type LandingLocale,
} from "@/components/landing/landingLocale";
import {
METHODOLOGY_PAGES,
absolutePublicUrl,
getMethodologyPage,
localizeMethodologyPage,
methodologyPath,
} from "@/content/public-content";
type MethodologyPageParams = {
slug: string;
};
type MethodologySearchParams = Promise<Record<string, string | string[] | undefined>>;
async function resolvePublicContentLocale(searchParams: MethodologySearchParams): Promise<LandingLocale> {
const params = await searchParams;
const rawLocale = params[LANDING_LOCALE_QUERY_PARAM];
const queryLocale = Array.isArray(rawLocale) ? rawLocale[0] : rawLocale;
const [cookieStore, headerStore] = await Promise.all([cookies(), headers()]);
return pickLandingLocale(
queryLocale,
cookieStore.get(LANDING_LOCALE_COOKIE)?.value,
headerStore.get("accept-language"),
);
}
export function generateStaticParams() {
return METHODOLOGY_PAGES.map((page) => ({ slug: page.slug }));
@@ -18,10 +39,15 @@ export function generateStaticParams() {
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<MethodologyPageParams>;
searchParams: MethodologySearchParams;
}): Promise<Metadata> {
const { slug } = await params;
const [{ slug }, locale] = await Promise.all([
params,
resolvePublicContentLocale(searchParams),
]);
const page = getMethodologyPage(slug);
if (!page) {
@@ -29,17 +55,18 @@ export async function generateMetadata({
title: "Methodology not found",
};
}
const localizedPage = localizeMethodologyPage(page, locale);
return {
title: page.title,
description: page.description,
title: localizedPage.title,
description: localizedPage.description,
alternates: {
canonical: methodologyPath(page),
},
openGraph: {
type: "article",
title: page.title,
description: page.description,
title: localizedPage.title,
description: localizedPage.description,
url: methodologyPath(page),
modifiedTime: page.updatedAt,
},
@@ -48,22 +75,28 @@ export async function generateMetadata({
export default async function MethodologyDetailPage({
params,
searchParams,
}: {
params: Promise<MethodologyPageParams>;
searchParams: MethodologySearchParams;
}) {
const { slug } = await params;
const [{ slug }, locale] = await Promise.all([
params,
resolvePublicContentLocale(searchParams),
]);
const page = getMethodologyPage(slug);
if (!page) {
notFound();
}
const localizedPage = localizeMethodologyPage(page, locale);
const pathname = methodologyPath(page);
const jsonLd = {
"@context": "https://schema.org",
"@type": "TechArticle",
headline: page.title,
description: page.description,
headline: localizedPage.title,
description: localizedPage.description,
dateModified: page.updatedAt,
mainEntityOfPage: absolutePublicUrl(pathname),
author: {
@@ -79,7 +112,7 @@ export default async function MethodologyDetailPage({
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<MethodologyDetailPageView page={page} />
<MethodologyDetailPageView page={page} locale={locale} />
</>
);
}
+49 -16
View File
@@ -1,21 +1,54 @@
import type { Metadata } from "next";
import { cookies, headers } from "next/headers";
import { MethodologyIndexPageView } from "@/components/public-content/PublicContentPages";
import {
LANDING_LOCALE_COOKIE,
LANDING_LOCALE_QUERY_PARAM,
pickLandingLocale,
type LandingLocale,
} from "@/components/landing/landingLocale";
import { PUBLIC_CONTENT_COPY } from "@/content/public-content";
export const metadata: Metadata = {
title: "Methodology",
description:
"PolyWeather public methodology for DEB forecast blending, settlement-source priority, freshness, and market weather analysis.",
alternates: {
canonical: "/methodology",
},
openGraph: {
title: "Methodology | PolyWeather",
description:
"Public methodology for DEB forecast blending, settlement-source priority, freshness, and market weather analysis.",
url: "/methodology",
},
};
type MethodologySearchParams = Promise<Record<string, string | string[] | undefined>>;
export default function MethodologyPage() {
return <MethodologyIndexPageView />;
async function resolvePublicContentLocale(searchParams: MethodologySearchParams): Promise<LandingLocale> {
const params = await searchParams;
const rawLocale = params[LANDING_LOCALE_QUERY_PARAM];
const queryLocale = Array.isArray(rawLocale) ? rawLocale[0] : rawLocale;
const [cookieStore, headerStore] = await Promise.all([cookies(), headers()]);
return pickLandingLocale(
queryLocale,
cookieStore.get(LANDING_LOCALE_COOKIE)?.value,
headerStore.get("accept-language"),
);
}
export async function generateMetadata({
searchParams,
}: {
searchParams: MethodologySearchParams;
}): Promise<Metadata> {
const locale = await resolvePublicContentLocale(searchParams);
const copy = PUBLIC_CONTENT_COPY[locale];
return {
title: copy.methodologyIndexEyebrow,
description: copy.methodologyIndexDescription,
alternates: {
canonical: "/methodology",
},
openGraph: {
title: `${copy.methodologyIndexEyebrow} | PolyWeather`,
description: copy.methodologyIndexDescription,
url: "/methodology",
},
};
}
export default async function MethodologyPage({
searchParams,
}: {
searchParams: MethodologySearchParams;
}) {
const locale = await resolvePublicContentLocale(searchParams);
return <MethodologyIndexPageView locale={locale} />;
}
+42 -9
View File
@@ -1,16 +1,37 @@
import type { Metadata } from "next";
import { cookies, headers } from "next/headers";
import { notFound } from "next/navigation";
import { SourceDetailPageView } from "@/components/public-content/PublicContentPages";
import {
LANDING_LOCALE_COOKIE,
LANDING_LOCALE_QUERY_PARAM,
pickLandingLocale,
type LandingLocale,
} from "@/components/landing/landingLocale";
import {
SOURCE_PAGES,
absolutePublicUrl,
getSourcePage,
localizeSourcePage,
sourcePath,
} from "@/content/public-content";
type SourcePageParams = {
slug: string;
};
type SourceSearchParams = Promise<Record<string, string | string[] | undefined>>;
async function resolvePublicContentLocale(searchParams: SourceSearchParams): Promise<LandingLocale> {
const params = await searchParams;
const rawLocale = params[LANDING_LOCALE_QUERY_PARAM];
const queryLocale = Array.isArray(rawLocale) ? rawLocale[0] : rawLocale;
const [cookieStore, headerStore] = await Promise.all([cookies(), headers()]);
return pickLandingLocale(
queryLocale,
cookieStore.get(LANDING_LOCALE_COOKIE)?.value,
headerStore.get("accept-language"),
);
}
export function generateStaticParams() {
return SOURCE_PAGES.map((source) => ({ slug: source.slug }));
@@ -18,10 +39,15 @@ export function generateStaticParams() {
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<SourcePageParams>;
searchParams: SourceSearchParams;
}): Promise<Metadata> {
const { slug } = await params;
const [{ slug }, locale] = await Promise.all([
params,
resolvePublicContentLocale(searchParams),
]);
const source = getSourcePage(slug);
if (!source) {
@@ -29,17 +55,18 @@ export async function generateMetadata({
title: "Source not found",
};
}
const localizedSource = localizeSourcePage(source, locale);
return {
title: source.title,
description: source.description,
title: localizedSource.title,
description: localizedSource.description,
alternates: {
canonical: sourcePath(source),
},
openGraph: {
type: "article",
title: source.title,
description: source.description,
title: localizedSource.title,
description: localizedSource.description,
url: sourcePath(source),
modifiedTime: source.updatedAt,
},
@@ -48,22 +75,28 @@ export async function generateMetadata({
export default async function SourceDetailPage({
params,
searchParams,
}: {
params: Promise<SourcePageParams>;
searchParams: SourceSearchParams;
}) {
const { slug } = await params;
const [{ slug }, locale] = await Promise.all([
params,
resolvePublicContentLocale(searchParams),
]);
const source = getSourcePage(slug);
if (!source) {
notFound();
}
const localizedSource = localizeSourcePage(source, locale);
const pathname = sourcePath(source);
const jsonLd = {
"@context": "https://schema.org",
"@type": "Dataset",
name: source.title,
description: source.description,
name: localizedSource.title,
description: localizedSource.description,
url: absolutePublicUrl(pathname),
dateModified: source.updatedAt,
creator: {
@@ -83,7 +116,7 @@ export default async function SourceDetailPage({
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<SourceDetailPageView source={source} />
<SourceDetailPageView source={source} locale={locale} />
</>
);
}
+49 -16
View File
@@ -1,21 +1,54 @@
import type { Metadata } from "next";
import { cookies, headers } from "next/headers";
import { SourcesIndexPageView } from "@/components/public-content/PublicContentPages";
import {
LANDING_LOCALE_COOKIE,
LANDING_LOCALE_QUERY_PARAM,
pickLandingLocale,
type LandingLocale,
} from "@/components/landing/landingLocale";
import { PUBLIC_CONTENT_COPY } from "@/content/public-content";
export const metadata: Metadata = {
title: "Weather Sources",
description:
"PolyWeather public source notes for MGM, METAR, HKO, NOAA, ECMWF, and settlement-source weather analysis.",
alternates: {
canonical: "/sources",
},
openGraph: {
title: "Weather Sources | PolyWeather",
description:
"Public source notes for MGM, METAR, HKO, NOAA, ECMWF, and settlement-source weather analysis.",
url: "/sources",
},
};
type SourcesSearchParams = Promise<Record<string, string | string[] | undefined>>;
export default function SourcesPage() {
return <SourcesIndexPageView />;
async function resolvePublicContentLocale(searchParams: SourcesSearchParams): Promise<LandingLocale> {
const params = await searchParams;
const rawLocale = params[LANDING_LOCALE_QUERY_PARAM];
const queryLocale = Array.isArray(rawLocale) ? rawLocale[0] : rawLocale;
const [cookieStore, headerStore] = await Promise.all([cookies(), headers()]);
return pickLandingLocale(
queryLocale,
cookieStore.get(LANDING_LOCALE_COOKIE)?.value,
headerStore.get("accept-language"),
);
}
export async function generateMetadata({
searchParams,
}: {
searchParams: SourcesSearchParams;
}): Promise<Metadata> {
const locale = await resolvePublicContentLocale(searchParams);
const copy = PUBLIC_CONTENT_COPY[locale];
return {
title: copy.sourceIndexEyebrow,
description: copy.sourceIndexDescription,
alternates: {
canonical: "/sources",
},
openGraph: {
title: `${copy.sourceIndexEyebrow} | PolyWeather`,
description: copy.sourceIndexDescription,
url: "/sources",
},
};
}
export default async function SourcesPage({
searchParams,
}: {
searchParams: SourcesSearchParams;
}) {
const locale = await resolvePublicContentLocale(searchParams);
return <SourcesIndexPageView locale={locale} />;
}
@@ -7,6 +7,7 @@ import {
} from "@/components/landing/LandingAuthActions";
import {
LANDING_LOCALE_COOKIE,
landingLocaleHref,
pickLandingLocale,
type LandingLocale,
} from "@/components/landing/landingLocale";
@@ -324,7 +325,7 @@ function InstitutionalLandingScreen({ locale }: { locale: LandingLocale }) {
<Link href="/docs/chart-guide" className="hover:text-slate-950">
{isEn ? "Guide" : "读图"}
</Link>
<Link href="/briefs" className="hover:text-slate-950">
<Link href={landingLocaleHref("/briefs", locale)} className="hover:text-slate-950">
{isEn ? "Briefs" : "简报"}
</Link>
<a href="#pricing" className="hover:text-slate-950">
@@ -104,7 +104,11 @@ export function runTests() {
"landing supported cities must render names and station codes from the generated city groups",
);
assert(source.includes("#contact"), "landing navigation must expose the contact section");
assert(source.includes('href="/briefs"'), "landing navigation must expose public Weather Market Brief assets");
assert(
source.includes("landingLocaleHref") &&
source.includes('href={landingLocaleHref("/briefs", locale)}'),
"landing navigation must expose public Weather Market Brief assets while preserving the current locale",
);
assert(source.includes('isEn ? "Briefs" : "简报"'), "landing page must label the public brief entry in both languages");
assert(source.includes('id="contact"'), "landing page must include a contact section");
assert(source.includes("yhrsc30@gmail.com"), "landing page must show the operator contact email");
@@ -38,3 +38,10 @@ export function pickLandingLocale(
export function nextLandingLocale(locale: LandingLocale): LandingLocale {
return locale === "zh-CN" ? "en-US" : "zh-CN";
}
export function landingLocaleHref(pathname: string, locale: LandingLocale): string {
const [pathWithQuery, hash] = pathname.split("#", 2);
const separator = pathWithQuery.includes("?") ? "&" : "?";
const localizedPath = `${pathWithQuery}${separator}${LANDING_LOCALE_QUERY_PARAM}=${encodeURIComponent(locale)}`;
return hash ? `${localizedPath}#${hash}` : localizedPath;
}
@@ -1,6 +1,6 @@
import Link from "next/link";
import { LandingLocaleToggle } from "@/components/landing/LandingLocaleToggle";
import type { LandingLocale } from "@/components/landing/landingLocale";
import { landingLocaleHref, type LandingLocale } from "@/components/landing/landingLocale";
import {
METHODOLOGY_PAGES,
PUBLIC_CONTENT_COPY,
@@ -44,21 +44,21 @@ function PublicHeader({ locale = "en-US" }: { locale?: LandingLocale }) {
return (
<header className="border-b border-slate-200 bg-white/90">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-3 px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-6 lg:px-8">
<Link className="text-base font-black text-slate-950" href="/">
<Link className="text-base font-black text-slate-950" href={landingLocaleHref("/", locale)}>
PolyWeather
</Link>
<div className="flex flex-wrap items-center gap-2 sm:justify-end">
<nav className="flex flex-wrap gap-2 text-sm font-semibold text-slate-700">
<Link className="rounded-md px-2.5 py-1.5 hover:bg-slate-100" href="/briefs">
<Link className="rounded-md px-2.5 py-1.5 hover:bg-slate-100" href={landingLocaleHref("/briefs", locale)}>
{locale === "en-US" ? "Briefs" : "简报"}
</Link>
<Link className="rounded-md px-2.5 py-1.5 hover:bg-slate-100" href="/methodology">
<Link className="rounded-md px-2.5 py-1.5 hover:bg-slate-100" href={landingLocaleHref("/methodology", locale)}>
{copy.methodology}
</Link>
<Link className="rounded-md px-2.5 py-1.5 hover:bg-slate-100" href="/sources">
<Link className="rounded-md px-2.5 py-1.5 hover:bg-slate-100" href={landingLocaleHref("/sources", locale)}>
{copy.sources}
</Link>
<Link className="rounded-md px-2.5 py-1.5 hover:bg-slate-100" href="/docs/intro">
<Link className="rounded-md px-2.5 py-1.5 hover:bg-slate-100" href={landingLocaleHref("/docs/intro", locale)}>
{copy.docs}
</Link>
</nav>
@@ -103,7 +103,7 @@ function SourceLinks({ locale = "en-US", slugs }: { locale?: LandingLocale; slug
return (
<Link
className="rounded-md border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-700 hover:border-slate-300 hover:bg-slate-50"
href={sourcePath(localizedSource)}
href={landingLocaleHref(sourcePath(localizedSource), locale)}
key={slug}
>
{localizedSource.title}
@@ -124,7 +124,7 @@ function MethodologyLinks({ locale = "en-US", slugs }: { locale?: LandingLocale;
return (
<Link
className="rounded-md border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-700 hover:border-slate-300 hover:bg-slate-50"
href={methodologyPath(localizedPage)}
href={landingLocaleHref(methodologyPath(localizedPage), locale)}
key={slug}
>
{localizedPage.title}
@@ -152,7 +152,7 @@ function BriefCard({
{brief.cityName} / {brief.date}
</p>
<h2 className="mt-2 text-xl font-black text-slate-950">
<Link href={briefPath(brief)}>{brief.title}</Link>
<Link href={landingLocaleHref(briefPath(brief), locale)}>{brief.title}</Link>
</h2>
</div>
<span className="rounded-md bg-emerald-50 px-2.5 py-1 text-xs font-bold text-emerald-800">
@@ -170,7 +170,7 @@ function BriefCard({
))}
</div>
<div className="flex flex-wrap items-center gap-3">
<Link className={secondaryButton} href={briefPath(brief)}>
<Link className={secondaryButton} href={landingLocaleHref(briefPath(brief), locale)}>
{readBriefLabel}
</Link>
<SourceLinks locale={locale} slugs={brief.sourceSlugs.slice(0, 2)} />
@@ -291,7 +291,7 @@ export function BriefDetailPageView({
>
{localizedBrief.primaryCtaLabel}
</PublicContentCta>
<Link className={secondaryButton} href="/briefs">
<Link className={secondaryButton} href={landingLocaleHref("/briefs", locale)}>
{copy.allPublicBriefs}
</Link>
</div>
@@ -315,7 +315,7 @@ export function BriefDetailPageView({
<div className="mt-4">
<PublicContentOutboundLink
className={secondaryButton}
href={`https://twitter.com/intent/tweet?text=${encodeURIComponent(localizedBrief.distributionText)}&url=${encodeURIComponent(absolutePublicUrl(briefPath(localizedBrief)))}`}
href={`https://twitter.com/intent/tweet?text=${encodeURIComponent(localizedBrief.distributionText)}&url=${encodeURIComponent(absolutePublicUrl(landingLocaleHref(briefPath(localizedBrief), locale)))}`}
payload={{ city: localizedBrief.city, date: localizedBrief.date, destination: "x_intent" }}
>
{copy.shareOnX}
@@ -365,26 +365,33 @@ function InfoRow({ label, value }: { label: string; value: string }) {
);
}
export function MethodologyIndexPageView() {
export function MethodologyIndexPageView({
locale = "en-US",
}: {
locale?: LandingLocale;
} = {}) {
const copy = PUBLIC_CONTENT_COPY[locale];
const pages = METHODOLOGY_PAGES.map((page) => localizeMethodologyPage(page, locale));
return (
<div className={pageShell}>
<PublicHeader />
<PublicHeader locale={locale} />
<PageIntro
description="Public methodology pages explain how PolyWeather handles DEB blending, settlement-source priority, freshness, and source reconciliation for prediction-market weather analysis."
eyebrow="Methodology"
title="How PolyWeather reads weather markets"
description={copy.methodologyIndexDescription}
eyebrow={copy.methodologyIndexEyebrow}
title={copy.methodologyIndexTitle}
/>
<div className={contentWrap}>
<section className="grid gap-4 md:grid-cols-2">
{METHODOLOGY_PAGES.map((page) => (
{pages.map((page) => (
<article className={`${panel} p-5`} key={page.slug}>
<p className={sectionTitle}>{formatDate(page.updatedAt)}</p>
<p className={sectionTitle}>{formatDate(page.updatedAt, locale)}</p>
<h2 className="mt-2 text-2xl font-black text-slate-950">
<Link href={methodologyPath(page)}>{page.title}</Link>
<Link href={landingLocaleHref(methodologyPath(page), locale)}>{page.title}</Link>
</h2>
<p className={`${bodyText} mt-3`}>{page.description}</p>
<Link className={`${secondaryButton} mt-5`} href={methodologyPath(page)}>
Read methodology
<Link className={`${secondaryButton} mt-5`} href={landingLocaleHref(methodologyPath(page), locale)}>
{copy.readMethodology}
</Link>
</article>
))}
@@ -394,7 +401,16 @@ export function MethodologyIndexPageView() {
);
}
export function MethodologyDetailPageView({ page }: { page: MethodologyPage }) {
export function MethodologyDetailPageView({
page,
locale = "en-US",
}: {
page: MethodologyPage;
locale?: LandingLocale;
}) {
const copy = PUBLIC_CONTENT_COPY[locale];
const localizedPage = localizeMethodologyPage(page, locale);
return (
<div className={pageShell}>
<PublicContentAnalytics
@@ -402,13 +418,17 @@ export function MethodologyDetailPageView({ page }: { page: MethodologyPage }) {
onceKey={`methodology:${page.slug}`}
payload={{ slug: page.slug, content_type: "methodology" }}
/>
<PublicHeader />
<PageIntro description={page.description} eyebrow="Methodology" title={page.title} />
<PublicHeader locale={locale} />
<PageIntro
description={localizedPage.description}
eyebrow={copy.methodologyIndexEyebrow}
title={localizedPage.title}
/>
<div className={contentWrap}>
<article className={`${panel} p-5`}>
<p className="max-w-3xl text-base leading-7 text-slate-700">{page.summary}</p>
<p className="max-w-3xl text-base leading-7 text-slate-700">{localizedPage.summary}</p>
<div className="mt-8 grid gap-8">
{page.sections.map((section) => (
{localizedPage.sections.map((section) => (
<section key={section.heading}>
<h2 className="text-xl font-black text-slate-950">{section.heading}</h2>
<p className={`${bodyText} mt-3`}>{section.body}</p>
@@ -428,26 +448,33 @@ export function MethodologyDetailPageView({ page }: { page: MethodologyPage }) {
);
}
export function SourcesIndexPageView() {
export function SourcesIndexPageView({
locale = "en-US",
}: {
locale?: LandingLocale;
} = {}) {
const copy = PUBLIC_CONTENT_COPY[locale];
const sources = SOURCE_PAGES.map((source) => localizeSourcePage(source, locale));
return (
<div className={pageShell}>
<PublicHeader />
<PublicHeader locale={locale} />
<PageIntro
description="Source pages separate official observations, airport observations, and model guidance so readers can inspect why PolyWeather prioritizes settlement-relevant evidence."
eyebrow="Sources"
title="Weather source notes for public audit"
description={copy.sourceIndexDescription}
eyebrow={copy.sourceIndexEyebrow}
title={copy.sourceIndexTitle}
/>
<div className={contentWrap}>
<section className="grid gap-4 md:grid-cols-2">
{SOURCE_PAGES.map((source) => (
{sources.map((source) => (
<article className={`${panel} p-5`} key={source.slug}>
<p className={sectionTitle}>{source.operator}</p>
<h2 className="mt-2 text-2xl font-black text-slate-950">
<Link href={sourcePath(source)}>{source.title}</Link>
<Link href={landingLocaleHref(sourcePath(source), locale)}>{source.title}</Link>
</h2>
<p className={`${bodyText} mt-3`}>{source.description}</p>
<Link className={`${secondaryButton} mt-5`} href={sourcePath(source)}>
Read source note
<Link className={`${secondaryButton} mt-5`} href={landingLocaleHref(sourcePath(source), locale)}>
{copy.readSourceNote}
</Link>
</article>
))}
@@ -457,32 +484,60 @@ export function SourcesIndexPageView() {
);
}
export function SourceDetailPageView({ source }: { source: SourcePage }) {
export function SourceDetailPageView({
source,
locale = "en-US",
}: {
source: SourcePage;
locale?: LandingLocale;
}) {
const localizedSource = localizeSourcePage(source, locale);
const labels =
locale === "en-US"
? {
cadence: "Cadence",
coverage: "Coverage",
operator: "Operator",
reliability: "Reliability notes",
relatedMethodology: "Related methodology",
settlementUse: "Settlement use",
sourceNote: "Source note",
}
: {
cadence: "频率",
coverage: "覆盖范围",
operator: "运营方",
reliability: "可靠性备注",
relatedMethodology: "相关方法",
settlementUse: "结算用途",
sourceNote: "来源说明",
};
return (
<div className={pageShell}>
<PublicHeader />
<PageIntro description={source.description} eyebrow="Source note" title={source.title} />
<PublicHeader locale={locale} />
<PageIntro description={localizedSource.description} eyebrow={labels.sourceNote} title={localizedSource.title} />
<div className={contentWrap}>
<article className={`${panel} grid gap-6 p-5 lg:grid-cols-[0.9fr_1.4fr]`}>
<dl className="grid gap-3 text-sm">
<InfoRow label="Operator" value={source.operator} />
<InfoRow label="Coverage" value={source.coverage} />
<InfoRow label="Cadence" value={source.cadence} />
<InfoRow label="Settlement use" value={source.settlementUse} />
<InfoRow label={labels.operator} value={localizedSource.operator} />
<InfoRow label={labels.coverage} value={localizedSource.coverage} />
<InfoRow label={labels.cadence} value={localizedSource.cadence} />
<InfoRow label={labels.settlementUse} value={localizedSource.settlementUse} />
</dl>
<div>
<p className={sectionTitle}>Reliability notes</p>
<p className={sectionTitle}>{labels.reliability}</p>
<ul className="mt-4 grid gap-3">
{source.reliabilityNotes.map((note) => (
{localizedSource.reliabilityNotes.map((note) => (
<li className={bodyText} key={note}>
{note}
</li>
))}
</ul>
<div className="mt-6">
<p className={sectionTitle}>Related methodology</p>
<p className={sectionTitle}>{labels.relatedMethodology}</p>
<div className="mt-4">
<MethodologyLinks slugs={source.relatedMethodologySlugs} />
<MethodologyLinks locale={locale} slugs={localizedSource.relatedMethodologySlugs} />
</div>
</div>
</div>
@@ -499,8 +554,8 @@ function formatDateTime(value: string, locale: LandingLocale = "en-US") {
}).format(new Date(value));
}
function formatDate(value: string) {
return new Intl.DateTimeFormat("en", {
function formatDate(value: string, locale: LandingLocale = "en-US") {
return new Intl.DateTimeFormat(locale === "en-US" ? "en" : "zh-CN", {
dateStyle: "medium",
}).format(new Date(value));
}
@@ -17,6 +17,7 @@ export function runTests() {
const analyticsPath = path.join(root, "lib", "app-analytics.ts");
const analyticsIslandPath = path.join(root, "components", "public-content", "PublicContentAnalytics.tsx");
const publicPagesPath = path.join(root, "components", "public-content", "PublicContentPages.tsx");
const localeHelperPath = path.join(root, "components", "landing", "landingLocale.ts");
const sitemapPath = path.join(root, "app", "sitemap.ts");
for (const requiredPath of [
@@ -35,11 +36,14 @@ export function runTests() {
const content = fs.readFileSync(contentPath, "utf8");
const briefDetail = fs.readFileSync(briefDetailPath, "utf8");
const methodologyIndex = fs.readFileSync(methodologyIndexPath, "utf8");
const methodologyDetail = fs.readFileSync(methodologyDetailPath, "utf8");
const sourcesIndex = fs.readFileSync(sourcesIndexPath, "utf8");
const sourceDetail = fs.readFileSync(sourceDetailPath, "utf8");
const analytics = fs.readFileSync(analyticsPath, "utf8");
const analyticsIsland = fs.readFileSync(analyticsIslandPath, "utf8");
const publicPages = fs.readFileSync(publicPagesPath, "utf8");
const localeHelper = fs.readFileSync(localeHelperPath, "utf8");
const sitemap = fs.readFileSync(sitemapPath, "utf8");
const briefsIndex = fs.readFileSync(briefsIndexPath, "utf8");
@@ -68,6 +72,13 @@ export function runTests() {
content.includes("阅读简报"),
"public brief content must provide Chinese and English localized copy",
);
assert(
content.includes("METHODOLOGY_PAGE_LOCALIZATIONS") &&
content.includes("SOURCE_PAGE_LOCALIZATIONS") &&
content.includes("DEB 不是结算预言机") &&
content.includes("官方摘要可能滞后于原始点位观测"),
"public methodology and source pages must provide Chinese body-level localization, not title-only translation",
);
assert(
content.includes("24.5°C") &&
content.includes("27.1°C") &&
@@ -104,6 +115,30 @@ export function runTests() {
publicPages.includes('locale === "en-US" ? "Briefs" : "简报"'),
"public content pages must render the shared language toggle and localized brief copy",
);
assert(
localeHelper.includes("export function landingLocaleHref") &&
localeHelper.includes("LANDING_LOCALE_QUERY_PARAM"),
"landing locale helpers must expose a shared href builder for explicit locale navigation",
);
assert(
publicPages.includes("landingLocaleHref") &&
publicPages.includes('href={landingLocaleHref("/briefs", locale)}') &&
publicPages.includes("href={landingLocaleHref(briefPath(brief), locale)}") &&
publicPages.includes("href={landingLocaleHref(methodologyPath(localizedPage), locale)}") &&
publicPages.includes("href={landingLocaleHref(sourcePath(localizedSource), locale)}"),
"public content brief cards, nav, methodology, and source links must preserve the current locale",
);
assert(
methodologyIndex.includes("resolvePublicContentLocale(searchParams)") &&
methodologyIndex.includes("<MethodologyIndexPageView locale={locale} />") &&
methodologyDetail.includes("resolvePublicContentLocale(searchParams)") &&
methodologyDetail.includes("<MethodologyDetailPageView page={page} locale={locale} />") &&
sourcesIndex.includes("resolvePublicContentLocale(searchParams)") &&
sourcesIndex.includes("<SourcesIndexPageView locale={locale} />") &&
sourceDetail.includes("resolvePublicContentLocale(searchParams)") &&
sourceDetail.includes("<SourceDetailPageView source={source} locale={locale} />"),
"methodology and source public routes must resolve and pass the same locale as brief routes",
);
assert(
analytics.includes('"brief_view"') &&
analytics.includes('"brief_cta_click"') &&
+142 -11
View File
@@ -661,17 +661,143 @@ const BRIEF_LOCALIZATIONS: Record<string, Partial<PublicBrief>> = {
},
};
const METHODOLOGY_TITLE_LOCALIZATIONS: Record<string, string> = {
deb: "DEB 预测方法",
"settlement-sources": "结算源优先级",
const METHODOLOGY_PAGE_LOCALIZATIONS: Record<string, Partial<MethodologyPage>> = {
deb: {
title: "DEB 预测方法",
description:
"PolyWeather 如何把 DEB 融合预报用于预测市场温度判断,同时不替代结算源证据。",
summary:
"DEB 是 PolyWeather 融合预报层的公开名称。它把模型指引、实时观测动量、来源新鲜度和站点上下文放在一起,帮助用户用更少单模型误判来判断最高温区间。",
sections: [
{
heading: "DEB 用来做什么",
body:
"DEB 不是结算预言机。它是决策辅助层,让实时观测路径和模型分歧更容易对比。",
bullets: [
"当模型指引与结算源证据冲突时,优先看结算源证据。",
"把过期或孤立的来源值视为需要质检的候选点。",
"展示预报区间以及区间变宽或收窄的原因。",
],
},
{
heading: "关键输入",
body:
"融合层有用,是因为它组合了不同证据类别,而不是假设一次模型运行就足够。",
bullets: [
"最新官方或机场观测及其新鲜度。",
"ECMWF、GFS、ICON、GEM 以及可用本地来源之间的模型共识和分歧。",
"城市特定来源行为、站点选择和日内最高温时段。",
],
},
{
heading: "如何复盘 DEB 偏差",
body:
"DEB 偏差应拆开来源新鲜度、模型分歧和结算源修订来复盘。",
bullets: [
"如果晚到来源 patch 改变了观测高温,将其归类为新鲜度或 replay 问题。",
"如果所有来源都新鲜但高温落在区间外,复盘模型权重和本地站点特征。",
"如果只有单一来源跳变,先审计相邻观测,再围绕异常点重训。",
],
},
],
},
"settlement-sources": {
title: "结算源优先级",
description:
"为什么 PolyWeather 先展示官方结算相关观测,而不是通用天气 API 数值。",
summary:
"预测市场用户关心的是合约最终结算数字。因此 PolyWeather 把官方站、机场和运营方特定来源行为放在泛消费者天气均值之上。",
sections: [
{
heading: "为什么通用天气值不够",
body:
"消费者天气应用经常平滑站点数据,或展示城市级近似值。市场结算可能依赖更窄的官方来源。",
bullets: [
"一个城市标签可能隐藏多个日高温不同的站点。",
"机场 METAR 可能比公开摘要更新更快,但未必是最终结算源。",
"官方来源修订往往比平滑的预报曲线更重要。",
],
},
{
heading: "PolyWeather 展示什么",
body:
"终端会拆开来源标签、观测时间戳、预报模型和新鲜度状态,让用户审计数字形成路径。",
bullets: [
"图表中的结算源标签和站点上下文。",
"来源新鲜度、缓存策略和 SSE patch 可见性。",
"DEB 预报上下文与实时观测并列展示,而不是替代观测。",
],
},
],
},
};
const SOURCE_TITLE_LOCALIZATIONS: Record<string, string> = {
ecmwf: "ECMWF 模型指引",
hko: "HKO 官方观测",
metar: "METAR 机场观测",
mgm: "MGM 天气来源",
noaa: "NOAA 天气上下文",
const SOURCE_PAGE_LOCALIZATIONS: Record<string, Partial<SourcePage>> = {
mgm: {
title: "MGM 天气来源",
description: "PolyWeather 针对土耳其 MGM 观测的来源说明,用于安卡拉类温度市场分析。",
operator: "土耳其国家气象局",
coverage: "土耳其官方站网络,包含安卡拉市场上下文",
cadence: "来源频率会随站点和发布路径变化;终端仍需要检查新鲜度。",
settlementUse: "当安卡拉市场引用土耳其官方观测时,作为主要官方来源族使用。",
reliabilityNotes: [
"单点尖峰在接受前应与相邻时间戳比对。",
"官方摘要可能滞后于原始点位观测。",
"数值突然出现时,应检查缓存和 SSE replay 状态。",
],
},
metar: {
title: "METAR 机场观测",
description: "PolyWeather 针对机场 METAR 观测的来源说明,用作温度市场工作流中的快速证据。",
operator: "机场天气观测网络",
coverage: "覆盖受支持市场的机场关联观测。",
cadence: "通常为小时级或更短,取决于机场和发布行为。",
settlementUse: "适合快速证据和机场关联合约;仍必须与合约的精确结算源校验。",
reliabilityNotes: [
"METAR 可能比官方日摘要更新更快。",
"机场暴露环境可能不同于市区官方站。",
"临近收盘的晚些 METAR 周期可能改变最高温判断。",
],
},
ecmwf: {
title: "ECMWF 模型指引",
description: "PolyWeather 针对 ECMWF 模型指引的来源说明,它是 DEB 融合预报的一个输入。",
operator: "欧洲中期天气预报中心",
coverage: "全球数值天气预报指引。",
cadence: "模型运行频率取决于产品和摄取时间。",
settlementUse: "仅用于预报上下文,不替代实时官方观测的结算解释。",
reliabilityNotes: [
"模型偏暖或偏冷都应由实时观测验证。",
"当来源证据尚未稳定时,模型运行间变化可以提供参考。",
"模型分歧应与结算源数据并列展示,而不是凌驾其上。",
],
},
hko: {
title: "HKO 官方观测",
description: "PolyWeather 针对香港天文台观测的来源说明,用于香港市场分析。",
operator: "香港天文台",
coverage: "香港官方观测网络和站点级上下文。",
cadence: "观测产品频率不同;终端仍应检查来源新鲜度。",
settlementUse: "作为香港站点和城市市场解释中的官方来源族使用。",
reliabilityNotes: [
"站点选择可能显著改变最高温读数。",
"机场观测应与更广泛的 HKO 网络读数分开解释。",
"湿度、风和午后短时日照会影响最终高温。",
],
},
noaa: {
title: "NOAA 天气上下文",
description: "PolyWeather 针对 NOAA 上下文的来源说明,用于校验美国天气市场观测和摘要。",
operator: "美国国家海洋和大气管理局",
coverage: "美国官方天气观测、摘要和上下文产品。",
cadence: "频率取决于产品族和站点报告行为。",
settlementUse: "当合约规则引用 NOAA/NWS 数据时,用于审计和解释美国官方观测。",
reliabilityNotes: [
"官方摘要可能晚于快速机场观测到达。",
"日高温解释应匹配合约的站点和时区规则。",
"使用 NOAA 上下文确认机场观测是否具代表性。",
],
},
};
export function localizeBrief(brief: PublicBrief, locale: LandingLocale): PublicBrief {
@@ -693,17 +819,22 @@ export function localizeBriefs(locale: LandingLocale) {
export function localizeMethodologyPage(page: MethodologyPage, locale: LandingLocale): MethodologyPage {
if (locale === "en-US") return page;
const localized = METHODOLOGY_PAGE_LOCALIZATIONS[page.slug] || {};
return {
...page,
title: METHODOLOGY_TITLE_LOCALIZATIONS[page.slug] || page.title,
...localized,
sections: localized.sections || page.sections,
};
}
export function localizeSourcePage(page: SourcePage, locale: LandingLocale): SourcePage {
if (locale === "en-US") return page;
const localized = SOURCE_PAGE_LOCALIZATIONS[page.slug] || {};
return {
...page,
title: SOURCE_TITLE_LOCALIZATIONS[page.slug] || page.title,
...localized,
reliabilityNotes: localized.reliabilityNotes || page.reliabilityNotes,
relatedMethodologySlugs: localized.relatedMethodologySlugs || page.relatedMethodologySlugs,
};
}