diff --git a/app/[filename]/ServerRulePage.tsx b/app/[filename]/ServerRulePage.tsx index 32c51709f..d8493bb8c 100644 --- a/app/[filename]/ServerRulePage.tsx +++ b/app/[filename]/ServerRulePage.tsx @@ -14,6 +14,7 @@ import { useIsAdminPage } from "@/components/hooks/useIsAdminPage"; import GitHubMetadata from "@/components/last-updated-by"; import RelatedRulesCard from "@/components/RelatedRulesCard"; import RuleActionButtons from "@/components/RuleActionButtons"; +import RuleFreshnessIndicator from "@/components/RuleFreshnessIndicator"; import { SocialVideoEmbed } from "@/components/shared/SocialVideoEmbed"; import { getMarkdownComponentMapping } from "@/components/tina-markdown/markdown-component-mapping"; import { Card } from "@/components/ui/card"; @@ -87,7 +88,10 @@ export default function ServerRulePage({ serverRulePageProps, tinaProps }: Serve
- +
+ + +
diff --git a/app/api/rule-freshness/route.ts b/app/api/rule-freshness/route.ts new file mode 100644 index 000000000..8b7af72b3 --- /dev/null +++ b/app/api/rule-freshness/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; +import client from "@/tina/__generated__/client"; + +// Always fetch fresh data from TinaCMS — never use cached version +export const dynamic = "force-dynamic"; + +// Only allow paths like: my-rule/rule.mdx or my-rule/rule.md +const VALID_RELATIVE_PATH = /^[\w-]+\/rule\.(mdx?|md)$/; + +export async function GET(req: Request) { + const { searchParams } = new URL(req.url); + const relativePath = searchParams.get("relativePath"); + + if (!relativePath || !VALID_RELATIVE_PATH.test(relativePath)) { + return NextResponse.json({ error: "Invalid or missing relativePath" }, { status: 400 }); + } + + try { + const res = await client.queries.ruleDataBasic({ relativePath }); + const lastUpdated = res?.data?.rule?.lastUpdated ?? null; + return NextResponse.json({ lastUpdated }); + } catch { + return NextResponse.json({ error: "Rule not found" }, { status: 404 }); + } +} diff --git a/components/RuleFreshnessIndicator.tsx b/components/RuleFreshnessIndicator.tsx new file mode 100644 index 000000000..d5d02faeb --- /dev/null +++ b/components/RuleFreshnessIndicator.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Tooltip from "@/components/tooltip/tooltip"; + +type FreshnessStatus = "loading" | "fresh" | "stale" | "error"; + +interface RuleFreshnessIndicatorProps { + relativePath: string; + staticLastUpdated: string | null | undefined; +} + +const STATUS_CONFIG: Record, { color: string; label: string; tooltip: string }> = { + loading: { + color: "bg-gray-400", + label: "Checking...", + tooltip: "Checking whether this page is up to date", + }, + fresh: { + color: "bg-green-500", + label: "Up to date", + tooltip: "This page reflects the latest published content", + }, + stale: { + color: "bg-orange-500", + label: "May be outdated", + tooltip: "Newer content exists — this page is scheduled to refresh shortly", + }, +}; + +export default function RuleFreshnessIndicator({ relativePath, staticLastUpdated }: RuleFreshnessIndicatorProps) { + const [status, setStatus] = useState("loading"); + + useEffect(() => { + let cancelled = false; + + async function check() { + try { + const res = await fetch(`/api/rule-freshness?relativePath=${encodeURIComponent(relativePath)}`, { cache: "no-store" }); + if (!res.ok || cancelled) { + setStatus("error"); + return; + } + const json = await res.json(); + const currentLastUpdated: string | null = json.lastUpdated ?? null; + + if (!currentLastUpdated || !staticLastUpdated) { + // Can't compare — treat as fresh to avoid false alarms + setStatus("fresh"); + return; + } + + const isStale = new Date(currentLastUpdated) > new Date(staticLastUpdated); + setStatus(isStale ? "stale" : "fresh"); + } catch { + if (!cancelled) setStatus("error"); + } + } + + check(); + return () => { + cancelled = true; + }; + }, [relativePath, staticLastUpdated]); + + if (status === "error") return null; + + const config = STATUS_CONFIG[status]; + + return ( + + + + + ); +} diff --git a/components/tooltip/tooltip.d.ts b/components/tooltip/tooltip.d.ts new file mode 100644 index 000000000..e2e0e800d --- /dev/null +++ b/components/tooltip/tooltip.d.ts @@ -0,0 +1,13 @@ +import type { ReactNode } from "react"; + +interface TooltipProps { + children: ReactNode; + text: string; + showDelay?: number; + hideDelay?: number; + className?: string; + opaque?: boolean; +} + +declare const Tooltip: (props: TooltipProps) => JSX.Element; +export default Tooltip;