Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a5bab9fad | |||
| 0fef948fac |
@@ -1,17 +1,38 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import { cookies, headers } from "next/headers";
|
||||||
import { BriefDetailPageView } from "@/components/public-content/PublicContentPages";
|
import { BriefDetailPageView } from "@/components/public-content/PublicContentPages";
|
||||||
|
import {
|
||||||
|
LANDING_LOCALE_COOKIE,
|
||||||
|
LANDING_LOCALE_QUERY_PARAM,
|
||||||
|
pickLandingLocale,
|
||||||
|
type LandingLocale,
|
||||||
|
} from "@/components/landing/landingLocale";
|
||||||
import {
|
import {
|
||||||
PUBLIC_BRIEFS,
|
PUBLIC_BRIEFS,
|
||||||
absolutePublicUrl,
|
absolutePublicUrl,
|
||||||
briefPath,
|
briefPath,
|
||||||
getBrief,
|
getBrief,
|
||||||
|
localizeBrief,
|
||||||
} from "@/content/public-content";
|
} from "@/content/public-content";
|
||||||
|
|
||||||
type BriefPageParams = {
|
type BriefPageParams = {
|
||||||
city: string;
|
city: string;
|
||||||
date: string;
|
date: string;
|
||||||
};
|
};
|
||||||
|
type BriefSearchParams = Promise<Record<string, string | string[] | undefined>>;
|
||||||
|
|
||||||
|
async function resolvePublicContentLocale(searchParams: BriefSearchParams): 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() {
|
export function generateStaticParams() {
|
||||||
return PUBLIC_BRIEFS.map((brief) => ({
|
return PUBLIC_BRIEFS.map((brief) => ({
|
||||||
@@ -22,10 +43,15 @@ export function generateStaticParams() {
|
|||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
params,
|
params,
|
||||||
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<BriefPageParams>;
|
params: Promise<BriefPageParams>;
|
||||||
|
searchParams: BriefSearchParams;
|
||||||
}): Promise<Metadata> {
|
}): Promise<Metadata> {
|
||||||
const { city, date } = await params;
|
const [{ city, date }, locale] = await Promise.all([
|
||||||
|
params,
|
||||||
|
resolvePublicContentLocale(searchParams),
|
||||||
|
]);
|
||||||
const brief = getBrief(city, date);
|
const brief = getBrief(city, date);
|
||||||
|
|
||||||
if (!brief) {
|
if (!brief) {
|
||||||
@@ -34,51 +60,58 @@ export async function generateMetadata({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const pathname = briefPath(brief);
|
const localizedBrief = localizeBrief(brief, locale);
|
||||||
|
const pathname = briefPath(localizedBrief);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: brief.title,
|
title: localizedBrief.title,
|
||||||
description: brief.description,
|
description: localizedBrief.description,
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: pathname,
|
canonical: pathname,
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
type: "article",
|
type: "article",
|
||||||
title: brief.title,
|
title: localizedBrief.title,
|
||||||
description: brief.description,
|
description: localizedBrief.description,
|
||||||
url: pathname,
|
url: pathname,
|
||||||
publishedTime: brief.publishedAt,
|
publishedTime: localizedBrief.publishedAt,
|
||||||
modifiedTime: brief.updatedAt,
|
modifiedTime: localizedBrief.updatedAt,
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: "summary",
|
card: "summary",
|
||||||
title: brief.title,
|
title: localizedBrief.title,
|
||||||
description: brief.description,
|
description: localizedBrief.description,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function BriefDetailPage({
|
export default async function BriefDetailPage({
|
||||||
params,
|
params,
|
||||||
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<BriefPageParams>;
|
params: Promise<BriefPageParams>;
|
||||||
|
searchParams: BriefSearchParams;
|
||||||
}) {
|
}) {
|
||||||
const { city, date } = await params;
|
const [{ city, date }, locale] = await Promise.all([
|
||||||
|
params,
|
||||||
|
resolvePublicContentLocale(searchParams),
|
||||||
|
]);
|
||||||
const brief = getBrief(city, date);
|
const brief = getBrief(city, date);
|
||||||
|
|
||||||
if (!brief) {
|
if (!brief) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const pathname = briefPath(brief);
|
const localizedBrief = localizeBrief(brief, locale);
|
||||||
|
const pathname = briefPath(localizedBrief);
|
||||||
const jsonLd = [
|
const jsonLd = [
|
||||||
{
|
{
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "Article",
|
"@type": "Article",
|
||||||
headline: brief.title,
|
headline: localizedBrief.title,
|
||||||
description: brief.description,
|
description: localizedBrief.description,
|
||||||
datePublished: brief.publishedAt,
|
datePublished: localizedBrief.publishedAt,
|
||||||
dateModified: brief.updatedAt,
|
dateModified: localizedBrief.updatedAt,
|
||||||
mainEntityOfPage: absolutePublicUrl(pathname),
|
mainEntityOfPage: absolutePublicUrl(pathname),
|
||||||
author: {
|
author: {
|
||||||
"@type": "Organization",
|
"@type": "Organization",
|
||||||
@@ -91,9 +124,9 @@ export default async function BriefDetailPage({
|
|||||||
url: "https://polyweather.top",
|
url: "https://polyweather.top",
|
||||||
},
|
},
|
||||||
about: [
|
about: [
|
||||||
brief.cityName,
|
localizedBrief.cityName,
|
||||||
brief.market,
|
localizedBrief.market,
|
||||||
brief.settlementSource,
|
localizedBrief.settlementSource,
|
||||||
"DEB forecast methodology",
|
"DEB forecast methodology",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -110,7 +143,7 @@ export default async function BriefDetailPage({
|
|||||||
{
|
{
|
||||||
"@type": "ListItem",
|
"@type": "ListItem",
|
||||||
position: 2,
|
position: 2,
|
||||||
name: brief.cityName,
|
name: localizedBrief.cityName,
|
||||||
item: absolutePublicUrl(pathname),
|
item: absolutePublicUrl(pathname),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -123,7 +156,7 @@ export default async function BriefDetailPage({
|
|||||||
type="application/ld+json"
|
type="application/ld+json"
|
||||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||||
/>
|
/>
|
||||||
<BriefDetailPageView brief={brief} />
|
<BriefDetailPageView brief={brief} locale={locale} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,54 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { cookies, headers } from "next/headers";
|
||||||
import { BriefsIndexPageView } from "@/components/public-content/PublicContentPages";
|
import { BriefsIndexPageView } 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 = {
|
type BriefsSearchParams = Promise<Record<string, string | string[] | undefined>>;
|
||||||
title: "Weather Market Brief",
|
|
||||||
description:
|
|
||||||
"Public PolyWeather market briefs for temperature judgment, settlement-source checks, DEB context, and source freshness notes.",
|
|
||||||
alternates: {
|
|
||||||
canonical: "/briefs",
|
|
||||||
},
|
|
||||||
openGraph: {
|
|
||||||
title: "Weather Market Brief | PolyWeather",
|
|
||||||
description:
|
|
||||||
"Public market briefs for temperature judgment, settlement-source checks, DEB context, and source freshness notes.",
|
|
||||||
url: "/briefs",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function BriefsPage() {
|
async function resolvePublicContentLocale(searchParams: BriefsSearchParams): Promise<LandingLocale> {
|
||||||
return <BriefsIndexPageView />;
|
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: BriefsSearchParams;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const locale = await resolvePublicContentLocale(searchParams);
|
||||||
|
const copy = PUBLIC_CONTENT_COPY[locale];
|
||||||
|
return {
|
||||||
|
title: copy.briefIndexEyebrow,
|
||||||
|
description: copy.briefIndexDescription,
|
||||||
|
alternates: {
|
||||||
|
canonical: "/briefs",
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
title: `Weather Market Brief | PolyWeather`,
|
||||||
|
description: copy.briefIndexDescription,
|
||||||
|
url: "/briefs",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function BriefsPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: BriefsSearchParams;
|
||||||
|
}) {
|
||||||
|
const locale = await resolvePublicContentLocale(searchParams);
|
||||||
|
return <BriefsIndexPageView locale={locale} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -325,7 +325,7 @@ function InstitutionalLandingScreen({ locale }: { locale: LandingLocale }) {
|
|||||||
{isEn ? "Guide" : "读图"}
|
{isEn ? "Guide" : "读图"}
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/briefs" className="hover:text-slate-950">
|
<Link href="/briefs" className="hover:text-slate-950">
|
||||||
Weather Market Brief
|
{isEn ? "Briefs" : "简报"}
|
||||||
</Link>
|
</Link>
|
||||||
<a href="#pricing" className="hover:text-slate-950">
|
<a href="#pricing" className="hover:text-slate-950">
|
||||||
{isEn ? "Pricing" : "定价"}
|
{isEn ? "Pricing" : "定价"}
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export function runTests() {
|
|||||||
);
|
);
|
||||||
assert(source.includes("#contact"), "landing navigation must expose the contact section");
|
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('href="/briefs"'), "landing navigation must expose public Weather Market Brief assets");
|
||||||
assert(source.includes("Weather Market Brief"), "landing page must label the public brief entry clearly");
|
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('id="contact"'), "landing page must include a contact section");
|
||||||
assert(source.includes("yhrsc30@gmail.com"), "landing page must show the operator contact email");
|
assert(source.includes("yhrsc30@gmail.com"), "landing page must show the operator contact email");
|
||||||
assert(source.includes("mailto:${CONTACT_EMAIL}"), "landing contact email must be clickable");
|
assert(source.includes("mailto:${CONTACT_EMAIL}"), "landing contact email must be clickable");
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { LandingLocaleToggle } from "@/components/landing/LandingLocaleToggle";
|
||||||
|
import type { LandingLocale } from "@/components/landing/landingLocale";
|
||||||
import {
|
import {
|
||||||
METHODOLOGY_PAGES,
|
METHODOLOGY_PAGES,
|
||||||
|
PUBLIC_CONTENT_COPY,
|
||||||
PUBLIC_BRIEFS,
|
PUBLIC_BRIEFS,
|
||||||
SOURCE_PAGES,
|
SOURCE_PAGES,
|
||||||
absolutePublicUrl,
|
absolutePublicUrl,
|
||||||
briefPath,
|
briefPath,
|
||||||
methodologyPath,
|
methodologyPath,
|
||||||
sourcePath,
|
sourcePath,
|
||||||
|
localizeBrief,
|
||||||
|
localizeBriefs,
|
||||||
|
localizeMethodologyPage,
|
||||||
|
localizeSourcePage,
|
||||||
type MethodologyPage,
|
type MethodologyPage,
|
||||||
type PublicBrief,
|
type PublicBrief,
|
||||||
type SourcePage,
|
type SourcePage,
|
||||||
@@ -31,27 +38,32 @@ const primaryButton =
|
|||||||
const secondaryButton =
|
const secondaryButton =
|
||||||
"inline-flex min-h-10 items-center justify-center rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-900 transition hover:border-slate-400 hover:bg-slate-50";
|
"inline-flex min-h-10 items-center justify-center rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-900 transition hover:border-slate-400 hover:bg-slate-50";
|
||||||
|
|
||||||
function PublicHeader() {
|
function PublicHeader({ locale = "en-US" }: { locale?: LandingLocale }) {
|
||||||
|
const copy = PUBLIC_CONTENT_COPY[locale];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="border-b border-slate-200 bg-white/90">
|
<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">
|
<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="/">
|
||||||
PolyWeather
|
PolyWeather
|
||||||
</Link>
|
</Link>
|
||||||
<nav className="flex flex-wrap gap-2 text-sm font-semibold text-slate-700">
|
<div className="flex flex-wrap items-center gap-2 sm:justify-end">
|
||||||
<Link className="rounded-md px-2.5 py-1.5 hover:bg-slate-100" href="/briefs">
|
<nav className="flex flex-wrap gap-2 text-sm font-semibold text-slate-700">
|
||||||
Briefs
|
<Link className="rounded-md px-2.5 py-1.5 hover:bg-slate-100" href="/briefs">
|
||||||
</Link>
|
{locale === "en-US" ? "Briefs" : "简报"}
|
||||||
<Link className="rounded-md px-2.5 py-1.5 hover:bg-slate-100" href="/methodology">
|
</Link>
|
||||||
Methodology
|
<Link className="rounded-md px-2.5 py-1.5 hover:bg-slate-100" href="/methodology">
|
||||||
</Link>
|
{copy.methodology}
|
||||||
<Link className="rounded-md px-2.5 py-1.5 hover:bg-slate-100" href="/sources">
|
</Link>
|
||||||
Sources
|
<Link className="rounded-md px-2.5 py-1.5 hover:bg-slate-100" href="/sources">
|
||||||
</Link>
|
{copy.sources}
|
||||||
<Link className="rounded-md px-2.5 py-1.5 hover:bg-slate-100" href="/docs/intro">
|
</Link>
|
||||||
Docs
|
<Link className="rounded-md px-2.5 py-1.5 hover:bg-slate-100" href="/docs/intro">
|
||||||
</Link>
|
{copy.docs}
|
||||||
</nav>
|
</Link>
|
||||||
|
</nav>
|
||||||
|
<LandingLocaleToggle locale={locale} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
@@ -81,19 +93,20 @@ function PageIntro({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SourceLinks({ slugs }: { slugs: string[] }) {
|
function SourceLinks({ locale = "en-US", slugs }: { locale?: LandingLocale; slugs: string[] }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{slugs.map((slug) => {
|
{slugs.map((slug) => {
|
||||||
const source = SOURCE_PAGES.find((entry) => entry.slug === slug);
|
const source = SOURCE_PAGES.find((entry) => entry.slug === slug);
|
||||||
if (!source) return null;
|
if (!source) return null;
|
||||||
|
const localizedSource = localizeSourcePage(source, locale);
|
||||||
return (
|
return (
|
||||||
<Link
|
<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"
|
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(source)}
|
href={sourcePath(localizedSource)}
|
||||||
key={slug}
|
key={slug}
|
||||||
>
|
>
|
||||||
{source.title}
|
{localizedSource.title}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -101,19 +114,20 @@ function SourceLinks({ slugs }: { slugs: string[] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MethodologyLinks({ slugs }: { slugs: string[] }) {
|
function MethodologyLinks({ locale = "en-US", slugs }: { locale?: LandingLocale; slugs: string[] }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{slugs.map((slug) => {
|
{slugs.map((slug) => {
|
||||||
const page = METHODOLOGY_PAGES.find((entry) => entry.slug === slug);
|
const page = METHODOLOGY_PAGES.find((entry) => entry.slug === slug);
|
||||||
if (!page) return null;
|
if (!page) return null;
|
||||||
|
const localizedPage = localizeMethodologyPage(page, locale);
|
||||||
return (
|
return (
|
||||||
<Link
|
<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"
|
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(page)}
|
href={methodologyPath(localizedPage)}
|
||||||
key={slug}
|
key={slug}
|
||||||
>
|
>
|
||||||
{page.title}
|
{localizedPage.title}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -121,7 +135,15 @@ function MethodologyLinks({ slugs }: { slugs: string[] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function BriefCard({ brief }: { brief: PublicBrief }) {
|
function BriefCard({
|
||||||
|
brief,
|
||||||
|
locale = "en-US",
|
||||||
|
readBriefLabel,
|
||||||
|
}: {
|
||||||
|
brief: PublicBrief;
|
||||||
|
locale?: LandingLocale;
|
||||||
|
readBriefLabel: string;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<article className={`${panel} flex flex-col gap-5 p-5`}>
|
<article className={`${panel} flex flex-col gap-5 p-5`}>
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
@@ -149,52 +171,60 @@ function BriefCard({ brief }: { brief: PublicBrief }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<Link className={secondaryButton} href={briefPath(brief)}>
|
<Link className={secondaryButton} href={briefPath(brief)}>
|
||||||
Read brief
|
{readBriefLabel}
|
||||||
</Link>
|
</Link>
|
||||||
<SourceLinks slugs={brief.sourceSlugs.slice(0, 2)} />
|
<SourceLinks locale={locale} slugs={brief.sourceSlugs.slice(0, 2)} />
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BriefsIndexPageView() {
|
export function BriefsIndexPageView({ locale = "en-US" }: { locale?: LandingLocale }) {
|
||||||
|
const copy = PUBLIC_CONTENT_COPY[locale];
|
||||||
|
const briefs = localizeBriefs(locale);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={pageShell}>
|
<div className={pageShell}>
|
||||||
<PublicHeader />
|
<PublicHeader locale={locale} />
|
||||||
<PageIntro
|
<PageIntro
|
||||||
description="Public Weather Market Brief pages turn selected city-market reads into indexable evidence: settlement source, DEB context, model disagreement, freshness notes, and a clear research disclaimer."
|
description={copy.briefIndexDescription}
|
||||||
eyebrow="Weather Market Brief"
|
eyebrow={copy.briefIndexEyebrow}
|
||||||
title="Public market briefs for temperature judgment"
|
title={copy.briefIndexTitle}
|
||||||
/>
|
/>
|
||||||
<div className={contentWrap}>
|
<div className={contentWrap}>
|
||||||
<section className="grid gap-4">
|
<section className="grid gap-4">
|
||||||
{PUBLIC_BRIEFS.map((brief) => (
|
{briefs.map((brief) => (
|
||||||
<BriefCard brief={brief} key={`${brief.city}-${brief.date}`} />
|
<BriefCard
|
||||||
|
brief={brief}
|
||||||
|
key={`${brief.city}-${brief.date}`}
|
||||||
|
locale={locale}
|
||||||
|
readBriefLabel={copy.readBrief}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
<section className={`${panel} grid gap-5 p-5 md:grid-cols-2`}>
|
<section className={`${panel} grid gap-5 p-5 md:grid-cols-2`}>
|
||||||
<div>
|
<div>
|
||||||
<p className={sectionTitle}>Methodology</p>
|
<p className={sectionTitle}>{copy.methodology}</p>
|
||||||
<h2 className="mt-2 text-2xl font-black text-slate-950">
|
<h2 className="mt-2 text-2xl font-black text-slate-950">
|
||||||
How the public read is produced
|
{copy.methodologyPanelTitle}
|
||||||
</h2>
|
</h2>
|
||||||
<p className={`${bodyText} mt-3`}>
|
<p className={`${bodyText} mt-3`}>
|
||||||
Briefs cross-link to the DEB methodology and settlement-source priority pages so readers can audit why PolyWeather does not treat generic city forecasts as market truth.
|
{copy.methodologyPanelBody}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<MethodologyLinks slugs={["deb", "settlement-sources"]} />
|
<MethodologyLinks locale={locale} slugs={["deb", "settlement-sources"]} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className={sectionTitle}>Source notes</p>
|
<p className={sectionTitle}>{copy.sourceNotes}</p>
|
||||||
<h2 className="mt-2 text-2xl font-black text-slate-950">
|
<h2 className="mt-2 text-2xl font-black text-slate-950">
|
||||||
Official-source context
|
{copy.sourcePanelTitle}
|
||||||
</h2>
|
</h2>
|
||||||
<p className={`${bodyText} mt-3`}>
|
<p className={`${bodyText} mt-3`}>
|
||||||
Source pages explain why MGM, METAR, HKO, NOAA, and model guidance are displayed separately in PolyWeather workflows.
|
{copy.sourcePanelBody}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<SourceLinks slugs={["mgm", "metar", "hko", "noaa"]} />
|
<SourceLinks locale={locale} slugs={["mgm", "metar", "hko", "noaa"]} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -203,25 +233,34 @@ export function BriefsIndexPageView() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BriefDetailPageView({ brief }: { brief: PublicBrief }) {
|
export function BriefDetailPageView({
|
||||||
|
brief,
|
||||||
|
locale = "en-US",
|
||||||
|
}: {
|
||||||
|
brief: PublicBrief;
|
||||||
|
locale?: LandingLocale;
|
||||||
|
}) {
|
||||||
|
const copy = PUBLIC_CONTENT_COPY[locale];
|
||||||
|
const localizedBrief = localizeBrief(brief, locale);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={pageShell}>
|
<div className={pageShell}>
|
||||||
<PublicContentAnalytics
|
<PublicContentAnalytics
|
||||||
eventType="brief_view"
|
eventType="brief_view"
|
||||||
onceKey={`brief:${brief.city}:${brief.date}`}
|
onceKey={`brief:${localizedBrief.city}:${localizedBrief.date}`}
|
||||||
payload={{ city: brief.city, date: brief.date, source: brief.settlementSource }}
|
payload={{ city: localizedBrief.city, date: localizedBrief.date, source: localizedBrief.settlementSource }}
|
||||||
/>
|
/>
|
||||||
<PublicHeader />
|
<PublicHeader locale={locale} />
|
||||||
<PageIntro
|
<PageIntro
|
||||||
description={brief.description}
|
description={localizedBrief.description}
|
||||||
eyebrow={`${brief.cityName}, ${brief.countryName} / ${brief.date}`}
|
eyebrow={`${localizedBrief.cityName}, ${localizedBrief.countryName} / ${localizedBrief.date}`}
|
||||||
title={brief.title}
|
title={localizedBrief.title}
|
||||||
/>
|
/>
|
||||||
<div className={contentWrap}>
|
<div className={contentWrap}>
|
||||||
<section className="grid gap-5 lg:grid-cols-[1.6fr_0.9fr]">
|
<section className="grid gap-5 lg:grid-cols-[1.6fr_0.9fr]">
|
||||||
<article className={`${panel} p-5`}>
|
<article className={`${panel} p-5`}>
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{brief.signals.map((signal) => (
|
{localizedBrief.signals.map((signal) => (
|
||||||
<div className="rounded-md border border-slate-200 bg-slate-50 p-4" key={signal.label}>
|
<div className="rounded-md border border-slate-200 bg-slate-50 p-4" key={signal.label}>
|
||||||
<p className="text-xs font-semibold text-slate-500">{signal.label}</p>
|
<p className="text-xs font-semibold text-slate-500">{signal.label}</p>
|
||||||
<p className="mt-1 font-mono text-2xl font-black text-slate-950">{signal.value}</p>
|
<p className="mt-1 font-mono text-2xl font-black text-slate-950">{signal.value}</p>
|
||||||
@@ -230,30 +269,30 @@ export function BriefDetailPageView({ brief }: { brief: PublicBrief }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6 grid gap-5">
|
<div className="mt-6 grid gap-5">
|
||||||
<BriefSection title="DEB read" body={brief.debRead} />
|
<BriefSection title={copy.detailLabels.debRead} body={localizedBrief.debRead} />
|
||||||
<BriefSection title="Settlement-source read" body={brief.sourceRead} />
|
<BriefSection title={copy.detailLabels.settlementSourceRead} body={localizedBrief.sourceRead} />
|
||||||
<BriefSection title="Model context" body={brief.modelRead} />
|
<BriefSection title={copy.detailLabels.modelContext} body={localizedBrief.modelRead} />
|
||||||
<BriefSection title="Risk notes" body={brief.riskRead} />
|
<BriefSection title={copy.detailLabels.riskNotes} body={localizedBrief.riskRead} />
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
<aside className={`${panel} h-fit p-5`}>
|
<aside className={`${panel} h-fit p-5`}>
|
||||||
<p className={sectionTitle}>Snapshot</p>
|
<p className={sectionTitle}>{copy.snapshot}</p>
|
||||||
<dl className="mt-4 grid gap-3 text-sm">
|
<dl className="mt-4 grid gap-3 text-sm">
|
||||||
<InfoRow label="Market" value={brief.market} />
|
<InfoRow label={copy.market} value={localizedBrief.market} />
|
||||||
<InfoRow label="Settlement source" value={brief.settlementSource} />
|
<InfoRow label={copy.settlementSource} value={localizedBrief.settlementSource} />
|
||||||
<InfoRow label="Updated" value={formatDateTime(brief.updatedAt)} />
|
<InfoRow label={copy.updated} value={formatDateTime(localizedBrief.updatedAt, locale)} />
|
||||||
<InfoRow label="Freshness" value={brief.dataFreshness} />
|
<InfoRow label={copy.freshness} value={localizedBrief.dataFreshness} />
|
||||||
</dl>
|
</dl>
|
||||||
<div className="mt-5 flex flex-col gap-3">
|
<div className="mt-5 flex flex-col gap-3">
|
||||||
<PublicContentCta
|
<PublicContentCta
|
||||||
className={primaryButton}
|
className={primaryButton}
|
||||||
href="/terminal"
|
href="/terminal"
|
||||||
payload={{ city: brief.city, date: brief.date, cta: "terminal" }}
|
payload={{ city: localizedBrief.city, date: localizedBrief.date, cta: "terminal" }}
|
||||||
>
|
>
|
||||||
{brief.primaryCtaLabel}
|
{localizedBrief.primaryCtaLabel}
|
||||||
</PublicContentCta>
|
</PublicContentCta>
|
||||||
<Link className={secondaryButton} href="/briefs">
|
<Link className={secondaryButton} href="/briefs">
|
||||||
All public briefs
|
{copy.allPublicBriefs}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -261,9 +300,9 @@ export function BriefDetailPageView({ brief }: { brief: PublicBrief }) {
|
|||||||
|
|
||||||
<section className="grid gap-5 lg:grid-cols-[1fr_1fr]">
|
<section className="grid gap-5 lg:grid-cols-[1fr_1fr]">
|
||||||
<div className={`${panel} p-5`}>
|
<div className={`${panel} p-5`}>
|
||||||
<p className={sectionTitle}>Checks before acting</p>
|
<p className={sectionTitle}>{copy.checksBeforeActing}</p>
|
||||||
<ul className="mt-4 grid gap-3">
|
<ul className="mt-4 grid gap-3">
|
||||||
{brief.checkpoints.map((checkpoint) => (
|
{localizedBrief.checkpoints.map((checkpoint) => (
|
||||||
<li className={bodyText} key={checkpoint}>
|
<li className={bodyText} key={checkpoint}>
|
||||||
{checkpoint}
|
{checkpoint}
|
||||||
</li>
|
</li>
|
||||||
@@ -271,15 +310,15 @@ export function BriefDetailPageView({ brief }: { brief: PublicBrief }) {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div className={`${panel} p-5`}>
|
<div className={`${panel} p-5`}>
|
||||||
<p className={sectionTitle}>Distribution copy</p>
|
<p className={sectionTitle}>{copy.distributionCopy}</p>
|
||||||
<p className={`${bodyText} mt-4`}>{brief.distributionText}</p>
|
<p className={`${bodyText} mt-4`}>{localizedBrief.distributionText}</p>
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<PublicContentOutboundLink
|
<PublicContentOutboundLink
|
||||||
className={secondaryButton}
|
className={secondaryButton}
|
||||||
href={`https://twitter.com/intent/tweet?text=${encodeURIComponent(brief.distributionText)}&url=${encodeURIComponent(absolutePublicUrl(briefPath(brief)))}`}
|
href={`https://twitter.com/intent/tweet?text=${encodeURIComponent(localizedBrief.distributionText)}&url=${encodeURIComponent(absolutePublicUrl(briefPath(localizedBrief)))}`}
|
||||||
payload={{ city: brief.city, date: brief.date, destination: "x_intent" }}
|
payload={{ city: localizedBrief.city, date: localizedBrief.date, destination: "x_intent" }}
|
||||||
>
|
>
|
||||||
Share on X
|
{copy.shareOnX}
|
||||||
</PublicContentOutboundLink>
|
</PublicContentOutboundLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -287,21 +326,21 @@ export function BriefDetailPageView({ brief }: { brief: PublicBrief }) {
|
|||||||
|
|
||||||
<section className={`${panel} grid gap-5 p-5 md:grid-cols-2`}>
|
<section className={`${panel} grid gap-5 p-5 md:grid-cols-2`}>
|
||||||
<div>
|
<div>
|
||||||
<p className={sectionTitle}>Methodology links</p>
|
<p className={sectionTitle}>{copy.methodologyLinks}</p>
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<MethodologyLinks slugs={brief.methodologySlugs} />
|
<MethodologyLinks locale={locale} slugs={localizedBrief.methodologySlugs} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className={sectionTitle}>Source links</p>
|
<p className={sectionTitle}>{copy.sourceLinks}</p>
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<SourceLinks slugs={brief.sourceSlugs} />
|
<SourceLinks locale={locale} slugs={localizedBrief.sourceSlugs} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<p className="rounded-md border border-amber-200 bg-amber-50 p-4 text-sm leading-6 text-amber-900">
|
<p className="rounded-md border border-amber-200 bg-amber-50 p-4 text-sm leading-6 text-amber-900">
|
||||||
{brief.notFinancialAdvice}
|
{localizedBrief.notFinancialAdvice}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -453,8 +492,8 @@ export function SourceDetailPageView({ source }: { source: SourcePage }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDateTime(value: string) {
|
function formatDateTime(value: string, locale: LandingLocale = "en-US") {
|
||||||
return new Intl.DateTimeFormat("en", {
|
return new Intl.DateTimeFormat(locale === "en-US" ? "en" : "zh-CN", {
|
||||||
dateStyle: "medium",
|
dateStyle: "medium",
|
||||||
timeStyle: "short",
|
timeStyle: "short",
|
||||||
}).format(new Date(value));
|
}).format(new Date(value));
|
||||||
|
|||||||
@@ -59,6 +59,15 @@ export function runTests() {
|
|||||||
content.includes("distributionText"),
|
content.includes("distributionText"),
|
||||||
"public briefs must carry disclaimer, freshness, settlement source, and shareable distribution copy",
|
"public briefs must carry disclaimer, freshness, settlement source, and shareable distribution copy",
|
||||||
);
|
);
|
||||||
|
assert(
|
||||||
|
content.includes("PUBLIC_CONTENT_COPY") &&
|
||||||
|
content.includes('"zh-CN"') &&
|
||||||
|
content.includes('"en-US"') &&
|
||||||
|
content.includes("公开天气市场简报") &&
|
||||||
|
content.includes("安卡拉") &&
|
||||||
|
content.includes("阅读简报"),
|
||||||
|
"public brief content must provide Chinese and English localized copy",
|
||||||
|
);
|
||||||
assert(
|
assert(
|
||||||
briefDetail.includes("generateStaticParams") &&
|
briefDetail.includes("generateStaticParams") &&
|
||||||
briefDetail.includes("generateMetadata") &&
|
briefDetail.includes("generateMetadata") &&
|
||||||
@@ -77,11 +86,18 @@ export function runTests() {
|
|||||||
"methodology and source detail routes must expose structured data for GEO/SEO",
|
"methodology and source detail routes must expose structured data for GEO/SEO",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
publicPages.includes('MethodologyLinks slugs={["deb", "settlement-sources"]}') &&
|
publicPages.includes('MethodologyLinks locale={locale} slugs={["deb", "settlement-sources"]}') &&
|
||||||
publicPages.includes('SourceLinks slugs={["mgm", "metar", "hko", "noaa"]}') &&
|
publicPages.includes('SourceLinks locale={locale} slugs={["mgm", "metar", "hko", "noaa"]}') &&
|
||||||
briefsIndex.includes("Weather Market Brief"),
|
briefsIndex.includes("Weather Market Brief"),
|
||||||
"brief index must cross-link to DEB methodology and settlement source pages",
|
"brief index must cross-link to DEB methodology and settlement source pages",
|
||||||
);
|
);
|
||||||
|
assert(
|
||||||
|
publicPages.includes("LandingLocaleToggle") &&
|
||||||
|
publicPages.includes("localizeBrief") &&
|
||||||
|
publicPages.includes("PUBLIC_CONTENT_COPY") &&
|
||||||
|
publicPages.includes('locale === "en-US" ? "Briefs" : "简报"'),
|
||||||
|
"public content pages must render the shared language toggle and localized brief copy",
|
||||||
|
);
|
||||||
assert(
|
assert(
|
||||||
analytics.includes('"brief_view"') &&
|
analytics.includes('"brief_view"') &&
|
||||||
analytics.includes('"brief_cta_click"') &&
|
analytics.includes('"brief_cta_click"') &&
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { LandingLocale } from "@/components/landing/landingLocale";
|
||||||
|
|
||||||
export const PUBLIC_CONTENT_BASE_URL = "https://polyweather.top";
|
export const PUBLIC_CONTENT_BASE_URL = "https://polyweather.top";
|
||||||
|
|
||||||
export type PublicBriefSignal = {
|
export type PublicBriefSignal = {
|
||||||
@@ -57,6 +59,140 @@ export type SourcePage = {
|
|||||||
relatedMethodologySlugs: string[];
|
relatedMethodologySlugs: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PublicContentCopy = {
|
||||||
|
allPublicBriefs: string;
|
||||||
|
briefIndexDescription: string;
|
||||||
|
briefIndexEyebrow: string;
|
||||||
|
briefIndexTitle: string;
|
||||||
|
checksBeforeActing: string;
|
||||||
|
distributionCopy: string;
|
||||||
|
docs: string;
|
||||||
|
freshness: string;
|
||||||
|
market: string;
|
||||||
|
methodology: string;
|
||||||
|
methodologyIndexDescription: string;
|
||||||
|
methodologyIndexEyebrow: string;
|
||||||
|
methodologyIndexTitle: string;
|
||||||
|
methodologyLinks: string;
|
||||||
|
methodologyPanelBody: string;
|
||||||
|
methodologyPanelTitle: string;
|
||||||
|
openLiveTerminal: string;
|
||||||
|
readBrief: string;
|
||||||
|
readMethodology: string;
|
||||||
|
readSourceNote: string;
|
||||||
|
settlementSource: string;
|
||||||
|
shareOnX: string;
|
||||||
|
snapshot: string;
|
||||||
|
sourceIndexDescription: string;
|
||||||
|
sourceIndexEyebrow: string;
|
||||||
|
sourceIndexTitle: string;
|
||||||
|
sourceLinks: string;
|
||||||
|
sourceNotes: string;
|
||||||
|
sourcePanelBody: string;
|
||||||
|
sourcePanelTitle: string;
|
||||||
|
sources: string;
|
||||||
|
updated: string;
|
||||||
|
detailLabels: {
|
||||||
|
debRead: string;
|
||||||
|
modelContext: string;
|
||||||
|
riskNotes: string;
|
||||||
|
settlementSourceRead: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PUBLIC_CONTENT_COPY: Record<LandingLocale, PublicContentCopy> = {
|
||||||
|
"zh-CN": {
|
||||||
|
allPublicBriefs: "全部公开简报",
|
||||||
|
briefIndexDescription:
|
||||||
|
"公开天气市场简报把选定城市的市场读数整理为可索引证据:结算源、DEB 背景、模型分歧、新鲜度备注,以及明确的研究免责声明。",
|
||||||
|
briefIndexEyebrow: "天气市场简报",
|
||||||
|
briefIndexTitle: "公开天气市场简报",
|
||||||
|
checksBeforeActing: "行动前检查",
|
||||||
|
distributionCopy: "分发文案",
|
||||||
|
docs: "文档",
|
||||||
|
freshness: "新鲜度",
|
||||||
|
market: "市场",
|
||||||
|
methodology: "方法",
|
||||||
|
methodologyIndexDescription:
|
||||||
|
"公开方法页解释 PolyWeather 如何处理 DEB 融合、结算源优先级、新鲜度和来源校验。",
|
||||||
|
methodologyIndexEyebrow: "方法",
|
||||||
|
methodologyIndexTitle: "PolyWeather 如何读取天气市场",
|
||||||
|
methodologyLinks: "方法链接",
|
||||||
|
methodologyPanelBody:
|
||||||
|
"简报会交叉链接 DEB 方法和结算源优先级页面,让读者能审计为什么 PolyWeather 不把通用城市预报直接当作市场真实值。",
|
||||||
|
methodologyPanelTitle: "公开读数如何产生",
|
||||||
|
openLiveTerminal: "打开实时终端",
|
||||||
|
readBrief: "阅读简报",
|
||||||
|
readMethodology: "阅读方法",
|
||||||
|
readSourceNote: "阅读来源说明",
|
||||||
|
settlementSource: "结算源",
|
||||||
|
shareOnX: "分享到 X",
|
||||||
|
snapshot: "快照",
|
||||||
|
sourceIndexDescription:
|
||||||
|
"来源页把官方观测、机场观测和模型指引分开展示,方便读者审计 PolyWeather 为什么优先使用与结算相关的证据。",
|
||||||
|
sourceIndexEyebrow: "来源",
|
||||||
|
sourceIndexTitle: "用于公开审计的天气来源说明",
|
||||||
|
sourceLinks: "来源链接",
|
||||||
|
sourceNotes: "来源说明",
|
||||||
|
sourcePanelBody:
|
||||||
|
"来源页解释为什么 MGM、METAR、HKO、NOAA 和模型指引会在 PolyWeather 工作流中分开展示。",
|
||||||
|
sourcePanelTitle: "官方来源上下文",
|
||||||
|
sources: "来源",
|
||||||
|
updated: "更新",
|
||||||
|
detailLabels: {
|
||||||
|
debRead: "DEB 读数",
|
||||||
|
modelContext: "模型上下文",
|
||||||
|
riskNotes: "风险备注",
|
||||||
|
settlementSourceRead: "结算源读数",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"en-US": {
|
||||||
|
allPublicBriefs: "All public briefs",
|
||||||
|
briefIndexDescription:
|
||||||
|
"Public Weather Market Brief pages turn selected city-market reads into indexable evidence: settlement source, DEB context, model disagreement, freshness notes, and a clear research disclaimer.",
|
||||||
|
briefIndexEyebrow: "Weather Market Brief",
|
||||||
|
briefIndexTitle: "Public market briefs for temperature judgment",
|
||||||
|
checksBeforeActing: "Checks before acting",
|
||||||
|
distributionCopy: "Distribution copy",
|
||||||
|
docs: "Docs",
|
||||||
|
freshness: "Freshness",
|
||||||
|
market: "Market",
|
||||||
|
methodology: "Methodology",
|
||||||
|
methodologyIndexDescription:
|
||||||
|
"Public methodology pages explain how PolyWeather handles DEB blending, settlement-source priority, freshness, and source reconciliation for prediction-market weather analysis.",
|
||||||
|
methodologyIndexEyebrow: "Methodology",
|
||||||
|
methodologyIndexTitle: "How PolyWeather reads weather markets",
|
||||||
|
methodologyLinks: "Methodology links",
|
||||||
|
methodologyPanelBody:
|
||||||
|
"Briefs cross-link to the DEB methodology and settlement-source priority pages so readers can audit why PolyWeather does not treat generic city forecasts as market truth.",
|
||||||
|
methodologyPanelTitle: "How the public read is produced",
|
||||||
|
openLiveTerminal: "Open live terminal",
|
||||||
|
readBrief: "Read brief",
|
||||||
|
readMethodology: "Read methodology",
|
||||||
|
readSourceNote: "Read source note",
|
||||||
|
settlementSource: "Settlement source",
|
||||||
|
shareOnX: "Share on X",
|
||||||
|
snapshot: "Snapshot",
|
||||||
|
sourceIndexDescription:
|
||||||
|
"Source pages separate official observations, airport observations, and model guidance so readers can inspect why PolyWeather prioritizes settlement-relevant evidence.",
|
||||||
|
sourceIndexEyebrow: "Sources",
|
||||||
|
sourceIndexTitle: "Weather source notes for public audit",
|
||||||
|
sourceLinks: "Source links",
|
||||||
|
sourceNotes: "Source notes",
|
||||||
|
sourcePanelBody:
|
||||||
|
"Source pages explain why MGM, METAR, HKO, NOAA, and model guidance are displayed separately in PolyWeather workflows.",
|
||||||
|
sourcePanelTitle: "Official-source context",
|
||||||
|
sources: "Sources",
|
||||||
|
updated: "Updated",
|
||||||
|
detailLabels: {
|
||||||
|
debRead: "DEB read",
|
||||||
|
modelContext: "Model context",
|
||||||
|
riskNotes: "Risk notes",
|
||||||
|
settlementSourceRead: "Settlement-source read",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const PUBLIC_BRIEFS: PublicBrief[] = [
|
export const PUBLIC_BRIEFS: PublicBrief[] = [
|
||||||
{
|
{
|
||||||
city: "ankara",
|
city: "ankara",
|
||||||
@@ -384,6 +520,193 @@ export const SOURCE_PAGES: SourcePage[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const BRIEF_LOCALIZATIONS: Record<string, Partial<PublicBrief>> = {
|
||||||
|
"ankara:2026-06-24": {
|
||||||
|
cityName: "安卡拉",
|
||||||
|
countryName: "土耳其",
|
||||||
|
title: "安卡拉天气市场简报 - 2026年6月24日",
|
||||||
|
description:
|
||||||
|
"安卡拉最高温公开简报,聚焦 MGM 结算源行为、DEB 融合预报背景和异常值复核。",
|
||||||
|
market: "当日最高温判断",
|
||||||
|
settlementSource: "MGM 官方站",
|
||||||
|
dataFreshness:
|
||||||
|
"静态公开快照。付费终端用户行动前应核对最新官方观测和 SSE replay 状态。",
|
||||||
|
debRead:
|
||||||
|
"DEB 将日内最高温读数压在孤立 MGM 尖峰下方,更接近已观测的官方区间。",
|
||||||
|
sourceRead:
|
||||||
|
"MGM 被视为主要结算参考。单个 27.1 C 点位在接受为新高前,需要与相邻官方读数比对。",
|
||||||
|
modelRead:
|
||||||
|
"ECMWF 在午后窗口比 DEB 融合更暖,但公开简报把官方观测置于纯模型移动之上。",
|
||||||
|
riskRead:
|
||||||
|
"主要风险是晚些时候官方更新或来源侧修正,导致公开快照后的认可高温发生变化。",
|
||||||
|
notFinancialAdvice:
|
||||||
|
"本简报是用于预测市场准备的天气研究内容,不构成金融建议,也不保证结算结果。",
|
||||||
|
distributionText:
|
||||||
|
"安卡拉 2026-06-24 公开天气市场简报:MGM 官方读数更支持 24.5 C 已观测高温,而非孤立 27.1 C 尖峰;DEB 仍低于异常点。非金融建议。",
|
||||||
|
primaryCtaLabel: "打开实时终端",
|
||||||
|
signals: [
|
||||||
|
{
|
||||||
|
label: "目前官方高温",
|
||||||
|
value: "24.5 C",
|
||||||
|
detail: "用于对比任何孤立更高点的官方来源值。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "异常点待复核",
|
||||||
|
value: "27.1 C",
|
||||||
|
detail: "突然出现的单源值需要相邻时间验证。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "DEB 公开读数",
|
||||||
|
value: "低于尖峰",
|
||||||
|
detail: "融合路径仍更接近已验证观测带。",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
checkpoints: [
|
||||||
|
"检查疑似尖峰是否进入官方高温摘要。",
|
||||||
|
"在把单点视为结算相关之前,先比较相邻 MGM 观测。",
|
||||||
|
"市场关闭前查看付费终端的实时图表 patch 和来源新鲜度。",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"hong-kong:2026-06-24": {
|
||||||
|
cityName: "香港",
|
||||||
|
countryName: "香港",
|
||||||
|
title: "香港天气市场简报 - 2026年6月24日",
|
||||||
|
description:
|
||||||
|
"公开简报展示 PolyWeather 如何把 HKO/长洲/机场观测与 DEB、模型分歧放在一起解释最高温市场。",
|
||||||
|
market: "城市与机场最高温判断",
|
||||||
|
settlementSource: "HKO 官方网络",
|
||||||
|
dataFreshness:
|
||||||
|
"静态公开快照。HKO 和站点缓存刷新后,实时终端数值可能不同。",
|
||||||
|
debRead:
|
||||||
|
"到午后后段,实况观测与模型分歧趋于一致,DEB 支持较窄的高温窗口。",
|
||||||
|
sourceRead:
|
||||||
|
"解释机场单独移动前,应先把 HKO 网络观测作为需要校验的来源族。",
|
||||||
|
modelRead:
|
||||||
|
"模型分歧有限,因此来源新鲜度和站点选择比大尺度天气不确定性更重要。",
|
||||||
|
riskRead:
|
||||||
|
"主要风险是午后后段特定站点保温,或官方摘要出现较晚修订。",
|
||||||
|
notFinancialAdvice:
|
||||||
|
"本简报是用于预测市场准备的天气研究内容,不构成金融建议,也不保证结算结果。",
|
||||||
|
distributionText:
|
||||||
|
"香港 2026-06-24 公开天气市场简报:来源选择和 HKO 新鲜度比模型分歧更关键。非金融建议。",
|
||||||
|
primaryCtaLabel: "打开实时终端",
|
||||||
|
signals: [
|
||||||
|
{
|
||||||
|
label: "来源族",
|
||||||
|
value: "HKO",
|
||||||
|
detail: "先使用官方站上下文,再解释机场读数。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "模型分歧",
|
||||||
|
value: "低",
|
||||||
|
detail: "晚间不确定性主要来自站点行为。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "终端需要",
|
||||||
|
value: "新鲜度",
|
||||||
|
detail: "实时来源时间戳决定公开快照是否仍有用。",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
checkpoints: [
|
||||||
|
"比较市场温度桶前先确认 HKO 站点时间戳。",
|
||||||
|
"把机场 METAR 观测与 HKO 网络读数分开解释。",
|
||||||
|
"如果公开快照已超过一个刷新周期,检查终端来源健康状态。",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"new-york:2026-06-24": {
|
||||||
|
cityName: "纽约",
|
||||||
|
countryName: "美国",
|
||||||
|
title: "纽约天气市场简报 - 2026年6月24日",
|
||||||
|
description:
|
||||||
|
"纽约温度市场公开简报,连接 METAR、NOAA 上下文、DEB 融合和日内尾段风险检查。",
|
||||||
|
market: "机场关联最高温判断",
|
||||||
|
settlementSource: "METAR 与 NOAA 站点上下文",
|
||||||
|
dataFreshness:
|
||||||
|
"静态公开快照。付费终端用户应检查当前 METAR 观测和官方摘要。",
|
||||||
|
debRead:
|
||||||
|
"DEB 用最新观测趋势校验偏暖模型指引,而不是直接追随原始模型高温。",
|
||||||
|
sourceRead:
|
||||||
|
"METAR 提供快速机场证据,NOAA 上下文帮助确认机场读数是否具代表性。",
|
||||||
|
modelRead:
|
||||||
|
"偏暖模型指引只有在和机场实况、云量/风场上下文校验后才有价值。",
|
||||||
|
riskRead:
|
||||||
|
"主要风险是日内尾段云层短暂打开,将机场观测推入更高温度桶。",
|
||||||
|
notFinancialAdvice:
|
||||||
|
"本简报是用于预测市场准备的天气研究内容,不构成金融建议,也不保证结算结果。",
|
||||||
|
distributionText:
|
||||||
|
"纽约 2026-06-24 公开天气市场简报:DEB 将偏暖模型指引与机场实时证据融合。非金融建议。",
|
||||||
|
primaryCtaLabel: "打开实时终端",
|
||||||
|
signals: [
|
||||||
|
{
|
||||||
|
label: "快速证据",
|
||||||
|
value: "METAR",
|
||||||
|
detail: "机场观测定义短周期读数。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "复核",
|
||||||
|
value: "NOAA",
|
||||||
|
detail: "官方上下文帮助审计最终最高温解释。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "DEB 立场",
|
||||||
|
value: "融合",
|
||||||
|
detail: "没有实况确认,不追随偏暖模型运行。",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
checkpoints: [
|
||||||
|
"在每日高温窗口结束前观察最后两个 METAR 周期。",
|
||||||
|
"检查模型偏暖是否得到云量和风场观测支持。",
|
||||||
|
"最终解释结算前先使用官方来源上下文。",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const METHODOLOGY_TITLE_LOCALIZATIONS: Record<string, string> = {
|
||||||
|
deb: "DEB 预测方法",
|
||||||
|
"settlement-sources": "结算源优先级",
|
||||||
|
};
|
||||||
|
|
||||||
|
const SOURCE_TITLE_LOCALIZATIONS: Record<string, string> = {
|
||||||
|
ecmwf: "ECMWF 模型指引",
|
||||||
|
hko: "HKO 官方观测",
|
||||||
|
metar: "METAR 机场观测",
|
||||||
|
mgm: "MGM 天气来源",
|
||||||
|
noaa: "NOAA 天气上下文",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function localizeBrief(brief: PublicBrief, locale: LandingLocale): PublicBrief {
|
||||||
|
if (locale === "en-US") return brief;
|
||||||
|
const localized = BRIEF_LOCALIZATIONS[`${brief.city}:${brief.date}`] || {};
|
||||||
|
return {
|
||||||
|
...brief,
|
||||||
|
...localized,
|
||||||
|
checkpoints: localized.checkpoints || brief.checkpoints,
|
||||||
|
methodologySlugs: localized.methodologySlugs || brief.methodologySlugs,
|
||||||
|
signals: localized.signals || brief.signals,
|
||||||
|
sourceSlugs: localized.sourceSlugs || brief.sourceSlugs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function localizeBriefs(locale: LandingLocale) {
|
||||||
|
return PUBLIC_BRIEFS.map((brief) => localizeBrief(brief, locale));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function localizeMethodologyPage(page: MethodologyPage, locale: LandingLocale): MethodologyPage {
|
||||||
|
if (locale === "en-US") return page;
|
||||||
|
return {
|
||||||
|
...page,
|
||||||
|
title: METHODOLOGY_TITLE_LOCALIZATIONS[page.slug] || page.title,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function localizeSourcePage(page: SourcePage, locale: LandingLocale): SourcePage {
|
||||||
|
if (locale === "en-US") return page;
|
||||||
|
return {
|
||||||
|
...page,
|
||||||
|
title: SOURCE_TITLE_LOCALIZATIONS[page.slug] || page.title,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function briefPath(brief: PublicBrief) {
|
export function briefPath(brief: PublicBrief) {
|
||||||
return `/briefs/${brief.city}/${brief.date}`;
|
return `/briefs/${brief.city}/${brief.date}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -880,6 +880,7 @@ def update_daily_record(
|
|||||||
shadow_probabilities=None,
|
shadow_probabilities=None,
|
||||||
calibration_summary=None,
|
calibration_summary=None,
|
||||||
hourly_error=None,
|
hourly_error=None,
|
||||||
|
actual_is_final=True,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
保存/更新某城市某天的各个模型预报与最终实测值
|
保存/更新某城市某天的各个模型预报与最终实测值
|
||||||
@@ -986,7 +987,7 @@ def update_daily_record(
|
|||||||
|
|
||||||
next_mu = round(mu, 2) if mu is not None else None
|
next_mu = round(mu, 2) if mu is not None else None
|
||||||
if (
|
if (
|
||||||
old_actual == actual_high
|
old_actual == (actual_high if actual_is_final else old_actual)
|
||||||
and old_forecasts == merged_forecasts
|
and old_forecasts == merged_forecasts
|
||||||
and (deb_prediction is None or old_deb == deb_prediction)
|
and (deb_prediction is None or old_deb == deb_prediction)
|
||||||
and (mu is None or old_mu == next_mu)
|
and (mu is None or old_mu == next_mu)
|
||||||
@@ -1007,15 +1008,21 @@ def update_daily_record(
|
|||||||
):
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
# actual_high 应该是日内最高温,理论上不应下降;防止异常写入覆盖已确认高值
|
# Only final settlement truth may update actual_high. Intraday max_so_far is
|
||||||
if old_actual is not None and actual_high is not None:
|
# useful context, but treating it as settled truth poisons DEB training.
|
||||||
try:
|
next_actual_high = old_actual
|
||||||
actual_high = max(float(old_actual), float(actual_high))
|
if actual_is_final:
|
||||||
except Exception:
|
next_actual_high = actual_high
|
||||||
pass
|
# actual_high 应该是日内最高温,理论上不应下降;防止异常写入覆盖已确认高值
|
||||||
|
if old_actual is not None and actual_high is not None:
|
||||||
|
try:
|
||||||
|
next_actual_high = max(float(old_actual), float(actual_high))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
existing["forecasts"] = merged_forecasts
|
existing["forecasts"] = merged_forecasts
|
||||||
existing["actual_high"] = actual_high
|
if actual_is_final or next_actual_high is not None:
|
||||||
|
existing["actual_high"] = next_actual_high
|
||||||
if deb_prediction is not None:
|
if deb_prediction is not None:
|
||||||
existing["deb_prediction"] = deb_prediction
|
existing["deb_prediction"] = deb_prediction
|
||||||
if mu is not None:
|
if mu is not None:
|
||||||
@@ -1031,16 +1038,16 @@ def update_daily_record(
|
|||||||
if next_hourly_error is not None:
|
if next_hourly_error is not None:
|
||||||
existing["hourly_error"] = next_hourly_error
|
existing["hourly_error"] = next_hourly_error
|
||||||
|
|
||||||
if actual_high is not None:
|
if actual_is_final and next_actual_high is not None:
|
||||||
try:
|
try:
|
||||||
_persist_truth_record(
|
_persist_truth_record(
|
||||||
city_name,
|
city_name,
|
||||||
date_str,
|
date_str,
|
||||||
float(actual_high),
|
float(next_actual_high),
|
||||||
updated_by="runtime:update_daily_record",
|
updated_by="runtime:update_daily_record",
|
||||||
reason="update_daily_record",
|
reason="update_daily_record",
|
||||||
source_payload={
|
source_payload={
|
||||||
"actual_high": actual_high,
|
"actual_high": next_actual_high,
|
||||||
"deb_prediction": deb_prediction,
|
"deb_prediction": deb_prediction,
|
||||||
"mu": next_mu,
|
"mu": next_mu,
|
||||||
},
|
},
|
||||||
@@ -1161,8 +1168,12 @@ def calculate_dynamic_weight_components(
|
|||||||
sorted_dates = sorted(city_data.keys(), reverse=True)
|
sorted_dates = sorted(city_data.keys(), reverse=True)
|
||||||
today_str = datetime.now().strftime("%Y-%m-%d")
|
today_str = datetime.now().strftime("%Y-%m-%d")
|
||||||
available_days = sum(
|
available_days = sum(
|
||||||
1 for d in sorted_dates
|
1
|
||||||
if d != today_str and city_data[d].get("actual_high") is not None
|
for d in sorted_dates
|
||||||
|
if d != today_str
|
||||||
|
and city_data[d].get("actual_high") is not None
|
||||||
|
and isinstance(city_data[d].get("forecasts"), dict)
|
||||||
|
and any(v is not None for v in city_data[d].get("forecasts", {}).values())
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── 改进3: 自适应 lookback — 数据多的城市用更多历史 ──
|
# ── 改进3: 自适应 lookback — 数据多的城市用更多历史 ──
|
||||||
@@ -1187,6 +1198,7 @@ def calculate_dynamic_weight_components(
|
|||||||
if actual is None:
|
if actual is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
usable_day = False
|
||||||
for model in forecasts.keys():
|
for model in forecasts.keys():
|
||||||
if model in past_forecasts and past_forecasts[model] is not None:
|
if model in past_forecasts and past_forecasts[model] is not None:
|
||||||
try:
|
try:
|
||||||
@@ -1194,6 +1206,7 @@ def calculate_dynamic_weight_components(
|
|||||||
av = float(actual)
|
av = float(actual)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
continue
|
continue
|
||||||
|
usable_day = True
|
||||||
# Track signed error for bias
|
# Track signed error for bias
|
||||||
model_biases[model] += (pv - av) # positive = model overpredicts
|
model_biases[model] += (pv - av) # positive = model overpredicts
|
||||||
bias_samples[model] += 1
|
bias_samples[model] += 1
|
||||||
@@ -1208,6 +1221,8 @@ def calculate_dynamic_weight_components(
|
|||||||
decay_weight = decay_factor ** days_used
|
decay_weight = decay_factor ** days_used
|
||||||
errors[model].append((blended_error, decay_weight))
|
errors[model].append((blended_error, decay_weight))
|
||||||
|
|
||||||
|
if not usable_day:
|
||||||
|
continue
|
||||||
days_used += 1
|
days_used += 1
|
||||||
if days_used >= effective_lookback:
|
if days_used >= effective_lookback:
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -974,6 +974,7 @@ def analyze_weather_trend(
|
|||||||
deb_prediction=_deb_to_save,
|
deb_prediction=_deb_to_save,
|
||||||
mu=mu,
|
mu=mu,
|
||||||
probabilities=_prob_list,
|
probabilities=_prob_list,
|
||||||
|
actual_is_final=False,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
from src.database.runtime_state import (
|
||||||
|
DailyRecordRepository,
|
||||||
|
RuntimeStateDB,
|
||||||
|
TrainingFeatureRecordRepository,
|
||||||
|
TruthRecordRepository,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _wire_runtime_repos(monkeypatch, tmp_path):
|
||||||
|
from src.analysis import deb_algorithm
|
||||||
|
|
||||||
|
db = RuntimeStateDB(str(tmp_path / "polyweather.db"))
|
||||||
|
daily_repo = DailyRecordRepository(db)
|
||||||
|
truth_repo = TruthRecordRepository(db)
|
||||||
|
feature_repo = TrainingFeatureRecordRepository(db)
|
||||||
|
monkeypatch.setattr(deb_algorithm, "_daily_record_repo", daily_repo)
|
||||||
|
monkeypatch.setattr(deb_algorithm, "_truth_record_repo", truth_repo)
|
||||||
|
monkeypatch.setattr(deb_algorithm, "_training_feature_repo", feature_repo)
|
||||||
|
monkeypatch.setattr(deb_algorithm, "_history_cache", {})
|
||||||
|
monkeypatch.setattr(deb_algorithm, "_history_mtime", 0)
|
||||||
|
monkeypatch.setenv("POLYWEATHER_STATE_STORAGE_MODE", "sqlite")
|
||||||
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
def test_intraday_daily_record_does_not_persist_unfinal_actual_as_truth(monkeypatch, tmp_path):
|
||||||
|
from src.analysis.deb_algorithm import update_daily_record
|
||||||
|
|
||||||
|
db = _wire_runtime_repos(monkeypatch, tmp_path)
|
||||||
|
|
||||||
|
update_daily_record(
|
||||||
|
"ankara",
|
||||||
|
"2026-06-24",
|
||||||
|
{"ECMWF": 28.0, "GFS": 27.0},
|
||||||
|
24.0,
|
||||||
|
deb_prediction=27.5,
|
||||||
|
mu=27.2,
|
||||||
|
actual_is_final=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
with db.connect() as conn:
|
||||||
|
daily = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT actual_high, deb_prediction, mu, payload_json
|
||||||
|
FROM daily_records_store
|
||||||
|
WHERE city = ? AND target_date = ?
|
||||||
|
""",
|
||||||
|
("ankara", "2026-06-24"),
|
||||||
|
).fetchone()
|
||||||
|
truth_count = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT count(*) AS n
|
||||||
|
FROM truth_records_store
|
||||||
|
WHERE city = ? AND target_date = ?
|
||||||
|
""",
|
||||||
|
("ankara", "2026-06-24"),
|
||||||
|
).fetchone()["n"]
|
||||||
|
|
||||||
|
assert daily is not None
|
||||||
|
assert daily["actual_high"] is None
|
||||||
|
assert daily["deb_prediction"] == 27.5
|
||||||
|
assert daily["mu"] == 27.2
|
||||||
|
assert truth_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_dynamic_weights_skip_actual_only_rows_when_selecting_recent_training_days(monkeypatch):
|
||||||
|
from src.analysis.deb_algorithm import calculate_dynamic_weight_components
|
||||||
|
|
||||||
|
history = {"ankara": {}}
|
||||||
|
for day in range(23, 15, -1):
|
||||||
|
history["ankara"][f"2026-06-{day:02d}"] = {"actual_high": 20.0}
|
||||||
|
history["ankara"]["2026-06-15"] = {
|
||||||
|
"actual_high": 20.0,
|
||||||
|
"forecasts": {"ECMWF": 20.0, "GFS": 30.0},
|
||||||
|
}
|
||||||
|
history["ankara"]["2026-06-14"] = {
|
||||||
|
"actual_high": 20.0,
|
||||||
|
"forecasts": {"ECMWF": 20.0, "GFS": 30.0},
|
||||||
|
}
|
||||||
|
monkeypatch.setattr("src.analysis.deb_algorithm.load_history", lambda _path: history)
|
||||||
|
|
||||||
|
components = calculate_dynamic_weight_components(
|
||||||
|
"ankara",
|
||||||
|
{"ECMWF": 20.0, "GFS": 30.0},
|
||||||
|
lookback_days=7,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert components["weights"]["ECMWF"] > components["weights"]["GFS"]
|
||||||
|
assert components["prediction"] < 25.0
|
||||||
Reference in New Issue
Block a user