import { useMemo } from 'react' // Public CHANGELOG (human-readable, без commit hashes / scope prefixes, // curated handler-friendly text). Lives в `docs/changelog.md` чтобы single // source of truth для public-facing changelog был один (docs build тоже // его публикует на /docs/changelog.html через Diplodoc). // // Internal CHANGELOG (semantic-release auto-generated, с commit hashes для // engineering team) — `ordinis-admin-ui/CHANGELOG.md`. Не bundled в UI. // eslint-disable-next-line import/no-unresolved -- Vite ?raw query resolved at build import changelogRaw from '../../../../docs/changelog.md?raw' /** * Parsed CHANGELOG entry — one version block from semantic-release output. * *

Source format (semantic-release default): *

 * ## [2.14.0](https://git.nstart.cloud/.../compare/v2.13.0...v2.14.0) (2026-05-12)
 *
 * ### ✨ Features
 *
 * * **webhook:** time-series histogram stats endpoint ([5479142](...))
 *
 * ### 🐛 Fixes
 *
 * * **ux:** friendly error UI вместо raw AxiosError ([3984d99](...))
 * 
* *

Parsed → structured for «What's New» modal rendering. */ export type ChangelogEntry = { /** Version string без 'v' префикса (e.g. "2.14.0"). */ version: string /** Compare link к git diff между previous и этой версии. */ compareUrl?: string /** Release date (ISO yyyy-mm-dd) если parsed из header'а. */ date?: string /** Категория → bullets. Категории: "Features", "Fixes", "Performance", etc. */ sections: ChangelogSection[] } export type ChangelogSection = { /** Display title без emoji (e.g. "Features"). */ title: string /** Emoji preserved if present (e.g. "✨"). */ emoji?: string items: ChangelogItem[] } export type ChangelogItem = { /** Scope если был в commit'е (e.g. "webhook", "ui"). */ scope?: string /** Description без scope + без commit hash link. */ description: string /** Git commit hash (8 chars). */ commit?: string /** Full commit URL. */ commitUrl?: string } /** * Parse CHANGELOG.md text → structured ChangelogEntry[]. * *

Format assumptions (semantic-release default): *

* *

Robust against missing parts: пустые scope, missing commit URL, * missing date. Returns entries в порядке появления (newest first для * semantic-release output). */ export function parseChangelog(raw: string): ChangelogEntry[] { const entries: ChangelogEntry[] = [] // Split на version headers — каждый ## [x.y.z] block. const versionRegex = /^##\s+\[([^\]]+)\](?:\(([^)]+)\))?\s*(?:\(([\d-]+)\))?/gm const matches: { start: number; end: number; version: string; url?: string; date?: string }[] = [] let m: RegExpExecArray | null while ((m = versionRegex.exec(raw)) !== null) { matches.push({ start: m.index, end: m.index, // tentative, finalized below version: m[1], url: m[2], date: m[3], }) } // Finalize end indices — каждый block заканчивается где начинается следующий for (let i = 0; i < matches.length; i++) { matches[i].end = i + 1 < matches.length ? matches[i + 1].start : raw.length } for (const block of matches) { const body = raw.substring(block.start, block.end) const sections = parseSections(body) entries.push({ version: block.version, compareUrl: block.url, date: block.date, sections, }) } return entries } function parseSections(body: string): ChangelogSection[] { const sectionRegex = /^###\s+(?:(\p{Emoji_Presentation}|\p{Extended_Pictographic})\s+)?(.+)$/gmu const sections: ChangelogSection[] = [] const matches: { start: number; end: number; emoji?: string; title: string }[] = [] let m: RegExpExecArray | null while ((m = sectionRegex.exec(body)) !== null) { matches.push({ start: m.index, end: m.index, emoji: m[1], title: m[2].trim(), }) } for (let i = 0; i < matches.length; i++) { matches[i].end = i + 1 < matches.length ? matches[i + 1].start : body.length } for (const s of matches) { const sectionBody = body.substring(s.start, s.end) const items = parseItems(sectionBody) sections.push({ title: s.title, emoji: s.emoji, items }) } return sections } const ITEM_REGEX = /^\*\s+(?:\*\*([^:]+?):\*\*\s+)?(.+?)(?:\s+\(\[([a-f0-9]{7,})\]\(([^)]+)\)\))?\s*$/gm function parseItems(sectionBody: string): ChangelogItem[] { const items: ChangelogItem[] = [] let m: RegExpExecArray | null const regex = new RegExp(ITEM_REGEX.source, ITEM_REGEX.flags) while ((m = regex.exec(sectionBody)) !== null) { items.push({ scope: m[1]?.trim(), description: m[2].trim(), commit: m[3], commitUrl: m[4], }) } return items } /** * Hook: returns parsed CHANGELOG entries. Memoized — parse runs once per * page load (CHANGELOG bundled into build, не fetched runtime). * *

Entries sorted newest-first (semantic-release output order). */ export function useChangelog(): ChangelogEntry[] { return useMemo(() => parseChangelog(changelogRaw), []) } /** * LocalStorage key для tracking last-seen version. Per-user (browser-scoped). * Если key empty / missing — пользователь никогда не открывал What's New → * unread = ALL entries (limited к recent N в UI). */ const LS_KEY = 'ord-whatsnew-last-seen' /** * Returns version string from localStorage, или null если не set. */ export function readLastSeenVersion(): string | null { try { return localStorage.getItem(LS_KEY) } catch { return null } } /** * Persist last-seen version. Called когда user открывает What's New modal * AND видит latest entry. Throttle через just writing once per session * (modal close persists current latest version → next session checks delta). */ export function writeLastSeenVersion(version: string): void { try { localStorage.setItem(LS_KEY, version) } catch { // localStorage может быть disabled (Safari private mode) — fail silently } } /** * Compare versions для unread detection. Semantic versioning major.minor.patch. * Returns true если {@code candidate} newer than {@code seen}. * *

Robust к unknown formats: «dev», «local», missing parts. Returns false * (treat as not-newer) для unparseable. */ export function isVersionNewer(candidate: string, seen: string | null): boolean { if (!seen) return true // нет baseline → все entries unread const parse = (v: string): number[] | null => { const m = v.match(/^v?(\d+)\.(\d+)\.(\d+)/) if (!m) return null return [parseInt(m[1], 10), parseInt(m[2], 10), parseInt(m[3], 10)] } const a = parse(candidate) const b = parse(seen) if (!a || !b) return false for (let i = 0; i < 3; i++) { if (a[i] > b[i]) return true if (a[i] < b[i]) return false } return false } /** * Compute count of entries newer than last seen — для notification dot * на «What's New» button. */ export function useUnreadCount(): number { const entries = useChangelog() return useMemo(() => { const seen = readLastSeenVersion() return entries.filter((e) => isVersionNewer(e.version, seen)).length // eslint-disable-next-line react-hooks/exhaustive-deps -- seen из localStorage, не trigger'ит re-render автоматически }, [entries]) }