From 010e39828df13e23cdb469682dec4ca33c3c075b Mon Sep 17 00:00:00 2001 From: PothieuG Date: Tue, 7 Apr 2026 14:56:56 +0200 Subject: [PATCH 1/3] ISR indicator implemented - Green for updated rule, yellow for under building rule, orange if the rule might be outdate (more than 1 year). --- app/[filename]/ServerRulePage.tsx | 6 +- app/api/github-open-prs/route.ts | 76 ++++++++++++++++ components/RuleStatusBadge.tsx | 52 +++++++++++ lib/ruleStatus.ts | 51 +++++++++++ package.json | 1 + pnpm-lock.yaml | 146 +++++++++++++++++++++++++----- 6 files changed, 310 insertions(+), 22 deletions(-) create mode 100644 app/api/github-open-prs/route.ts create mode 100644 components/RuleStatusBadge.tsx create mode 100644 lib/ruleStatus.ts diff --git a/app/[filename]/ServerRulePage.tsx b/app/[filename]/ServerRulePage.tsx index 68ffb2148..5f6264c24 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 RuleStatusBadge from "@/components/RuleStatusBadge"; import { SocialVideoEmbed } from "@/components/shared/SocialVideoEmbed"; import { getMarkdownComponentMapping } from "@/components/tina-markdown/markdown-component-mapping"; import { Card } from "@/components/ui/card"; @@ -85,7 +86,10 @@ export default function ServerRulePage({ serverRulePageProps, tinaProps }: Serve
- +
+ + +
diff --git a/app/api/github-open-prs/route.ts b/app/api/github-open-prs/route.ts new file mode 100644 index 000000000..564b42b51 --- /dev/null +++ b/app/api/github-open-prs/route.ts @@ -0,0 +1,76 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getGitHubAppToken } from "@/lib/services/github/github.utils"; +import { GITHUB_API_BASE_URL } from "@/lib/services/github/github.constants"; + +const CACHE_TTL = 300; // 5 minutes +const GITHUB_ACTIVE_BRANCH = process.env.NEXT_PUBLIC_TINA_BRANCH || "main"; + +const OPEN_PRS_QUERY = ` + query SearchOpenPRs($query: String!, $first: Int!) { + search(query: $query, type: ISSUE, first: $first) { + issueCount + } + } +`; + +type CacheEntry = { expiresAt: number; hasOpenPRs: boolean }; +const cache = new Map(); + +export async function GET(request: NextRequest) { + const { searchParams } = request.nextUrl; + const owner = searchParams.get("owner") || "SSWConsulting"; + const repo = searchParams.get("repo") || "SSW.Rules.Content"; + const path = searchParams.get("path"); + + if (!path) { + return NextResponse.json({ hasOpenPRs: false }); + } + + // Extract the rule folder name from the path (e.g. "content/rule/do-something/rule.mdx" -> "do-something") + const ruleFolder = path.split("/").find((_, i, arr) => arr[i + 1] === "rule.mdx") || path.split("/").slice(-2, -1)[0] || ""; + + if (!ruleFolder) { + return NextResponse.json({ hasOpenPRs: false }); + } + + const cacheKey = `${owner}/${repo}/${ruleFolder}`; + const cached = cache.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) { + return NextResponse.json({ hasOpenPRs: cached.hasOpenPRs }); + } + + try { + const token = await getGitHubAppToken(); + + const query = `repo:${owner}/${repo} is:pr is:open ${ruleFolder}`; + const response = await fetch(GITHUB_API_BASE_URL, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "User-Agent": "Rules.V3", + }, + body: JSON.stringify({ + query: OPEN_PRS_QUERY, + variables: { query, first: 1 }, + }), + next: { revalidate: CACHE_TTL }, + }); + + if (!response.ok) { + console.error("GitHub open PRs API error:", response.status); + return NextResponse.json({ hasOpenPRs: false }); + } + + const result = await response.json(); + const issueCount = result?.data?.search?.issueCount ?? 0; + const hasOpenPRs = issueCount > 0; + + cache.set(cacheKey, { hasOpenPRs, expiresAt: Date.now() + CACHE_TTL * 1000 }); + + return NextResponse.json({ hasOpenPRs }); + } catch (err) { + console.error("Error checking open PRs:", err); + return NextResponse.json({ hasOpenPRs: false }); + } +} diff --git a/components/RuleStatusBadge.tsx b/components/RuleStatusBadge.tsx new file mode 100644 index 000000000..92f6a68ff --- /dev/null +++ b/components/RuleStatusBadge.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Tooltip from "@/components/tooltip/tooltip"; +import { getRuleStatus, getRuleStatusDescription } from "@/lib/ruleStatus"; +import { cn } from "@/lib/utils"; + +interface RuleStatusBadgeProps { + lastUpdated: string | null | undefined; + rulePath?: string; + className?: string; +} + +const dotStyles = { + fresh: "bg-green-500", + stale: "bg-orange-500", + rebuilding: "bg-yellow-500", +} as const; + +export default function RuleStatusBadge({ lastUpdated, rulePath, className }: RuleStatusBadgeProps) { + const [isBeingRebuilt, setIsBeingRebuilt] = useState(false); + + useEffect(() => { + if (!rulePath) return; + + const checkOpenPRs = async () => { + try { + const params = new URLSearchParams({ path: rulePath }); + const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_PATH}/api/github-open-prs?${params.toString()}`); + if (response.ok) { + const { hasOpenPRs } = await response.json(); + setIsBeingRebuilt(hasOpenPRs); + } + } catch { + // Silently fail — default to no "rebuilding" state + } + }; + + checkOpenPRs(); + }, [rulePath]); + + const status = getRuleStatus(lastUpdated, isBeingRebuilt); + if (!status) return null; + + const description = getRuleStatusDescription(lastUpdated, status); + + return ( + + + + ); +} diff --git a/lib/ruleStatus.ts b/lib/ruleStatus.ts new file mode 100644 index 000000000..6317f5a58 --- /dev/null +++ b/lib/ruleStatus.ts @@ -0,0 +1,51 @@ +export type RuleStatus = "fresh" | "stale" | "rebuilding"; + +const STALE_THRESHOLD_MONTHS = 12; + +export function getRuleStatus(lastUpdated: string | null | undefined, isBeingRebuilt?: boolean): RuleStatus | null { + if (!lastUpdated) return null; + + const updatedDate = new Date(lastUpdated); + if (isNaN(updatedDate.getTime())) return null; + + if (isBeingRebuilt) return "rebuilding"; + + const now = new Date(); + const thresholdDate = new Date(now.getFullYear(), now.getMonth() - STALE_THRESHOLD_MONTHS, now.getDate()); + + return updatedDate >= thresholdDate ? "fresh" : "stale"; +} + +export function getRuleStatusLabel(status: RuleStatus): string { + switch (status) { + case "fresh": + return "Fresh"; + case "stale": + return "Stale"; + case "rebuilding": + return "Being Rebuilt"; + } +} + +export function getRuleStatusDescription(lastUpdated: string | null | undefined, status: RuleStatus): string { + if (!lastUpdated) return ""; + + const updatedDate = new Date(lastUpdated); + if (isNaN(updatedDate.getTime())) return ""; + + const now = new Date(); + const diffMs = now.getTime() - updatedDate.getTime(); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + const diffMonths = Math.floor(diffDays / 30); + + const timeAgoText = diffMonths < 1 ? `${diffDays} day${diffDays !== 1 ? "s" : ""} ago` : `${diffMonths} month${diffMonths !== 1 ? "s" : ""} ago`; + + switch (status) { + case "fresh": + return `This rule was last updated ${timeAgoText}`; + case "stale": + return `This rule was last updated ${timeAgoText} and may be outdated`; + case "rebuilding": + return `This rule is currently being updated (open PR detected)`; + } +} diff --git a/package.json b/package.json index a52f73059..d953d43b2 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "postcss": "^8.5.3", "postcss-import": "^16.1.0", "postcss-nesting": "^13.0.1", + "ts-node": "^10.9.2", "typescript": "^5.8.2" }, "dependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c4102061c..e87299518 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -218,7 +218,7 @@ importers: version: 10.1.0 jest: specifier: ^30.2.0 - version: 30.3.0(@types/node@22.19.15) + version: 30.3.0(@types/node@22.19.15)(ts-node@10.9.2(@types/node@22.19.15)(typescript@5.9.3)) jest-environment-jsdom: specifier: ^30.2.0 version: 30.3.0 @@ -231,6 +231,9 @@ importers: postcss-nesting: specifier: ^13.0.1 version: 13.0.2(postcss@8.5.8) + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@types/node@22.19.15)(typescript@5.9.3) typescript: specifier: ^5.8.2 version: 5.9.3 @@ -1175,6 +1178,10 @@ packages: '@corex/deepmerge@4.0.43': resolution: {integrity: sha512-N8uEMrMPL0cu/bdboEWpQYb/0i2K5Qn8eCsxzOmxSggJbbQte7ljMRoXm917AbntqTGOzdTu+vP3KOOzoC70HQ==} + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + '@csstools/color-helpers@5.1.0': resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} engines: {node: '>=18'} @@ -1657,8 +1664,8 @@ packages: peerDependencies: graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 - '@graphql-codegen/plugin-helpers@6.2.0': - resolution: {integrity: sha512-TKm0Q0+wRlg354Qt3PyXc+sy6dCKxmNofBsgmHoFZNVHtzMQSSgNT+rUWdwBwObQ9bFHiUVsDIv8QqxKMiKmpw==} + '@graphql-codegen/plugin-helpers@6.2.1': + resolution: {integrity: sha512-shRr26TfVZ6KFBjzRYUj02gLNh6yaECz9gTGgI6riANw5sSH9PONwTsBRYkEgU+6IXiL7VQeCumahvxSGFbRlQ==} engines: {node: '>=16'} peerDependencies: graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 @@ -2083,6 +2090,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@js-sdsl/ordered-map@4.4.2': resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} @@ -3671,6 +3681,18 @@ packages: resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} engines: {node: '>=10.13.0'} + '@tsconfig/node10@1.0.12': + resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==} + + '@tsconfig/node12@1.0.11': + resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} + + '@tsconfig/node14@1.0.3': + resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} + + '@tsconfig/node16@1.0.4': + resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -4287,10 +4309,9 @@ packages: peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - acorn@8.15.0: - resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + acorn-walk@8.3.5: + resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} engines: {node: '>=0.4.0'} - hasBin: true acorn@8.16.0: resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} @@ -4361,6 +4382,9 @@ packages: resolution: {integrity: sha512-S84oAM3By//EL98JhO9nYOgnvamEQxWzJyDGDVGMMJ1PdjFdxAuyy47SPm8kvY9fQdtLTM0vXMuG1SeT39ONxw==} engines: {node: '>=18.0.0'} + arg@4.1.3: + resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + arg@5.0.2: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} @@ -4830,6 +4854,9 @@ packages: engines: {node: '>=0.8'} hasBin: true + create-require@1.1.1: + resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + crelt@1.0.6: resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} @@ -5195,6 +5222,10 @@ packages: diff3@0.0.3: resolution: {integrity: sha512-iSq8ngPOt0K53A6eVr4d5Kn6GNrM2nQZtC740pzIriHtn4pOQ2lyzEXQMBeVcWERN0ye7fhBsk9PbLLQOnUx/g==} + diff@4.0.4: + resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==} + engines: {node: '>=0.3.1'} + diff@5.2.2: resolution: {integrity: sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==} engines: {node: '>=0.3.1'} @@ -6542,6 +6573,9 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + makeerror@1.0.12: resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} @@ -8425,6 +8459,20 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + ts-node@10.9.2: + resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} + hasBin: true + peerDependencies: + '@swc/core': '>=1.2.50' + '@swc/wasm': '>=1.2.50' + '@types/node': '*' + typescript: '>=2.7' + peerDependenciesMeta: + '@swc/core': + optional: true + '@swc/wasm': + optional: true + tslib@1.10.0: resolution: {integrity: sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==} @@ -8714,6 +8762,9 @@ packages: engines: {node: '>=8'} hasBin: true + v8-compile-cache-lib@3.0.1: + resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} + v8-to-istanbul@9.3.0: resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} engines: {node: '>=10.12.0'} @@ -8923,6 +8974,10 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} + yn@3.1.1: + resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} + engines: {node: '>=6'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -10265,6 +10320,10 @@ snapshots: '@corex/deepmerge@4.0.43': {} + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + '@csstools/color-helpers@5.1.0': {} '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': @@ -10633,14 +10692,13 @@ snapshots: lodash: 4.17.23 tslib: 2.6.3 - '@graphql-codegen/plugin-helpers@6.2.0(graphql@15.8.0)': + '@graphql-codegen/plugin-helpers@6.2.1(graphql@15.8.0)': dependencies: '@graphql-tools/utils': 11.0.0(graphql@15.8.0) change-case-all: 1.0.15 common-tags: 1.8.2 graphql: 15.8.0 import-from: 4.0.0 - lodash: 4.17.23 tslib: 2.6.3 '@graphql-codegen/schema-ast@4.1.0(graphql@15.8.0)': @@ -10967,7 +11025,7 @@ snapshots: jest-util: 30.3.0 slash: 3.0.0 - '@jest/core@30.3.0': + '@jest/core@30.3.0(ts-node@10.9.2(@types/node@22.19.15)(typescript@5.9.3))': dependencies: '@jest/console': 30.3.0 '@jest/pattern': 30.0.1 @@ -10982,7 +11040,7 @@ snapshots: exit-x: 0.2.2 graceful-fs: 4.2.11 jest-changed-files: 30.3.0 - jest-config: 30.3.0(@types/node@22.19.15) + jest-config: 30.3.0(@types/node@22.19.15)(ts-node@10.9.2(@types/node@22.19.15)(typescript@5.9.3)) jest-haste-map: 30.3.0 jest-message-util: 30.3.0 jest-regex-util: 30.0.1 @@ -11181,6 +11239,11 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + '@js-sdsl/ordered-map@4.4.2': {} '@jsep-plugin/assignment@1.3.0(jsep@1.4.0)': @@ -12929,7 +12992,7 @@ snapshots: '@tinacms/cli@2.2.0(@codemirror/language@6.0.0)(@types/node@22.19.15)(@types/react@19.2.14)(abstract-level@1.0.4)(immer@10.2.0)(lightningcss@1.31.1)(react-dnd-html5-backend@16.0.1)(react-dnd@16.0.1(@types/hoist-non-react-statics@3.3.7(@types/react@19.2.14))(@types/node@22.19.15)(@types/react@19.2.14)(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@3.30.0)(scheduler@0.27.0)(slate-dom@0.114.0(slate@0.114.0))(slate@0.114.0)(sucrase@3.35.1)(use-sync-external-store@1.6.0(react@19.2.4))(yaml@2.8.2)': dependencies: '@graphql-codegen/core': 2.6.8(graphql@15.8.0) - '@graphql-codegen/plugin-helpers': 6.2.0(graphql@15.8.0) + '@graphql-codegen/plugin-helpers': 6.2.1(graphql@15.8.0) '@graphql-codegen/typescript': 4.1.6(graphql@15.8.0) '@graphql-codegen/typescript-operations': 4.6.1(graphql@15.8.0) '@graphql-codegen/visitor-plugin-common': 4.1.2(graphql@15.8.0) @@ -13105,6 +13168,14 @@ snapshots: '@trysound/sax@0.2.0': {} + '@tsconfig/node10@1.0.12': {} + + '@tsconfig/node12@1.0.11': {} + + '@tsconfig/node14@1.0.3': {} + + '@tsconfig/node16@1.0.4': {} + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 @@ -13810,7 +13881,9 @@ snapshots: dependencies: acorn: 8.16.0 - acorn@8.15.0: {} + acorn-walk@8.3.5: + dependencies: + acorn: 8.16.0 acorn@8.16.0: {} @@ -13902,6 +13975,8 @@ snapshots: transitivePeerDependencies: - supports-color + arg@4.1.3: {} + arg@5.0.2: {} argparse@1.0.10: @@ -14412,6 +14487,8 @@ snapshots: crc-32@1.2.2: {} + create-require@1.1.1: {} + crelt@1.0.6: {} cross-env@10.1.0: @@ -14800,6 +14877,8 @@ snapshots: diff3@0.0.3: {} + diff@4.0.4: {} + diff@5.2.2: {} dir-glob@3.0.1: @@ -15752,15 +15831,15 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@30.3.0(@types/node@22.19.15): + jest-cli@30.3.0(@types/node@22.19.15)(ts-node@10.9.2(@types/node@22.19.15)(typescript@5.9.3)): dependencies: - '@jest/core': 30.3.0 + '@jest/core': 30.3.0(ts-node@10.9.2(@types/node@22.19.15)(typescript@5.9.3)) '@jest/test-result': 30.3.0 '@jest/types': 30.3.0 chalk: 4.1.2 exit-x: 0.2.2 import-local: 3.2.0 - jest-config: 30.3.0(@types/node@22.19.15) + jest-config: 30.3.0(@types/node@22.19.15)(ts-node@10.9.2(@types/node@22.19.15)(typescript@5.9.3)) jest-util: 30.3.0 jest-validate: 30.3.0 yargs: 17.7.2 @@ -15771,7 +15850,7 @@ snapshots: - supports-color - ts-node - jest-config@30.3.0(@types/node@22.19.15): + jest-config@30.3.0(@types/node@22.19.15)(ts-node@10.9.2(@types/node@22.19.15)(typescript@5.9.3)): dependencies: '@babel/core': 7.29.0 '@jest/get-type': 30.1.0 @@ -15798,6 +15877,7 @@ snapshots: strip-json-comments: 3.1.1 optionalDependencies: '@types/node': 22.19.15 + ts-node: 10.9.2(@types/node@22.19.15)(typescript@5.9.3) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -16068,12 +16148,12 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 - jest@30.3.0(@types/node@22.19.15): + jest@30.3.0(@types/node@22.19.15)(ts-node@10.9.2(@types/node@22.19.15)(typescript@5.9.3)): dependencies: - '@jest/core': 30.3.0 + '@jest/core': 30.3.0(ts-node@10.9.2(@types/node@22.19.15)(typescript@5.9.3)) '@jest/types': 30.3.0 import-local: 3.2.0 - jest-cli: 30.3.0(@types/node@22.19.15) + jest-cli: 30.3.0(@types/node@22.19.15)(ts-node@10.9.2(@types/node@22.19.15)(typescript@5.9.3)) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -16423,6 +16503,8 @@ snapshots: dependencies: semver: 7.7.4 + make-error@1.3.6: {} + makeerror@1.0.12: dependencies: tmpl: 1.0.5 @@ -17056,7 +17138,7 @@ snapshots: mlly@1.8.0: dependencies: - acorn: 8.15.0 + acorn: 8.16.0 pathe: 2.0.3 pkg-types: 1.3.1 ufo: 1.6.3 @@ -18895,6 +18977,24 @@ snapshots: ts-interface-checker@0.1.13: {} + ts-node@10.9.2(@types/node@22.19.15)(typescript@5.9.3): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.12 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 22.19.15 + acorn: 8.16.0 + acorn-walk: 8.3.5 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.4 + make-error: 1.3.6 + typescript: 5.9.3 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + tslib@1.10.0: {} tslib@1.14.1: {} @@ -19065,7 +19165,7 @@ snapshots: unplugin@2.3.11: dependencies: '@jridgewell/remapping': 2.3.5 - acorn: 8.15.0 + acorn: 8.16.0 picomatch: 4.0.3 webpack-virtual-modules: 0.6.2 @@ -19168,6 +19268,8 @@ snapshots: kleur: 4.1.5 sade: 1.8.1 + v8-compile-cache-lib@3.0.1: {} + v8-to-istanbul@9.3.0: dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -19353,6 +19455,8 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 + yn@3.1.1: {} + yocto-queue@0.1.0: {} youtube-video-element@1.8.1: {} From f9e8ccac0960dff05e5a655c80c5f85a4b127f4f Mon Sep 17 00:00:00 2001 From: PothieuG Date: Tue, 7 Apr 2026 16:25:13 +0200 Subject: [PATCH 2/3] Fixing build --- components/RuleStatusBadge.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/RuleStatusBadge.tsx b/components/RuleStatusBadge.tsx index 92f6a68ff..2df9817f9 100644 --- a/components/RuleStatusBadge.tsx +++ b/components/RuleStatusBadge.tsx @@ -45,7 +45,7 @@ export default function RuleStatusBadge({ lastUpdated, rulePath, className }: Ru const description = getRuleStatusDescription(lastUpdated, status); return ( - + ); From 5a1759caf5cc9449ee3ccb54b7a48818ca4b140d Mon Sep 17 00:00:00 2001 From: PothieuG Date: Mon, 13 Apr 2026 16:43:43 +0200 Subject: [PATCH 3/3] Changing revalidation logic with a more up to date revlidation --- app/[filename]/ServerRulePage.tsx | 5 +-- app/[filename]/page.tsx | 5 +++ app/api/isr-status/route.ts | 17 ++++++++++ app/api/revalidate/route.ts | 2 ++ components/RuleStatusBadge.tsx | 36 +++++++++++---------- lib/revalidation-store.ts | 37 +++++++++++++++++++++ lib/ruleStatus.ts | 54 ++++++++++++++----------------- 7 files changed, 107 insertions(+), 49 deletions(-) create mode 100644 app/api/isr-status/route.ts create mode 100644 lib/revalidation-store.ts diff --git a/app/[filename]/ServerRulePage.tsx b/app/[filename]/ServerRulePage.tsx index b73a8c232..576eda6d6 100644 --- a/app/[filename]/ServerRulePage.tsx +++ b/app/[filename]/ServerRulePage.tsx @@ -22,6 +22,7 @@ import { Card } from "@/components/ui/card"; export interface ServerRulePageProps { rule: any; brokenReferences?: BrokenReferences | null; + pageGeneratedAt?: number; } export type ServerRulePagePropsWithTinaProps = { @@ -34,7 +35,7 @@ export default function ServerRulePage({ serverRulePageProps, tinaProps }: Serve const rule = data?.rule; const { isAdmin: isAdminPage, isLoading: isAdminLoading } = useIsAdminPage(); - const { brokenReferences } = serverRulePageProps; + const { brokenReferences, pageGeneratedAt } = serverRulePageProps; const allCategories = rule.categories ?.map((c: any) => { const cat = c?.category; @@ -89,7 +90,7 @@ export default function ServerRulePage({ serverRulePageProps, tinaProps }: Serve
- +
diff --git a/app/[filename]/page.tsx b/app/[filename]/page.tsx index c114d2347..25777fb96 100644 --- a/app/[filename]/page.tsx +++ b/app/[filename]/page.tsx @@ -2,6 +2,7 @@ import React from "react"; import categoryTitleIndex from "@/category-uri-title-map.json"; import { Section } from "@/components/layout/section"; import { extractBodyPreview } from "@/lib/bodyUtils"; +import { recordPageGeneration } from "@/lib/revalidation-store"; import { siteUrl } from "@/site-config"; import client from "@/tina/__generated__/client"; import { CategoryWithRulesQueryDocument } from "@/tina/__generated__/types"; @@ -313,6 +314,9 @@ export default async function Page({ const rule = await getRuleData(filename); if (rule?.data) { + const pageGeneratedAt = Date.now(); + recordPageGeneration(filename, pageGeneratedAt); + return (
diff --git a/app/api/isr-status/route.ts b/app/api/isr-status/route.ts new file mode 100644 index 000000000..f3dd59b8d --- /dev/null +++ b/app/api/isr-status/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from "next/server"; +import { getSlugStatus } from "@/lib/revalidation-store"; + +export async function GET(req: Request) { + const { searchParams } = new URL(req.url); + const slug = searchParams.get("slug"); + + if (!slug || typeof slug !== "string") { + return NextResponse.json({ error: "slug parameter is required" }, { status: 400 }); + } + + const status = getSlugStatus(slug); + + return NextResponse.json(status, { + headers: { "Cache-Control": "no-store" }, + }); +} diff --git a/app/api/revalidate/route.ts b/app/api/revalidate/route.ts index d93fa7257..1e253c985 100644 --- a/app/api/revalidate/route.ts +++ b/app/api/revalidate/route.ts @@ -1,5 +1,6 @@ import { revalidatePath, revalidateTag } from "next/cache"; import { NextResponse } from "next/server"; +import { recordRevalidation } from "@/lib/revalidation-store"; enum TINA_CONTENT_CHANGE_TYPE { Modified = "content.modified", @@ -36,6 +37,7 @@ export async function POST(req: Request) { const slug = changedPath.replace("public/uploads/rules/", "").replace("/rule.mdx", "").replace(/\/+$/, ""); if (slug) { routesToRevalidate.add(`/${slug}`); + recordRevalidation(slug); } // If change type is add then we also need to revalidate the /api/rules route if (eventType === TINA_CONTENT_CHANGE_TYPE.Added) { diff --git a/components/RuleStatusBadge.tsx b/components/RuleStatusBadge.tsx index 2df9817f9..299fbf20e 100644 --- a/components/RuleStatusBadge.tsx +++ b/components/RuleStatusBadge.tsx @@ -2,12 +2,12 @@ import { useEffect, useState } from "react"; import Tooltip from "@/components/tooltip/tooltip"; -import { getRuleStatus, getRuleStatusDescription } from "@/lib/ruleStatus"; +import { getRuleStatus, getRuleStatusDescription, type RuleStatus } from "@/lib/ruleStatus"; import { cn } from "@/lib/utils"; interface RuleStatusBadgeProps { - lastUpdated: string | null | undefined; - rulePath?: string; + pageGeneratedAt?: number; + ruleSlug?: string; className?: string; } @@ -17,32 +17,34 @@ const dotStyles = { rebuilding: "bg-yellow-500", } as const; -export default function RuleStatusBadge({ lastUpdated, rulePath, className }: RuleStatusBadgeProps) { - const [isBeingRebuilt, setIsBeingRebuilt] = useState(false); +export default function RuleStatusBadge({ pageGeneratedAt, ruleSlug, className }: RuleStatusBadgeProps) { + const [status, setStatus] = useState("fresh"); + const [loaded, setLoaded] = useState(false); useEffect(() => { - if (!rulePath) return; + if (!ruleSlug) return; - const checkOpenPRs = async () => { + const fetchStatus = async () => { try { - const params = new URLSearchParams({ path: rulePath }); - const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_PATH}/api/github-open-prs?${params.toString()}`); + const params = new URLSearchParams({ slug: ruleSlug }); + const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/isr-status?${params.toString()}`); if (response.ok) { - const { hasOpenPRs } = await response.json(); - setIsBeingRebuilt(hasOpenPRs); + const { lastWebhookAt } = await response.json(); + setStatus(getRuleStatus(pageGeneratedAt, lastWebhookAt)); } } catch { - // Silently fail — default to no "rebuilding" state + // Silently fail — default to "fresh" + } finally { + setLoaded(true); } }; - checkOpenPRs(); - }, [rulePath]); + fetchStatus(); + }, [ruleSlug, pageGeneratedAt]); - const status = getRuleStatus(lastUpdated, isBeingRebuilt); - if (!status) return null; + if (!loaded) return null; - const description = getRuleStatusDescription(lastUpdated, status); + const description = getRuleStatusDescription(status); return ( diff --git a/lib/revalidation-store.ts b/lib/revalidation-store.ts new file mode 100644 index 000000000..19e22737b --- /dev/null +++ b/lib/revalidation-store.ts @@ -0,0 +1,37 @@ +// In-memory store for tracking ISR revalidation events per rule slug. +// NOTE: This store is not shared across server instances. In a multi-instance +// deployment (e.g. multiple Azure containers), each instance maintains its own +// copy. For production at scale, consider a shared store (Redis, Azure Table Storage). + +interface SlugStatus { + lastWebhookAt: number | null; + lastGeneratedAt: number | null; +} + +const store = new Map(); + +function getOrCreate(slug: string): SlugStatus { + let entry = store.get(slug); + if (!entry) { + entry = { lastWebhookAt: null, lastGeneratedAt: null }; + store.set(slug, entry); + } + return entry; +} + +/** Record that a TinaCMS webhook triggered revalidation for a rule slug. */ +export function recordRevalidation(slug: string): void { + const entry = getOrCreate(slug); + entry.lastWebhookAt = Date.now(); +} + +/** Record that a rule page was generated (ISR) at the given timestamp. */ +export function recordPageGeneration(slug: string, timestamp: number): void { + const entry = getOrCreate(slug); + entry.lastGeneratedAt = timestamp; +} + +/** Get the revalidation/generation timestamps for a rule slug. */ +export function getSlugStatus(slug: string): SlugStatus { + return store.get(slug) ?? { lastWebhookAt: null, lastGeneratedAt: null }; +} diff --git a/lib/ruleStatus.ts b/lib/ruleStatus.ts index 6317f5a58..b30591a14 100644 --- a/lib/ruleStatus.ts +++ b/lib/ruleStatus.ts @@ -1,19 +1,25 @@ export type RuleStatus = "fresh" | "stale" | "rebuilding"; -const STALE_THRESHOLD_MONTHS = 12; - -export function getRuleStatus(lastUpdated: string | null | undefined, isBeingRebuilt?: boolean): RuleStatus | null { - if (!lastUpdated) return null; - - const updatedDate = new Date(lastUpdated); - if (isNaN(updatedDate.getTime())) return null; - - if (isBeingRebuilt) return "rebuilding"; - - const now = new Date(); - const thresholdDate = new Date(now.getFullYear(), now.getMonth() - STALE_THRESHOLD_MONTHS, now.getDate()); - - return updatedDate >= thresholdDate ? "fresh" : "stale"; +/** If a webhook was received but the page hasn't regenerated within this window, consider it "rebuilding". Beyond this, it's "stale". */ +const REBUILDING_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes + +/** + * Determine the ISR status of a rule page by comparing timestamps. + * + * @param pageGeneratedAt - When this static page was last rendered (baked into HTML at ISR time) + * @param lastWebhookAt - When the most recent TinaCMS webhook fired for this slug (from revalidation-store) + * @param now - Current time in ms (defaults to Date.now(), injectable for testing) + */ +export function getRuleStatus(pageGeneratedAt: number | undefined, lastWebhookAt: number | null, now: number = Date.now()): RuleStatus { + // No webhook recorded → page is as fresh as it can be + if (!lastWebhookAt) return "fresh"; + + // Page was generated after the webhook → content is up to date + if (pageGeneratedAt && pageGeneratedAt >= lastWebhookAt) return "fresh"; + + // Webhook fired but page hasn't regenerated yet + const elapsed = now - lastWebhookAt; + return elapsed < REBUILDING_THRESHOLD_MS ? "rebuilding" : "stale"; } export function getRuleStatusLabel(status: RuleStatus): string { @@ -27,25 +33,13 @@ export function getRuleStatusLabel(status: RuleStatus): string { } } -export function getRuleStatusDescription(lastUpdated: string | null | undefined, status: RuleStatus): string { - if (!lastUpdated) return ""; - - const updatedDate = new Date(lastUpdated); - if (isNaN(updatedDate.getTime())) return ""; - - const now = new Date(); - const diffMs = now.getTime() - updatedDate.getTime(); - const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); - const diffMonths = Math.floor(diffDays / 30); - - const timeAgoText = diffMonths < 1 ? `${diffDays} day${diffDays !== 1 ? "s" : ""} ago` : `${diffMonths} month${diffMonths !== 1 ? "s" : ""} ago`; - +export function getRuleStatusDescription(status: RuleStatus): string { switch (status) { case "fresh": - return `This rule was last updated ${timeAgoText}`; + return "This page is up to date with the latest content"; case "stale": - return `This rule was last updated ${timeAgoText} and may be outdated`; + return "Content has changed but this page has not been regenerated yet"; case "rebuilding": - return `This rule is currently being updated (open PR detected)`; + return "Content has changed and the page is being regenerated"; } }