Add public market brief content assets

This commit is contained in:
2569718930@qq.com
2026-06-24 16:41:13 +08:00
parent 004439b736
commit 0a46933f6a
12 changed files with 1496 additions and 3 deletions
+129
View File
@@ -0,0 +1,129 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { BriefDetailPageView } from "@/components/public-content/PublicContentPages";
import {
PUBLIC_BRIEFS,
absolutePublicUrl,
briefPath,
getBrief,
} from "@/content/public-content";
type BriefPageParams = {
city: string;
date: string;
};
export function generateStaticParams() {
return PUBLIC_BRIEFS.map((brief) => ({
city: brief.city,
date: brief.date,
}));
}
export async function generateMetadata({
params,
}: {
params: Promise<BriefPageParams>;
}): Promise<Metadata> {
const { city, date } = await params;
const brief = getBrief(city, date);
if (!brief) {
return {
title: "Brief not found",
};
}
const pathname = briefPath(brief);
return {
title: brief.title,
description: brief.description,
alternates: {
canonical: pathname,
},
openGraph: {
type: "article",
title: brief.title,
description: brief.description,
url: pathname,
publishedTime: brief.publishedAt,
modifiedTime: brief.updatedAt,
},
twitter: {
card: "summary",
title: brief.title,
description: brief.description,
},
};
}
export default async function BriefDetailPage({
params,
}: {
params: Promise<BriefPageParams>;
}) {
const { city, date } = await params;
const brief = getBrief(city, date);
if (!brief) {
notFound();
}
const pathname = briefPath(brief);
const jsonLd = [
{
"@context": "https://schema.org",
"@type": "Article",
headline: brief.title,
description: brief.description,
datePublished: brief.publishedAt,
dateModified: brief.updatedAt,
mainEntityOfPage: absolutePublicUrl(pathname),
author: {
"@type": "Organization",
name: "PolyWeather",
url: "https://polyweather.top",
},
publisher: {
"@type": "Organization",
name: "PolyWeather",
url: "https://polyweather.top",
},
about: [
brief.cityName,
brief.market,
brief.settlementSource,
"DEB forecast methodology",
],
},
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: [
{
"@type": "ListItem",
position: 1,
name: "Briefs",
item: absolutePublicUrl("/briefs"),
},
{
"@type": "ListItem",
position: 2,
name: brief.cityName,
item: absolutePublicUrl(pathname),
},
],
},
];
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<BriefDetailPageView brief={brief} />
</>
);
}
+21
View File
@@ -0,0 +1,21 @@
import type { Metadata } from "next";
import { BriefsIndexPageView } from "@/components/public-content/PublicContentPages";
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",
},
};
export default function BriefsPage() {
return <BriefsIndexPageView />;
}
+85
View File
@@ -0,0 +1,85 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { MethodologyDetailPageView } from "@/components/public-content/PublicContentPages";
import {
METHODOLOGY_PAGES,
absolutePublicUrl,
getMethodologyPage,
methodologyPath,
} from "@/content/public-content";
type MethodologyPageParams = {
slug: string;
};
export function generateStaticParams() {
return METHODOLOGY_PAGES.map((page) => ({ slug: page.slug }));
}
export async function generateMetadata({
params,
}: {
params: Promise<MethodologyPageParams>;
}): Promise<Metadata> {
const { slug } = await params;
const page = getMethodologyPage(slug);
if (!page) {
return {
title: "Methodology not found",
};
}
return {
title: page.title,
description: page.description,
alternates: {
canonical: methodologyPath(page),
},
openGraph: {
type: "article",
title: page.title,
description: page.description,
url: methodologyPath(page),
modifiedTime: page.updatedAt,
},
};
}
export default async function MethodologyDetailPage({
params,
}: {
params: Promise<MethodologyPageParams>;
}) {
const { slug } = await params;
const page = getMethodologyPage(slug);
if (!page) {
notFound();
}
const pathname = methodologyPath(page);
const jsonLd = {
"@context": "https://schema.org",
"@type": "TechArticle",
headline: page.title,
description: page.description,
dateModified: page.updatedAt,
mainEntityOfPage: absolutePublicUrl(pathname),
author: {
"@type": "Organization",
name: "PolyWeather",
url: "https://polyweather.top",
},
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<MethodologyDetailPageView page={page} />
</>
);
}
+21
View File
@@ -0,0 +1,21 @@
import type { Metadata } from "next";
import { MethodologyIndexPageView } from "@/components/public-content/PublicContentPages";
export const metadata: Metadata = {
title: "Methodology",
description:
"PolyWeather public methodology for DEB forecast blending, settlement-source priority, freshness, and market weather analysis.",
alternates: {
canonical: "/methodology",
},
openGraph: {
title: "Methodology | PolyWeather",
description:
"Public methodology for DEB forecast blending, settlement-source priority, freshness, and market weather analysis.",
url: "/methodology",
},
};
export default function MethodologyPage() {
return <MethodologyIndexPageView />;
}
+44 -2
View File
@@ -1,20 +1,62 @@
import type { MetadataRoute } from "next";
import {
METHODOLOGY_PAGES,
PUBLIC_BRIEFS,
SOURCE_PAGES,
} from "@/content/public-content";
export default function sitemap(): MetadataRoute.Sitemap {
const baseUrl = "https://polyweather.top";
const now = new Date();
return [
{
url: baseUrl,
lastModified: new Date(),
lastModified: now,
changeFrequency: "weekly",
priority: 1.0,
},
{
url: `${baseUrl}/auth/login`,
lastModified: new Date(),
lastModified: now,
changeFrequency: "monthly",
priority: 0.6,
},
{
url: `${baseUrl}/briefs`,
lastModified: now,
changeFrequency: "weekly",
priority: 0.9,
},
{
url: `${baseUrl}/methodology`,
lastModified: now,
changeFrequency: "monthly",
priority: 0.8,
},
{
url: `${baseUrl}/sources`,
lastModified: now,
changeFrequency: "monthly",
priority: 0.8,
},
...PUBLIC_BRIEFS.map((brief) => ({
url: `${baseUrl}/briefs/${brief.city}/${brief.date}`,
lastModified: new Date(brief.updatedAt),
changeFrequency: "daily" as const,
priority: 0.85,
})),
...METHODOLOGY_PAGES.map((page) => ({
url: `${baseUrl}/methodology/${page.slug}`,
lastModified: new Date(page.updatedAt),
changeFrequency: "monthly" as const,
priority: 0.75,
})),
...SOURCE_PAGES.map((source) => ({
url: `${baseUrl}/sources/${source.slug}`,
lastModified: new Date(source.updatedAt),
changeFrequency: "monthly" as const,
priority: 0.7,
})),
];
}
+89
View File
@@ -0,0 +1,89 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { SourceDetailPageView } from "@/components/public-content/PublicContentPages";
import {
SOURCE_PAGES,
absolutePublicUrl,
getSourcePage,
sourcePath,
} from "@/content/public-content";
type SourcePageParams = {
slug: string;
};
export function generateStaticParams() {
return SOURCE_PAGES.map((source) => ({ slug: source.slug }));
}
export async function generateMetadata({
params,
}: {
params: Promise<SourcePageParams>;
}): Promise<Metadata> {
const { slug } = await params;
const source = getSourcePage(slug);
if (!source) {
return {
title: "Source not found",
};
}
return {
title: source.title,
description: source.description,
alternates: {
canonical: sourcePath(source),
},
openGraph: {
type: "article",
title: source.title,
description: source.description,
url: sourcePath(source),
modifiedTime: source.updatedAt,
},
};
}
export default async function SourceDetailPage({
params,
}: {
params: Promise<SourcePageParams>;
}) {
const { slug } = await params;
const source = getSourcePage(slug);
if (!source) {
notFound();
}
const pathname = sourcePath(source);
const jsonLd = {
"@context": "https://schema.org",
"@type": "Dataset",
name: source.title,
description: source.description,
url: absolutePublicUrl(pathname),
dateModified: source.updatedAt,
creator: {
"@type": "Organization",
name: source.operator,
},
includedInDataCatalog: {
"@type": "DataCatalog",
name: "PolyWeather source notes",
url: absolutePublicUrl("/sources"),
},
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<SourceDetailPageView source={source} />
</>
);
}
+21
View File
@@ -0,0 +1,21 @@
import type { Metadata } from "next";
import { SourcesIndexPageView } from "@/components/public-content/PublicContentPages";
export const metadata: Metadata = {
title: "Weather Sources",
description:
"PolyWeather public source notes for MGM, METAR, HKO, NOAA, ECMWF, and settlement-source weather analysis.",
alternates: {
canonical: "/sources",
},
openGraph: {
title: "Weather Sources | PolyWeather",
description:
"Public source notes for MGM, METAR, HKO, NOAA, ECMWF, and settlement-source weather analysis.",
url: "/sources",
},
};
export default function SourcesPage() {
return <SourcesIndexPageView />;
}
@@ -0,0 +1,91 @@
"use client";
import Link from "next/link";
import { useEffect, type ReactNode } from "react";
type PublicContentEvent =
| "brief_view"
| "brief_cta_click"
| "methodology_view"
| "social_outbound_click";
type AnalyticsPayload = Record<string, unknown>;
async function emitPublicContentEvent(
eventType: PublicContentEvent,
payload: AnalyticsPayload,
onceKey?: string,
) {
const { markAnalyticsOnce, trackAppEvent } = await import("@/lib/app-analytics");
if (onceKey && !markAnalyticsOnce(`public-content:${onceKey}`)) return;
trackAppEvent(eventType, payload);
}
export function PublicContentAnalytics({
eventType,
onceKey,
payload,
}: {
eventType: PublicContentEvent;
onceKey?: string;
payload: AnalyticsPayload;
}) {
const payloadKey = JSON.stringify(payload);
useEffect(() => {
const parsedPayload = JSON.parse(payloadKey) as AnalyticsPayload;
void emitPublicContentEvent(eventType, parsedPayload, onceKey);
}, [eventType, onceKey, payloadKey]);
return null;
}
export function PublicContentCta({
children,
className,
href,
payload,
}: {
children: ReactNode;
className?: string;
href: string;
payload: AnalyticsPayload;
}) {
return (
<Link
className={className}
href={href}
onClick={() => {
void emitPublicContentEvent("brief_cta_click", payload);
}}
>
{children}
</Link>
);
}
export function PublicContentOutboundLink({
children,
className,
href,
payload,
}: {
children: ReactNode;
className?: string;
href: string;
payload: AnalyticsPayload;
}) {
return (
<a
className={className}
href={href}
onClick={() => {
void emitPublicContentEvent("social_outbound_click", payload);
}}
rel="noreferrer"
target="_blank"
>
{children}
</a>
);
}
@@ -0,0 +1,467 @@
import Link from "next/link";
import {
METHODOLOGY_PAGES,
PUBLIC_BRIEFS,
SOURCE_PAGES,
absolutePublicUrl,
briefPath,
methodologyPath,
sourcePath,
type MethodologyPage,
type PublicBrief,
type SourcePage,
} from "@/content/public-content";
import {
PublicContentAnalytics,
PublicContentCta,
PublicContentOutboundLink,
} from "./PublicContentAnalytics";
const pageShell =
"min-h-screen bg-[#f4f7fb] text-slate-950";
const contentWrap =
"mx-auto flex w-full max-w-6xl flex-col gap-8 px-4 py-6 sm:px-6 lg:px-8";
const panel =
"rounded-lg border border-slate-200 bg-white shadow-[0_1px_3px_rgba(15,23,42,0.05)]";
const sectionTitle =
"text-sm font-semibold uppercase tracking-[0.08em] text-slate-500";
const bodyText = "text-sm leading-6 text-slate-700";
const primaryButton =
"inline-flex min-h-10 items-center justify-center rounded-md bg-slate-950 px-4 py-2 text-sm font-semibold text-white transition hover:bg-slate-800";
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() {
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>
</header>
);
}
function PageIntro({
eyebrow,
title,
description,
}: {
eyebrow: string;
title: string;
description: string;
}) {
return (
<section className="grid gap-4 border-b border-slate-200 bg-white">
<div className={`${contentWrap} py-10 sm:py-12`}>
<p className={sectionTitle}>{eyebrow}</p>
<div className="max-w-3xl space-y-4">
<h1 className="text-3xl font-black leading-tight text-slate-950 sm:text-4xl">
{title}
</h1>
<p className="text-base leading-7 text-slate-700">{description}</p>
</div>
</div>
</section>
);
}
function SourceLinks({ slugs }: { 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;
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)}
key={slug}
>
{source.title}
</Link>
);
})}
</div>
);
}
function MethodologyLinks({ slugs }: { 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;
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)}
key={slug}
>
{page.title}
</Link>
);
})}
</div>
);
}
function BriefCard({ brief }: { brief: PublicBrief }) {
return (
<article className={`${panel} flex flex-col gap-5 p-5`}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.08em] text-blue-700">
{brief.cityName} / {brief.date}
</p>
<h2 className="mt-2 text-xl font-black text-slate-950">
<Link href={briefPath(brief)}>{brief.title}</Link>
</h2>
</div>
<span className="rounded-md bg-emerald-50 px-2.5 py-1 text-xs font-bold text-emerald-800">
{brief.settlementSource}
</span>
</div>
<p className={bodyText}>{brief.description}</p>
<div className="grid gap-3 sm:grid-cols-3">
{brief.signals.map((signal) => (
<div className="rounded-md border border-slate-200 bg-slate-50 p-3" key={signal.label}>
<p className="text-xs font-semibold text-slate-500">{signal.label}</p>
<p className="mt-1 font-mono text-lg font-bold text-slate-950">{signal.value}</p>
<p className="mt-1 text-xs leading-5 text-slate-600">{signal.detail}</p>
</div>
))}
</div>
<div className="flex flex-wrap items-center gap-3">
<Link className={secondaryButton} href={briefPath(brief)}>
Read brief
</Link>
<SourceLinks slugs={brief.sourceSlugs.slice(0, 2)} />
</div>
</article>
);
}
export function BriefsIndexPageView() {
return (
<div className={pageShell}>
<PublicHeader />
<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"
/>
<div className={contentWrap}>
<section className="grid gap-4">
{PUBLIC_BRIEFS.map((brief) => (
<BriefCard brief={brief} key={`${brief.city}-${brief.date}`} />
))}
</section>
<section className={`${panel} grid gap-5 p-5 md:grid-cols-2`}>
<div>
<p className={sectionTitle}>Methodology</p>
<h2 className="mt-2 text-2xl font-black text-slate-950">
How the public read is produced
</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.
</p>
<div className="mt-4">
<MethodologyLinks slugs={["deb", "settlement-sources"]} />
</div>
</div>
<div>
<p className={sectionTitle}>Source notes</p>
<h2 className="mt-2 text-2xl font-black text-slate-950">
Official-source context
</h2>
<p className={`${bodyText} mt-3`}>
Source pages explain why MGM, METAR, HKO, NOAA, and model guidance are displayed separately in PolyWeather workflows.
</p>
<div className="mt-4">
<SourceLinks slugs={["mgm", "metar", "hko", "noaa"]} />
</div>
</div>
</section>
</div>
</div>
);
}
export function BriefDetailPageView({ brief }: { brief: PublicBrief }) {
return (
<div className={pageShell}>
<PublicContentAnalytics
eventType="brief_view"
onceKey={`brief:${brief.city}:${brief.date}`}
payload={{ city: brief.city, date: brief.date, source: brief.settlementSource }}
/>
<PublicHeader />
<PageIntro
description={brief.description}
eyebrow={`${brief.cityName}, ${brief.countryName} / ${brief.date}`}
title={brief.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) => (
<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>
<p className="mt-2 text-xs leading-5 text-slate-600">{signal.detail}</p>
</div>
))}
</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} />
</div>
</article>
<aside className={`${panel} h-fit p-5`}>
<p className={sectionTitle}>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} />
</dl>
<div className="mt-5 flex flex-col gap-3">
<PublicContentCta
className={primaryButton}
href="/terminal"
payload={{ city: brief.city, date: brief.date, cta: "terminal" }}
>
{brief.primaryCtaLabel}
</PublicContentCta>
<Link className={secondaryButton} href="/briefs">
All public briefs
</Link>
</div>
</aside>
</section>
<section className="grid gap-5 lg:grid-cols-[1fr_1fr]">
<div className={`${panel} p-5`}>
<p className={sectionTitle}>Checks before acting</p>
<ul className="mt-4 grid gap-3">
{brief.checkpoints.map((checkpoint) => (
<li className={bodyText} key={checkpoint}>
{checkpoint}
</li>
))}
</ul>
</div>
<div className={`${panel} p-5`}>
<p className={sectionTitle}>Distribution copy</p>
<p className={`${bodyText} mt-4`}>{brief.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" }}
>
Share on X
</PublicContentOutboundLink>
</div>
</div>
</section>
<section className={`${panel} grid gap-5 p-5 md:grid-cols-2`}>
<div>
<p className={sectionTitle}>Methodology links</p>
<div className="mt-4">
<MethodologyLinks slugs={brief.methodologySlugs} />
</div>
</div>
<div>
<p className={sectionTitle}>Source links</p>
<div className="mt-4">
<SourceLinks slugs={brief.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}
</p>
</div>
</div>
);
}
function BriefSection({ body, title }: { body: string; title: string }) {
return (
<section>
<h2 className="text-lg font-black text-slate-950">{title}</h2>
<p className={`${bodyText} mt-2`}>{body}</p>
</section>
);
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div className="grid gap-1 border-b border-slate-100 pb-3 last:border-b-0 last:pb-0">
<dt className="text-xs font-semibold uppercase tracking-[0.08em] text-slate-500">{label}</dt>
<dd className="leading-6 text-slate-800">{value}</dd>
</div>
);
}
export function MethodologyIndexPageView() {
return (
<div className={pageShell}>
<PublicHeader />
<PageIntro
description="Public methodology pages explain how PolyWeather handles DEB blending, settlement-source priority, freshness, and source reconciliation for prediction-market weather analysis."
eyebrow="Methodology"
title="How PolyWeather reads weather markets"
/>
<div className={contentWrap}>
<section className="grid gap-4 md:grid-cols-2">
{METHODOLOGY_PAGES.map((page) => (
<article className={`${panel} p-5`} key={page.slug}>
<p className={sectionTitle}>{formatDate(page.updatedAt)}</p>
<h2 className="mt-2 text-2xl font-black text-slate-950">
<Link href={methodologyPath(page)}>{page.title}</Link>
</h2>
<p className={`${bodyText} mt-3`}>{page.description}</p>
<Link className={`${secondaryButton} mt-5`} href={methodologyPath(page)}>
Read methodology
</Link>
</article>
))}
</section>
</div>
</div>
);
}
export function MethodologyDetailPageView({ page }: { page: MethodologyPage }) {
return (
<div className={pageShell}>
<PublicContentAnalytics
eventType="methodology_view"
onceKey={`methodology:${page.slug}`}
payload={{ slug: page.slug, content_type: "methodology" }}
/>
<PublicHeader />
<PageIntro description={page.description} eyebrow="Methodology" title={page.title} />
<div className={contentWrap}>
<article className={`${panel} p-5`}>
<p className="max-w-3xl text-base leading-7 text-slate-700">{page.summary}</p>
<div className="mt-8 grid gap-8">
{page.sections.map((section) => (
<section key={section.heading}>
<h2 className="text-xl font-black text-slate-950">{section.heading}</h2>
<p className={`${bodyText} mt-3`}>{section.body}</p>
<ul className="mt-4 grid gap-3">
{section.bullets.map((bullet) => (
<li className={bodyText} key={bullet}>
{bullet}
</li>
))}
</ul>
</section>
))}
</div>
</article>
</div>
</div>
);
}
export function SourcesIndexPageView() {
return (
<div className={pageShell}>
<PublicHeader />
<PageIntro
description="Source pages separate official observations, airport observations, and model guidance so readers can inspect why PolyWeather prioritizes settlement-relevant evidence."
eyebrow="Sources"
title="Weather source notes for public audit"
/>
<div className={contentWrap}>
<section className="grid gap-4 md:grid-cols-2">
{SOURCE_PAGES.map((source) => (
<article className={`${panel} p-5`} key={source.slug}>
<p className={sectionTitle}>{source.operator}</p>
<h2 className="mt-2 text-2xl font-black text-slate-950">
<Link href={sourcePath(source)}>{source.title}</Link>
</h2>
<p className={`${bodyText} mt-3`}>{source.description}</p>
<Link className={`${secondaryButton} mt-5`} href={sourcePath(source)}>
Read source note
</Link>
</article>
))}
</section>
</div>
</div>
);
}
export function SourceDetailPageView({ source }: { source: SourcePage }) {
return (
<div className={pageShell}>
<PublicHeader />
<PageIntro description={source.description} eyebrow="Source note" title={source.title} />
<div className={contentWrap}>
<article className={`${panel} grid gap-6 p-5 lg:grid-cols-[0.9fr_1.4fr]`}>
<dl className="grid gap-3 text-sm">
<InfoRow label="Operator" value={source.operator} />
<InfoRow label="Coverage" value={source.coverage} />
<InfoRow label="Cadence" value={source.cadence} />
<InfoRow label="Settlement use" value={source.settlementUse} />
</dl>
<div>
<p className={sectionTitle}>Reliability notes</p>
<ul className="mt-4 grid gap-3">
{source.reliabilityNotes.map((note) => (
<li className={bodyText} key={note}>
{note}
</li>
))}
</ul>
<div className="mt-6">
<p className={sectionTitle}>Related methodology</p>
<div className="mt-4">
<MethodologyLinks slugs={source.relatedMethodologySlugs} />
</div>
</div>
</div>
</article>
</div>
</div>
);
}
function formatDateTime(value: string) {
return new Intl.DateTimeFormat("en", {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(value));
}
function formatDate(value: string) {
return new Intl.DateTimeFormat("en", {
dateStyle: "medium",
}).format(new Date(value));
}
@@ -0,0 +1,110 @@
import fs from "node:fs";
import path from "node:path";
function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message);
}
export function runTests() {
const root = process.cwd();
const contentPath = path.join(root, "content", "public-content.ts");
const briefsIndexPath = path.join(root, "app", "briefs", "page.tsx");
const briefDetailPath = path.join(root, "app", "briefs", "[city]", "[date]", "page.tsx");
const methodologyIndexPath = path.join(root, "app", "methodology", "page.tsx");
const methodologyDetailPath = path.join(root, "app", "methodology", "[slug]", "page.tsx");
const sourcesIndexPath = path.join(root, "app", "sources", "page.tsx");
const sourceDetailPath = path.join(root, "app", "sources", "[slug]", "page.tsx");
const analyticsPath = path.join(root, "lib", "app-analytics.ts");
const analyticsIslandPath = path.join(root, "components", "public-content", "PublicContentAnalytics.tsx");
const publicPagesPath = path.join(root, "components", "public-content", "PublicContentPages.tsx");
const sitemapPath = path.join(root, "app", "sitemap.ts");
for (const requiredPath of [
contentPath,
briefsIndexPath,
briefDetailPath,
methodologyIndexPath,
methodologyDetailPath,
sourcesIndexPath,
sourceDetailPath,
analyticsIslandPath,
publicPagesPath,
]) {
assert(fs.existsSync(requiredPath), `${path.relative(root, requiredPath)} must exist`);
}
const content = fs.readFileSync(contentPath, "utf8");
const briefDetail = fs.readFileSync(briefDetailPath, "utf8");
const methodologyDetail = fs.readFileSync(methodologyDetailPath, "utf8");
const sourceDetail = fs.readFileSync(sourceDetailPath, "utf8");
const analytics = fs.readFileSync(analyticsPath, "utf8");
const analyticsIsland = fs.readFileSync(analyticsIslandPath, "utf8");
const publicPages = fs.readFileSync(publicPagesPath, "utf8");
const sitemap = fs.readFileSync(sitemapPath, "utf8");
const briefsIndex = fs.readFileSync(briefsIndexPath, "utf8");
assert(
content.includes("PUBLIC_BRIEFS") &&
content.includes("METHODOLOGY_PAGES") &&
content.includes("SOURCE_PAGES") &&
content.includes('"ankara"') &&
content.includes('"deb"') &&
content.includes('"mgm"'),
"public content module must define sample briefs plus DEB and MGM public pages",
);
assert(
content.includes("notFinancialAdvice") &&
content.includes("updatedAt") &&
content.includes("settlementSource") &&
content.includes("distributionText"),
"public briefs must carry disclaimer, freshness, settlement source, and shareable distribution copy",
);
assert(
briefDetail.includes("generateStaticParams") &&
briefDetail.includes("generateMetadata") &&
briefDetail.includes("application/ld+json") &&
briefDetail.includes("BreadcrumbList") &&
briefDetail.includes("Article") &&
briefDetail.includes("notFound()"),
"brief detail route must be statically indexable with metadata, JSON-LD, breadcrumbs, and 404 handling",
);
assert(
methodologyDetail.includes("generateStaticParams") &&
methodologyDetail.includes("TechArticle") &&
methodologyDetail.includes("application/ld+json") &&
sourceDetail.includes("Dataset") &&
sourceDetail.includes("application/ld+json"),
"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"]}') &&
briefsIndex.includes("Weather Market Brief"),
"brief index must cross-link to DEB methodology and settlement source pages",
);
assert(
analytics.includes('"brief_view"') &&
analytics.includes('"brief_cta_click"') &&
analytics.includes('"methodology_view"') &&
analytics.includes('"social_outbound_click"'),
"analytics event union must include public content acquisition events",
);
assert(
analyticsIsland.startsWith('"use client"') &&
analyticsIsland.includes("trackAppEvent") &&
analyticsIsland.includes("brief_view") &&
analyticsIsland.includes("brief_cta_click") &&
analyticsIsland.includes("methodology_view") &&
analyticsIsland.includes("social_outbound_click"),
"public content analytics must be isolated in a client island and emit the new events",
);
assert(
sitemap.includes("PUBLIC_BRIEFS") &&
sitemap.includes("METHODOLOGY_PAGES") &&
sitemap.includes("SOURCE_PAGES") &&
sitemap.includes("/briefs") &&
sitemap.includes("/methodology/") &&
sitemap.includes("/sources/"),
"sitemap must enumerate public content assets for search and answer engines",
);
}
+413
View File
@@ -0,0 +1,413 @@
export const PUBLIC_CONTENT_BASE_URL = "https://polyweather.top";
export type PublicBriefSignal = {
label: string;
value: string;
detail: string;
};
export type PublicBrief = {
city: string;
cityName: string;
countryName: string;
date: string;
title: string;
description: string;
market: string;
settlementSource: string;
updatedAt: string;
publishedAt: string;
dataFreshness: string;
debRead: string;
sourceRead: string;
modelRead: string;
riskRead: string;
notFinancialAdvice: string;
distributionText: string;
primaryCtaLabel: string;
sourceSlugs: string[];
methodologySlugs: string[];
signals: PublicBriefSignal[];
checkpoints: string[];
};
export type MethodologyPage = {
slug: string;
title: string;
description: string;
updatedAt: string;
summary: string;
sections: Array<{
heading: string;
body: string;
bullets: string[];
}>;
};
export type SourcePage = {
slug: string;
title: string;
description: string;
updatedAt: string;
operator: string;
coverage: string;
cadence: string;
settlementUse: string;
reliabilityNotes: string[];
relatedMethodologySlugs: string[];
};
export const PUBLIC_BRIEFS: PublicBrief[] = [
{
city: "ankara",
cityName: "Ankara",
countryName: "Turkey",
date: "2026-06-24",
title: "Ankara Weather Market Brief - 24 Jun 2026",
description:
"A public market brief for Ankara maximum temperature judgment, focused on MGM settlement-source behavior, DEB blended forecast context, and anomaly checks.",
market: "Same-day maximum temperature judgment",
settlementSource: "MGM official station",
updatedAt: "2026-06-24T13:55:00+03:00",
publishedAt: "2026-06-24T13:55:00+03:00",
dataFreshness:
"Static public snapshot. Paid terminal users should verify the latest official observation and SSE replay state before acting.",
debRead:
"DEB kept the intraday high-temperature read below the isolated MGM spike and closer to the observed official range.",
sourceRead:
"MGM is treated as the primary settlement reference. A single 27.1 C point should be checked against adjacent official readings before it is accepted as a new high.",
modelRead:
"ECMWF was warmer than the DEB blend in the early afternoon window, but the public brief weights official observations above model-only movement.",
riskRead:
"Main risk is a late official update or a source-side correction that changes the recognized high after the public snapshot.",
notFinancialAdvice:
"This brief is weather-research content for prediction-market preparation. It is not financial advice and does not guarantee settlement outcomes.",
distributionText:
"Ankara 2026-06-24 public Weather Market Brief: MGM official readings favored a 24.5 C observed high over an isolated 27.1 C spike; DEB stayed below the outlier. Not financial advice.",
primaryCtaLabel: "Open live terminal",
sourceSlugs: ["mgm", "metar", "ecmwf"],
methodologySlugs: ["deb", "settlement-sources"],
signals: [
{
label: "Observed high so far",
value: "24.5 C",
detail: "Official-source value to compare against any isolated higher point.",
},
{
label: "Outlier under review",
value: "27.1 C",
detail: "A sudden single-source value needs neighboring-time validation.",
},
{
label: "DEB public read",
value: "Below spike",
detail: "Blend stayed closer to the verified observation band.",
},
],
checkpoints: [
"Check whether the suspected spike appears in the official high-temperature summary.",
"Compare adjacent MGM observations before treating a single point as settlement-relevant.",
"Review the paid terminal for live chart patches and source freshness before market close.",
],
},
{
city: "hong-kong",
cityName: "Hong Kong",
countryName: "Hong Kong",
date: "2026-06-24",
title: "Hong Kong Weather Market Brief - 24 Jun 2026",
description:
"A public brief showing how PolyWeather frames HKO/Cheung Chau/airport observations against DEB and model spread for maximum-temperature markets.",
market: "Urban and airport maximum temperature judgment",
settlementSource: "HKO official network",
updatedAt: "2026-06-24T18:00:00+08:00",
publishedAt: "2026-06-24T18:00:00+08:00",
dataFreshness:
"Static public snapshot. Live terminal values may differ as HKO and station caches refresh.",
debRead:
"DEB favored a narrow high-temperature window because live observations and model spread were aligned by late afternoon.",
sourceRead:
"HKO network observations remain the source family to reconcile before interpreting airport-only movement.",
modelRead:
"Model disagreement was limited, so source freshness and station selection mattered more than broad synoptic uncertainty.",
riskRead:
"Primary risk is station-specific heat retention during late afternoon or a late official summary revision.",
notFinancialAdvice:
"This brief is weather-research content for prediction-market preparation. It is not financial advice and does not guarantee settlement outcomes.",
distributionText:
"Hong Kong 2026-06-24 public Weather Market Brief: source selection and HKO freshness mattered more than model spread. Not financial advice.",
primaryCtaLabel: "Open live terminal",
sourceSlugs: ["hko", "metar"],
methodologySlugs: ["deb", "settlement-sources"],
signals: [
{
label: "Source family",
value: "HKO",
detail: "Use official station context before airport-only interpretation.",
},
{
label: "Model spread",
value: "Low",
detail: "Late-day uncertainty mainly came from station behavior.",
},
{
label: "Terminal need",
value: "Freshness",
detail: "Live source timestamps decide whether the public snapshot is still useful.",
},
],
checkpoints: [
"Verify HKO station timestamps before comparing market bands.",
"Separate airport METAR observations from settlement-source network readings.",
"Check terminal source health if the public snapshot is older than one refresh cycle.",
],
},
{
city: "new-york",
cityName: "New York",
countryName: "United States",
date: "2026-06-24",
title: "New York Weather Market Brief - 24 Jun 2026",
description:
"A public brief for New York temperature markets, connecting METAR, NOAA context, DEB blending, and late-day risk checks.",
market: "Airport-linked maximum temperature judgment",
settlementSource: "METAR and NOAA station context",
updatedAt: "2026-06-24T12:30:00-04:00",
publishedAt: "2026-06-24T12:30:00-04:00",
dataFreshness:
"Static public snapshot. Paid terminal users should check current METAR observations and official summaries.",
debRead:
"DEB weighted the latest observation trend against warmer model guidance instead of following the raw model high.",
sourceRead:
"METAR provides fast airport evidence, while NOAA context helps validate whether the airport value is representative.",
modelRead:
"Warm model guidance can be useful only after it is reconciled with live airport observations and cloud/wind context.",
riskRead:
"Primary risk is a short late-day break in cloud cover that lifts airport observations into a higher band.",
notFinancialAdvice:
"This brief is weather-research content for prediction-market preparation. It is not financial advice and does not guarantee settlement outcomes.",
distributionText:
"New York 2026-06-24 public Weather Market Brief: DEB blended warmer guidance against live airport evidence. Not financial advice.",
primaryCtaLabel: "Open live terminal",
sourceSlugs: ["metar", "noaa"],
methodologySlugs: ["deb", "settlement-sources"],
signals: [
{
label: "Fast evidence",
value: "METAR",
detail: "Airport observations define the short-cycle read.",
},
{
label: "Validation",
value: "NOAA",
detail: "Official context helps audit the final high-temperature interpretation.",
},
{
label: "DEB stance",
value: "Blend",
detail: "Do not follow a warm model run without live evidence confirmation.",
},
],
checkpoints: [
"Watch the last two METAR cycles before the daily high window ends.",
"Check whether model warmth is supported by cloud and wind observations.",
"Use official source context before final settlement interpretation.",
],
},
];
export const METHODOLOGY_PAGES: MethodologyPage[] = [
{
slug: "deb",
title: "DEB Forecast Methodology",
description:
"How PolyWeather frames DEB blended forecasts for prediction-market temperature decisions without replacing settlement-source evidence.",
updatedAt: "2026-06-24T00:00:00Z",
summary:
"DEB is the public name for PolyWeather's blended forecast layer. It reconciles model guidance, live observation momentum, source freshness, and station context so users can judge a high-temperature band with fewer single-model mistakes.",
sections: [
{
heading: "What DEB is for",
body:
"DEB is not a settlement oracle. It is a decision-support layer that makes the live observation path and model spread easier to compare.",
bullets: [
"Prefer settlement-source evidence when it conflicts with model-only guidance.",
"Treat stale or isolated source values as quality-control candidates.",
"Expose the forecast band and the reason a band is widening or narrowing.",
],
},
{
heading: "Inputs that matter",
body:
"The blend is useful because it combines different evidence classes instead of pretending one model run is enough.",
bullets: [
"Latest official or airport observations and their freshness.",
"Model consensus and disagreement across ECMWF, GFS, ICON, GEM, and local sources when available.",
"City-specific source behavior, station selection, and intraday high timing.",
],
},
{
heading: "How to read a DEB miss",
body:
"A DEB miss should be reviewed by separating source freshness, model spread, and settlement-source revisions.",
bullets: [
"If the observed high changed after a late source patch, classify it as a freshness or replay issue.",
"If every source was fresh but the high landed outside the band, review model weighting and local station features.",
"If one source jumped alone, audit neighboring observations before retraining around the outlier.",
],
},
],
},
{
slug: "settlement-sources",
title: "Settlement-Source Priority",
description:
"Why PolyWeather presents official settlement-related observations before generic weather API values.",
updatedAt: "2026-06-24T00:00:00Z",
summary:
"Prediction-market users care about the number that resolves the contract. PolyWeather therefore puts official station, airport, and operator-specific source behavior above broad consumer-weather averages.",
sections: [
{
heading: "Why generic weather values are not enough",
body:
"Consumer weather apps often smooth station data or display city-wide approximations. Market resolution can depend on a narrower official source.",
bullets: [
"A city label may hide multiple stations with different daily highs.",
"Airport METAR can update faster than a public summary, but may not be the final settlement source.",
"Official source revisions can matter more than a visually smooth forecast curve.",
],
},
{
heading: "What PolyWeather surfaces",
body:
"The terminal separates source labels, observation timestamps, forecast models, and freshness state so a user can audit the path to a number.",
bullets: [
"Settlement-source labels and station context on charts.",
"Source freshness, cache policy, and SSE patch visibility.",
"DEB forecast context shown next to live observations, not as a replacement for them.",
],
},
],
},
];
export const SOURCE_PAGES: SourcePage[] = [
{
slug: "mgm",
title: "MGM Weather Source",
description:
"PolyWeather source note for Turkish MGM observations used in Ankara-style temperature market analysis.",
updatedAt: "2026-06-24T00:00:00Z",
operator: "Turkish State Meteorological Service",
coverage: "Turkey official station network, including Ankara market context.",
cadence: "Source cadence varies by station and publication path; terminal freshness checks are required.",
settlementUse:
"Used as the primary official-source family when Ankara markets reference Turkish official observations.",
reliabilityNotes: [
"Single-point spikes should be compared with neighboring timestamps before being accepted.",
"Official summaries can lag raw point observations.",
"Cache and SSE replay state should be checked when a value appears suddenly.",
],
relatedMethodologySlugs: ["settlement-sources", "deb"],
},
{
slug: "metar",
title: "METAR Airport Observations",
description:
"PolyWeather source note for airport METAR observations used as fast evidence in temperature-market workflows.",
updatedAt: "2026-06-24T00:00:00Z",
operator: "Airport weather observation network",
coverage: "Airport-linked observations across supported markets.",
cadence: "Often hourly or sub-hourly depending on airport and issuance behavior.",
settlementUse:
"Useful for fast evidence and airport-linked contracts; must be reconciled with the contract's exact settlement source.",
reliabilityNotes: [
"METAR can update faster than official daily summaries.",
"Airport exposure may differ from city-center official stations.",
"Late METAR cycles can change high-temperature judgment near market close.",
],
relatedMethodologySlugs: ["settlement-sources", "deb"],
},
{
slug: "ecmwf",
title: "ECMWF Model Guidance",
description:
"PolyWeather source note for ECMWF model guidance as one input into DEB blended forecasts.",
updatedAt: "2026-06-24T00:00:00Z",
operator: "European Centre for Medium-Range Weather Forecasts",
coverage: "Global numerical weather prediction guidance.",
cadence: "Model-run cadence depends on product and ingestion timing.",
settlementUse:
"Used for forecast context only. It does not replace live official observations for settlement interpretation.",
reliabilityNotes: [
"Model warmth or coolness should be validated against live observations.",
"Run-to-run shifts can be useful when source evidence has not yet settled.",
"Model spread should be shown beside, not above, settlement-source data.",
],
relatedMethodologySlugs: ["deb"],
},
{
slug: "hko",
title: "HKO Official Observations",
description:
"PolyWeather source note for Hong Kong Observatory observations in Hong Kong market analysis.",
updatedAt: "2026-06-24T00:00:00Z",
operator: "Hong Kong Observatory",
coverage: "Hong Kong official observation network and station-specific context.",
cadence: "Cadence varies by observation product; terminal freshness checks remain required.",
settlementUse:
"Used as the official-source family for Hong Kong station and city-market interpretation.",
reliabilityNotes: [
"Station selection can materially change the maximum-temperature read.",
"Airport observations should be separated from broader HKO network readings.",
"Humidity, wind, and late-day sun breaks can affect final highs.",
],
relatedMethodologySlugs: ["settlement-sources", "deb"],
},
{
slug: "noaa",
title: "NOAA Weather Context",
description:
"PolyWeather source note for NOAA context used to validate US weather-market observations and summaries.",
updatedAt: "2026-06-24T00:00:00Z",
operator: "National Oceanic and Atmospheric Administration",
coverage: "United States official weather observations, summaries, and context products.",
cadence: "Cadence depends on product family and station reporting behavior.",
settlementUse:
"Used to audit and contextualize US official observations where contract rules reference NOAA/NWS data.",
reliabilityNotes: [
"Official summaries can arrive after fast airport observations.",
"Daily high interpretation should match the contract's station and time zone rules.",
"Use NOAA context to confirm whether an airport observation is representative.",
],
relatedMethodologySlugs: ["settlement-sources", "deb"],
},
];
export function briefPath(brief: PublicBrief) {
return `/briefs/${brief.city}/${brief.date}`;
}
export function methodologyPath(page: MethodologyPage) {
return `/methodology/${page.slug}`;
}
export function sourcePath(page: SourcePage) {
return `/sources/${page.slug}`;
}
export function getBrief(city: string, date: string) {
return PUBLIC_BRIEFS.find((brief) => brief.city === city && brief.date === date);
}
export function getMethodologyPage(slug: string) {
return METHODOLOGY_PAGES.find((page) => page.slug === slug);
}
export function getSourcePage(slug: string) {
return SOURCE_PAGES.find((page) => page.slug === slug);
}
export function absolutePublicUrl(pathname: string) {
return `${PUBLIC_CONTENT_BASE_URL}${pathname}`;
}
+5 -1
View File
@@ -17,7 +17,11 @@ type TrackableAnalyticsEvent =
| "paywall_feature_clicked"
| "paywall_viewed"
| "checkout_started"
| "checkout_succeeded";
| "checkout_succeeded"
| "brief_view"
| "brief_cta_click"
| "methodology_view"
| "social_outbound_click";
const CLIENT_ID_KEY = "polyweather:analytics:client-id";
const SESSION_ID_KEY = "polyweather:analytics:session-id";