Add public market brief content assets
This commit is contained in:
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user