Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a5bab9fad | |||
| 0fef948fac | |||
| 07c5451f1d |
@@ -1,17 +1,38 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { BriefDetailPageView } from "@/components/public-content/PublicContentPages";
|
||||
import {
|
||||
LANDING_LOCALE_COOKIE,
|
||||
LANDING_LOCALE_QUERY_PARAM,
|
||||
pickLandingLocale,
|
||||
type LandingLocale,
|
||||
} from "@/components/landing/landingLocale";
|
||||
import {
|
||||
PUBLIC_BRIEFS,
|
||||
absolutePublicUrl,
|
||||
briefPath,
|
||||
getBrief,
|
||||
localizeBrief,
|
||||
} from "@/content/public-content";
|
||||
|
||||
type BriefPageParams = {
|
||||
city: 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() {
|
||||
return PUBLIC_BRIEFS.map((brief) => ({
|
||||
@@ -22,10 +43,15 @@ export function generateStaticParams() {
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<BriefPageParams>;
|
||||
searchParams: BriefSearchParams;
|
||||
}): Promise<Metadata> {
|
||||
const { city, date } = await params;
|
||||
const [{ city, date }, locale] = await Promise.all([
|
||||
params,
|
||||
resolvePublicContentLocale(searchParams),
|
||||
]);
|
||||
const brief = getBrief(city, date);
|
||||
|
||||
if (!brief) {
|
||||
@@ -34,51 +60,58 @@ export async function generateMetadata({
|
||||
};
|
||||
}
|
||||
|
||||
const pathname = briefPath(brief);
|
||||
const localizedBrief = localizeBrief(brief, locale);
|
||||
const pathname = briefPath(localizedBrief);
|
||||
|
||||
return {
|
||||
title: brief.title,
|
||||
description: brief.description,
|
||||
title: localizedBrief.title,
|
||||
description: localizedBrief.description,
|
||||
alternates: {
|
||||
canonical: pathname,
|
||||
},
|
||||
openGraph: {
|
||||
type: "article",
|
||||
title: brief.title,
|
||||
description: brief.description,
|
||||
title: localizedBrief.title,
|
||||
description: localizedBrief.description,
|
||||
url: pathname,
|
||||
publishedTime: brief.publishedAt,
|
||||
modifiedTime: brief.updatedAt,
|
||||
publishedTime: localizedBrief.publishedAt,
|
||||
modifiedTime: localizedBrief.updatedAt,
|
||||
},
|
||||
twitter: {
|
||||
card: "summary",
|
||||
title: brief.title,
|
||||
description: brief.description,
|
||||
title: localizedBrief.title,
|
||||
description: localizedBrief.description,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function BriefDetailPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
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);
|
||||
|
||||
if (!brief) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const pathname = briefPath(brief);
|
||||
const localizedBrief = localizeBrief(brief, locale);
|
||||
const pathname = briefPath(localizedBrief);
|
||||
const jsonLd = [
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Article",
|
||||
headline: brief.title,
|
||||
description: brief.description,
|
||||
datePublished: brief.publishedAt,
|
||||
dateModified: brief.updatedAt,
|
||||
headline: localizedBrief.title,
|
||||
description: localizedBrief.description,
|
||||
datePublished: localizedBrief.publishedAt,
|
||||
dateModified: localizedBrief.updatedAt,
|
||||
mainEntityOfPage: absolutePublicUrl(pathname),
|
||||
author: {
|
||||
"@type": "Organization",
|
||||
@@ -91,9 +124,9 @@ export default async function BriefDetailPage({
|
||||
url: "https://polyweather.top",
|
||||
},
|
||||
about: [
|
||||
brief.cityName,
|
||||
brief.market,
|
||||
brief.settlementSource,
|
||||
localizedBrief.cityName,
|
||||
localizedBrief.market,
|
||||
localizedBrief.settlementSource,
|
||||
"DEB forecast methodology",
|
||||
],
|
||||
},
|
||||
@@ -110,7 +143,7 @@ export default async function BriefDetailPage({
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 2,
|
||||
name: brief.cityName,
|
||||
name: localizedBrief.cityName,
|
||||
item: absolutePublicUrl(pathname),
|
||||
},
|
||||
],
|
||||
@@ -123,7 +156,7 @@ export default async function BriefDetailPage({
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
<BriefDetailPageView brief={brief} />
|
||||
<BriefDetailPageView brief={brief} locale={locale} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,54 @@
|
||||
import type { Metadata } from "next";
|
||||
import { cookies, headers } from "next/headers";
|
||||
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 = {
|
||||
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",
|
||||
},
|
||||
};
|
||||
type BriefsSearchParams = Promise<Record<string, string | string[] | undefined>>;
|
||||
|
||||
export default function BriefsPage() {
|
||||
return <BriefsIndexPageView />;
|
||||
async function resolvePublicContentLocale(searchParams: BriefsSearchParams): 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: 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} />;
|
||||
}
|
||||
|
||||
@@ -94,6 +94,12 @@ export function runTests() {
|
||||
paymentFlowSource.includes("/validate"),
|
||||
"manual payment flow must validate tx hashes with the backend before submission",
|
||||
);
|
||||
assert(
|
||||
paymentFlowSource.includes("confirmRes.status === 503") &&
|
||||
paymentFlowSource.includes('lowerRaw.includes("cannot connect payment rpc")') &&
|
||||
paymentFlowSource.includes('lowerRaw.includes("payment rpc chain mismatch")'),
|
||||
"manual payment confirm must treat transient payment RPC failures as pending after tx hash submission",
|
||||
);
|
||||
assert(
|
||||
paymentFlowSource.includes("await waitForReceipt(txHashNorm, eth)") &&
|
||||
paymentFlowSource.indexOf("await waitForReceipt(txHashNorm, eth)") <
|
||||
|
||||
@@ -826,6 +826,9 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
const lowerRaw = raw.toLowerCase();
|
||||
const maybePending =
|
||||
confirmRes.status === 408 ||
|
||||
(confirmRes.status === 503 &&
|
||||
(lowerRaw.includes("cannot connect payment rpc") ||
|
||||
lowerRaw.includes("payment rpc chain mismatch"))) ||
|
||||
(confirmRes.status === 409 && (lowerRaw.includes("confirmations not enough") || lowerRaw.includes("tx indexed partially")));
|
||||
if (maybePending) {
|
||||
setPaymentInfo(`交易已提交: ${shortAddress(txHashNorm)},等待链上确认中...`);
|
||||
|
||||
@@ -325,7 +325,7 @@ function InstitutionalLandingScreen({ locale }: { locale: LandingLocale }) {
|
||||
{isEn ? "Guide" : "读图"}
|
||||
</Link>
|
||||
<Link href="/briefs" className="hover:text-slate-950">
|
||||
Weather Market Brief
|
||||
{isEn ? "Briefs" : "简报"}
|
||||
</Link>
|
||||
<a href="#pricing" className="hover:text-slate-950">
|
||||
{isEn ? "Pricing" : "定价"}
|
||||
|
||||
@@ -105,7 +105,7 @@ export function runTests() {
|
||||
);
|
||||
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("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("yhrsc30@gmail.com"), "landing page must show the operator contact email");
|
||||
assert(source.includes("mailto:${CONTACT_EMAIL}"), "landing contact email must be clickable");
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import Link from "next/link";
|
||||
import { LandingLocaleToggle } from "@/components/landing/LandingLocaleToggle";
|
||||
import type { LandingLocale } from "@/components/landing/landingLocale";
|
||||
import {
|
||||
METHODOLOGY_PAGES,
|
||||
PUBLIC_CONTENT_COPY,
|
||||
PUBLIC_BRIEFS,
|
||||
SOURCE_PAGES,
|
||||
absolutePublicUrl,
|
||||
briefPath,
|
||||
methodologyPath,
|
||||
sourcePath,
|
||||
localizeBrief,
|
||||
localizeBriefs,
|
||||
localizeMethodologyPage,
|
||||
localizeSourcePage,
|
||||
type MethodologyPage,
|
||||
type PublicBrief,
|
||||
type SourcePage,
|
||||
@@ -31,27 +38,32 @@ const primaryButton =
|
||||
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";
|
||||
|
||||
function PublicHeader() {
|
||||
function PublicHeader({ locale = "en-US" }: { locale?: LandingLocale }) {
|
||||
const copy = PUBLIC_CONTENT_COPY[locale];
|
||||
|
||||
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="/">
|
||||
PolyWeather
|
||||
</Link>
|
||||
<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">
|
||||
Briefs
|
||||
</Link>
|
||||
<Link className="rounded-md px-2.5 py-1.5 hover:bg-slate-100" href="/methodology">
|
||||
Methodology
|
||||
</Link>
|
||||
<Link className="rounded-md px-2.5 py-1.5 hover:bg-slate-100" href="/sources">
|
||||
Sources
|
||||
</Link>
|
||||
<Link className="rounded-md px-2.5 py-1.5 hover:bg-slate-100" href="/docs/intro">
|
||||
Docs
|
||||
</Link>
|
||||
</nav>
|
||||
<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">
|
||||
{locale === "en-US" ? "Briefs" : "简报"}
|
||||
</Link>
|
||||
<Link className="rounded-md px-2.5 py-1.5 hover:bg-slate-100" href="/methodology">
|
||||
{copy.methodology}
|
||||
</Link>
|
||||
<Link className="rounded-md px-2.5 py-1.5 hover:bg-slate-100" href="/sources">
|
||||
{copy.sources}
|
||||
</Link>
|
||||
<Link className="rounded-md px-2.5 py-1.5 hover:bg-slate-100" href="/docs/intro">
|
||||
{copy.docs}
|
||||
</Link>
|
||||
</nav>
|
||||
<LandingLocaleToggle locale={locale} />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
@@ -81,19 +93,20 @@ function PageIntro({
|
||||
);
|
||||
}
|
||||
|
||||
function SourceLinks({ slugs }: { slugs: string[] }) {
|
||||
function SourceLinks({ locale = "en-US", slugs }: { locale?: LandingLocale; slugs: string[] }) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{slugs.map((slug) => {
|
||||
const source = SOURCE_PAGES.find((entry) => entry.slug === slug);
|
||||
if (!source) return null;
|
||||
const localizedSource = localizeSourcePage(source, locale);
|
||||
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(source)}
|
||||
href={sourcePath(localizedSource)}
|
||||
key={slug}
|
||||
>
|
||||
{source.title}
|
||||
{localizedSource.title}
|
||||
</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 (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{slugs.map((slug) => {
|
||||
const page = METHODOLOGY_PAGES.find((entry) => entry.slug === slug);
|
||||
if (!page) return null;
|
||||
const localizedPage = localizeMethodologyPage(page, locale);
|
||||
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(page)}
|
||||
href={methodologyPath(localizedPage)}
|
||||
key={slug}
|
||||
>
|
||||
{page.title}
|
||||
{localizedPage.title}
|
||||
</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 (
|
||||
<article className={`${panel} flex flex-col gap-5 p-5`}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
@@ -149,52 +171,60 @@ function BriefCard({ brief }: { brief: PublicBrief }) {
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Link className={secondaryButton} href={briefPath(brief)}>
|
||||
Read brief
|
||||
{readBriefLabel}
|
||||
</Link>
|
||||
<SourceLinks slugs={brief.sourceSlugs.slice(0, 2)} />
|
||||
<SourceLinks locale={locale} slugs={brief.sourceSlugs.slice(0, 2)} />
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function BriefsIndexPageView() {
|
||||
export function BriefsIndexPageView({ locale = "en-US" }: { locale?: LandingLocale }) {
|
||||
const copy = PUBLIC_CONTENT_COPY[locale];
|
||||
const briefs = localizeBriefs(locale);
|
||||
|
||||
return (
|
||||
<div className={pageShell}>
|
||||
<PublicHeader />
|
||||
<PublicHeader locale={locale} />
|
||||
<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."
|
||||
eyebrow="Weather Market Brief"
|
||||
title="Public market briefs for temperature judgment"
|
||||
description={copy.briefIndexDescription}
|
||||
eyebrow={copy.briefIndexEyebrow}
|
||||
title={copy.briefIndexTitle}
|
||||
/>
|
||||
<div className={contentWrap}>
|
||||
<section className="grid gap-4">
|
||||
{PUBLIC_BRIEFS.map((brief) => (
|
||||
<BriefCard brief={brief} key={`${brief.city}-${brief.date}`} />
|
||||
{briefs.map((brief) => (
|
||||
<BriefCard
|
||||
brief={brief}
|
||||
key={`${brief.city}-${brief.date}`}
|
||||
locale={locale}
|
||||
readBriefLabel={copy.readBrief}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
<section className={`${panel} grid gap-5 p-5 md:grid-cols-2`}>
|
||||
<div>
|
||||
<p className={sectionTitle}>Methodology</p>
|
||||
<p className={sectionTitle}>{copy.methodology}</p>
|
||||
<h2 className="mt-2 text-2xl font-black text-slate-950">
|
||||
How the public read is produced
|
||||
{copy.methodologyPanelTitle}
|
||||
</h2>
|
||||
<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>
|
||||
<div className="mt-4">
|
||||
<MethodologyLinks slugs={["deb", "settlement-sources"]} />
|
||||
<MethodologyLinks locale={locale} slugs={["deb", "settlement-sources"]} />
|
||||
</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">
|
||||
Official-source context
|
||||
{copy.sourcePanelTitle}
|
||||
</h2>
|
||||
<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>
|
||||
<div className="mt-4">
|
||||
<SourceLinks slugs={["mgm", "metar", "hko", "noaa"]} />
|
||||
<SourceLinks locale={locale} slugs={["mgm", "metar", "hko", "noaa"]} />
|
||||
</div>
|
||||
</div>
|
||||
</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 (
|
||||
<div className={pageShell}>
|
||||
<PublicContentAnalytics
|
||||
eventType="brief_view"
|
||||
onceKey={`brief:${brief.city}:${brief.date}`}
|
||||
payload={{ city: brief.city, date: brief.date, source: brief.settlementSource }}
|
||||
onceKey={`brief:${localizedBrief.city}:${localizedBrief.date}`}
|
||||
payload={{ city: localizedBrief.city, date: localizedBrief.date, source: localizedBrief.settlementSource }}
|
||||
/>
|
||||
<PublicHeader />
|
||||
<PublicHeader locale={locale} />
|
||||
<PageIntro
|
||||
description={brief.description}
|
||||
eyebrow={`${brief.cityName}, ${brief.countryName} / ${brief.date}`}
|
||||
title={brief.title}
|
||||
description={localizedBrief.description}
|
||||
eyebrow={`${localizedBrief.cityName}, ${localizedBrief.countryName} / ${localizedBrief.date}`}
|
||||
title={localizedBrief.title}
|
||||
/>
|
||||
<div className={contentWrap}>
|
||||
<section className="grid gap-5 lg:grid-cols-[1.6fr_0.9fr]">
|
||||
<article className={`${panel} p-5`}>
|
||||
<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}>
|
||||
<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>
|
||||
@@ -230,30 +269,30 @@ export function BriefDetailPageView({ brief }: { brief: PublicBrief }) {
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-6 grid gap-5">
|
||||
<BriefSection title="DEB read" body={brief.debRead} />
|
||||
<BriefSection title="Settlement-source read" body={brief.sourceRead} />
|
||||
<BriefSection title="Model context" body={brief.modelRead} />
|
||||
<BriefSection title="Risk notes" body={brief.riskRead} />
|
||||
<BriefSection title={copy.detailLabels.debRead} body={localizedBrief.debRead} />
|
||||
<BriefSection title={copy.detailLabels.settlementSourceRead} body={localizedBrief.sourceRead} />
|
||||
<BriefSection title={copy.detailLabels.modelContext} body={localizedBrief.modelRead} />
|
||||
<BriefSection title={copy.detailLabels.riskNotes} body={localizedBrief.riskRead} />
|
||||
</div>
|
||||
</article>
|
||||
<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">
|
||||
<InfoRow label="Market" value={brief.market} />
|
||||
<InfoRow label="Settlement source" value={brief.settlementSource} />
|
||||
<InfoRow label="Updated" value={formatDateTime(brief.updatedAt)} />
|
||||
<InfoRow label="Freshness" value={brief.dataFreshness} />
|
||||
<InfoRow label={copy.market} value={localizedBrief.market} />
|
||||
<InfoRow label={copy.settlementSource} value={localizedBrief.settlementSource} />
|
||||
<InfoRow label={copy.updated} value={formatDateTime(localizedBrief.updatedAt, locale)} />
|
||||
<InfoRow label={copy.freshness} value={localizedBrief.dataFreshness} />
|
||||
</dl>
|
||||
<div className="mt-5 flex flex-col gap-3">
|
||||
<PublicContentCta
|
||||
className={primaryButton}
|
||||
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>
|
||||
<Link className={secondaryButton} href="/briefs">
|
||||
All public briefs
|
||||
{copy.allPublicBriefs}
|
||||
</Link>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -261,9 +300,9 @@ export function BriefDetailPageView({ brief }: { brief: PublicBrief }) {
|
||||
|
||||
<section className="grid gap-5 lg:grid-cols-[1fr_1fr]">
|
||||
<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">
|
||||
{brief.checkpoints.map((checkpoint) => (
|
||||
{localizedBrief.checkpoints.map((checkpoint) => (
|
||||
<li className={bodyText} key={checkpoint}>
|
||||
{checkpoint}
|
||||
</li>
|
||||
@@ -271,15 +310,15 @@ export function BriefDetailPageView({ brief }: { brief: PublicBrief }) {
|
||||
</ul>
|
||||
</div>
|
||||
<div className={`${panel} p-5`}>
|
||||
<p className={sectionTitle}>Distribution copy</p>
|
||||
<p className={`${bodyText} mt-4`}>{brief.distributionText}</p>
|
||||
<p className={sectionTitle}>{copy.distributionCopy}</p>
|
||||
<p className={`${bodyText} mt-4`}>{localizedBrief.distributionText}</p>
|
||||
<div className="mt-4">
|
||||
<PublicContentOutboundLink
|
||||
className={secondaryButton}
|
||||
href={`https://twitter.com/intent/tweet?text=${encodeURIComponent(brief.distributionText)}&url=${encodeURIComponent(absolutePublicUrl(briefPath(brief)))}`}
|
||||
payload={{ city: brief.city, date: brief.date, destination: "x_intent" }}
|
||||
href={`https://twitter.com/intent/tweet?text=${encodeURIComponent(localizedBrief.distributionText)}&url=${encodeURIComponent(absolutePublicUrl(briefPath(localizedBrief)))}`}
|
||||
payload={{ city: localizedBrief.city, date: localizedBrief.date, destination: "x_intent" }}
|
||||
>
|
||||
Share on X
|
||||
{copy.shareOnX}
|
||||
</PublicContentOutboundLink>
|
||||
</div>
|
||||
</div>
|
||||
@@ -287,21 +326,21 @@ export function BriefDetailPageView({ brief }: { brief: PublicBrief }) {
|
||||
|
||||
<section className={`${panel} grid gap-5 p-5 md:grid-cols-2`}>
|
||||
<div>
|
||||
<p className={sectionTitle}>Methodology links</p>
|
||||
<p className={sectionTitle}>{copy.methodologyLinks}</p>
|
||||
<div className="mt-4">
|
||||
<MethodologyLinks slugs={brief.methodologySlugs} />
|
||||
<MethodologyLinks locale={locale} slugs={localizedBrief.methodologySlugs} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className={sectionTitle}>Source links</p>
|
||||
<p className={sectionTitle}>{copy.sourceLinks}</p>
|
||||
<div className="mt-4">
|
||||
<SourceLinks slugs={brief.sourceSlugs} />
|
||||
<SourceLinks locale={locale} slugs={localizedBrief.sourceSlugs} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -453,8 +492,8 @@ export function SourceDetailPageView({ source }: { source: SourcePage }) {
|
||||
);
|
||||
}
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
return new Intl.DateTimeFormat("en", {
|
||||
function formatDateTime(value: string, locale: LandingLocale = "en-US") {
|
||||
return new Intl.DateTimeFormat(locale === "en-US" ? "en" : "zh-CN", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(new Date(value));
|
||||
|
||||
@@ -59,6 +59,15 @@ export function runTests() {
|
||||
content.includes("distributionText"),
|
||||
"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(
|
||||
briefDetail.includes("generateStaticParams") &&
|
||||
briefDetail.includes("generateMetadata") &&
|
||||
@@ -77,11 +86,18 @@ export function runTests() {
|
||||
"methodology and source detail routes must expose structured data for GEO/SEO",
|
||||
);
|
||||
assert(
|
||||
publicPages.includes('MethodologyLinks slugs={["deb", "settlement-sources"]}') &&
|
||||
publicPages.includes('SourceLinks slugs={["mgm", "metar", "hko", "noaa"]}') &&
|
||||
publicPages.includes('MethodologyLinks locale={locale} slugs={["deb", "settlement-sources"]}') &&
|
||||
publicPages.includes('SourceLinks locale={locale} slugs={["mgm", "metar", "hko", "noaa"]}') &&
|
||||
briefsIndex.includes("Weather Market Brief"),
|
||||
"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(
|
||||
analytics.includes('"brief_view"') &&
|
||||
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 type PublicBriefSignal = {
|
||||
@@ -57,6 +59,140 @@ export type SourcePage = {
|
||||
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[] = [
|
||||
{
|
||||
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) {
|
||||
return `/briefs/${brief.city}/${brief.date}`;
|
||||
}
|
||||
|
||||
@@ -880,6 +880,7 @@ def update_daily_record(
|
||||
shadow_probabilities=None,
|
||||
calibration_summary=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
|
||||
if (
|
||||
old_actual == actual_high
|
||||
old_actual == (actual_high if actual_is_final else old_actual)
|
||||
and old_forecasts == merged_forecasts
|
||||
and (deb_prediction is None or old_deb == deb_prediction)
|
||||
and (mu is None or old_mu == next_mu)
|
||||
@@ -1007,15 +1008,21 @@ def update_daily_record(
|
||||
):
|
||||
return
|
||||
|
||||
# actual_high 应该是日内最高温,理论上不应下降;防止异常写入覆盖已确认高值
|
||||
if old_actual is not None and actual_high is not None:
|
||||
try:
|
||||
actual_high = max(float(old_actual), float(actual_high))
|
||||
except Exception:
|
||||
pass
|
||||
# Only final settlement truth may update actual_high. Intraday max_so_far is
|
||||
# useful context, but treating it as settled truth poisons DEB training.
|
||||
next_actual_high = old_actual
|
||||
if actual_is_final:
|
||||
next_actual_high = actual_high
|
||||
# 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["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:
|
||||
existing["deb_prediction"] = deb_prediction
|
||||
if mu is not None:
|
||||
@@ -1031,16 +1038,16 @@ def update_daily_record(
|
||||
if next_hourly_error is not None:
|
||||
existing["hourly_error"] = next_hourly_error
|
||||
|
||||
if actual_high is not None:
|
||||
if actual_is_final and next_actual_high is not None:
|
||||
try:
|
||||
_persist_truth_record(
|
||||
city_name,
|
||||
date_str,
|
||||
float(actual_high),
|
||||
float(next_actual_high),
|
||||
updated_by="runtime:update_daily_record",
|
||||
reason="update_daily_record",
|
||||
source_payload={
|
||||
"actual_high": actual_high,
|
||||
"actual_high": next_actual_high,
|
||||
"deb_prediction": deb_prediction,
|
||||
"mu": next_mu,
|
||||
},
|
||||
@@ -1161,8 +1168,12 @@ def calculate_dynamic_weight_components(
|
||||
sorted_dates = sorted(city_data.keys(), reverse=True)
|
||||
today_str = datetime.now().strftime("%Y-%m-%d")
|
||||
available_days = sum(
|
||||
1 for d in sorted_dates
|
||||
if d != today_str and city_data[d].get("actual_high") is not None
|
||||
1
|
||||
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 — 数据多的城市用更多历史 ──
|
||||
@@ -1187,6 +1198,7 @@ def calculate_dynamic_weight_components(
|
||||
if actual is None:
|
||||
continue
|
||||
|
||||
usable_day = False
|
||||
for model in forecasts.keys():
|
||||
if model in past_forecasts and past_forecasts[model] is not None:
|
||||
try:
|
||||
@@ -1194,6 +1206,7 @@ def calculate_dynamic_weight_components(
|
||||
av = float(actual)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
usable_day = True
|
||||
# Track signed error for bias
|
||||
model_biases[model] += (pv - av) # positive = model overpredicts
|
||||
bias_samples[model] += 1
|
||||
@@ -1208,6 +1221,8 @@ def calculate_dynamic_weight_components(
|
||||
decay_weight = decay_factor ** days_used
|
||||
errors[model].append((blended_error, decay_weight))
|
||||
|
||||
if not usable_day:
|
||||
continue
|
||||
days_used += 1
|
||||
if days_used >= effective_lookback:
|
||||
break
|
||||
|
||||
@@ -974,6 +974,7 @@ def analyze_weather_trend(
|
||||
deb_prediction=_deb_to_save,
|
||||
mu=mu,
|
||||
probabilities=_prob_list,
|
||||
actual_is_final=False,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -2470,18 +2470,32 @@ class PaymentContractCheckoutService:
|
||||
else:
|
||||
self._require_user_wallet(user_id, from_addr)
|
||||
|
||||
validation_pending_reasons = {
|
||||
"payment_tx_validation_failed",
|
||||
"tx_not_mined",
|
||||
}
|
||||
try:
|
||||
validation = self._validate_loaded_intent_tx(intent, tx_hash_text)
|
||||
except Exception as exc:
|
||||
raise PaymentCheckoutError(
|
||||
400,
|
||||
f"payment_tx_validation_failed: {exc}",
|
||||
) from exc
|
||||
if intent.payment_mode != "direct":
|
||||
raise PaymentCheckoutError(
|
||||
400,
|
||||
f"payment_tx_validation_failed: {exc}",
|
||||
) from exc
|
||||
validation = {
|
||||
"valid": False,
|
||||
"reason": "payment_tx_validation_failed",
|
||||
"detail": str(exc),
|
||||
}
|
||||
if not bool(validation.get("valid")):
|
||||
reason = str(validation.get("reason") or "payment_tx_invalid").strip()
|
||||
detail = str(validation.get("detail") or reason).strip()
|
||||
message = reason if detail == reason else f"{reason}: {detail}"
|
||||
raise PaymentCheckoutError(400, message)
|
||||
is_pending_direct_validation = (
|
||||
intent.payment_mode == "direct" and reason in validation_pending_reasons
|
||||
)
|
||||
if not is_pending_direct_validation:
|
||||
raise PaymentCheckoutError(400, message)
|
||||
|
||||
now_iso = _to_iso(now)
|
||||
self._rest(
|
||||
|
||||
@@ -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
|
||||
@@ -416,10 +416,12 @@ def test_direct_submit_tx_does_not_require_from_address(monkeypatch, tmp_path):
|
||||
assert submitted["tx_hash"] == "0x" + "2" * 64
|
||||
|
||||
|
||||
def test_submit_rejects_direct_tx_until_validation_passes(monkeypatch, tmp_path):
|
||||
def test_direct_submit_accepts_pending_validation_for_background_confirm(monkeypatch, tmp_path):
|
||||
_setup_env(monkeypatch, tmp_path)
|
||||
service = PaymentContractCheckoutService()
|
||||
tx_hash = "0x" + "7" * 64
|
||||
submitted = {}
|
||||
transactions = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
@@ -458,17 +460,76 @@ def test_submit_rejects_direct_tx_until_validation_passes(monkeypatch, tmp_path)
|
||||
return []
|
||||
if method == "GET" and table == "payment_intents":
|
||||
return []
|
||||
raise AssertionError(f"submit must not mutate {table} before validation passes")
|
||||
if method == "PATCH" and table == "payment_intents":
|
||||
submitted.update(kwargs["payload"])
|
||||
return [{"ok": True}]
|
||||
if method == "POST" and table == "payment_transactions":
|
||||
transactions.append(kwargs["payload"])
|
||||
return [kwargs["payload"]]
|
||||
raise AssertionError((method, table, kwargs))
|
||||
|
||||
monkeypatch.setattr(service, "_rest", fake_rest)
|
||||
|
||||
try:
|
||||
service.submit_intent_tx("user-1", "intent-direct-pending", tx_hash, "")
|
||||
except Exception as exc:
|
||||
assert getattr(exc, "status_code", None) == 400
|
||||
assert "tx_not_mined" in getattr(exc, "detail", "")
|
||||
else:
|
||||
raise AssertionError("expected tx_not_mined rejection")
|
||||
result = service.submit_intent_tx("user-1", "intent-direct-pending", tx_hash, "")
|
||||
|
||||
assert result["status"] == "submitted"
|
||||
assert submitted["status"] == "submitted"
|
||||
assert submitted["tx_hash"] == tx_hash
|
||||
assert transactions[0]["status"] == "submitted"
|
||||
|
||||
|
||||
def test_direct_submit_accepts_validation_exception_for_background_confirm(monkeypatch, tmp_path):
|
||||
_setup_env(monkeypatch, tmp_path)
|
||||
service = PaymentContractCheckoutService()
|
||||
tx_hash = "0x" + "a" * 64
|
||||
submitted = {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"get_intent",
|
||||
lambda user_id, intent_id: service._serialize_intent(
|
||||
{
|
||||
"id": intent_id,
|
||||
"plan_code": "pro_monthly",
|
||||
"plan_id": 101,
|
||||
"chain_id": 137,
|
||||
"token_address": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174",
|
||||
"receiver_address": "0xeD2f13Aa5fF033c58FB436E178451Cd07f693f32",
|
||||
"amount_units": "5000000",
|
||||
"payment_mode": "direct",
|
||||
"allowed_wallet": None,
|
||||
"order_id_hex": "0x" + "1" * 64,
|
||||
"status": "created",
|
||||
"expires_at": "2099-01-01T00:00:00+00:00",
|
||||
"tx_hash": None,
|
||||
"metadata": {},
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
def fail_validation(*args, **kwargs):
|
||||
raise RuntimeError("rpc timeout")
|
||||
|
||||
monkeypatch.setattr(service, "_validate_loaded_intent_tx", fail_validation)
|
||||
|
||||
def fake_rest(method, table, **kwargs):
|
||||
if method == "GET" and table == "payment_transactions":
|
||||
return []
|
||||
if method == "GET" and table == "payment_intents":
|
||||
return []
|
||||
if method == "PATCH" and table == "payment_intents":
|
||||
submitted.update(kwargs["payload"])
|
||||
return [{"ok": True}]
|
||||
if method == "POST" and table == "payment_transactions":
|
||||
return [kwargs["payload"]]
|
||||
raise AssertionError((method, table, kwargs))
|
||||
|
||||
monkeypatch.setattr(service, "_rest", fake_rest)
|
||||
|
||||
result = service.submit_intent_tx("user-1", "intent-direct-rpc-pending", tx_hash, "")
|
||||
|
||||
assert result["status"] == "submitted"
|
||||
assert submitted["tx_hash"] == tx_hash
|
||||
|
||||
|
||||
def test_submit_rejects_mined_direct_tx_when_receiver_mismatches(monkeypatch, tmp_path):
|
||||
|
||||
Reference in New Issue
Block a user