feat(topbar): «Что нового» button + 2026-05-12 state.md update
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
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.
|
||||
*
|
||||
* <p>Source format (semantic-release default):
|
||||
* <pre>
|
||||
* ## [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](...))
|
||||
* </pre>
|
||||
*
|
||||
* <p>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[].
|
||||
*
|
||||
* <p>Format assumptions (semantic-release default):
|
||||
* <ul>
|
||||
* <li>Version header: {@code ## [2.14.0](url) (yyyy-mm-dd)}</li>
|
||||
* <li>Section header: {@code ### ✨ Features} (emoji optional)</li>
|
||||
* <li>Item: {@code * **scope:** description ([hash](url))}</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>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).
|
||||
*
|
||||
* <p>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}.
|
||||
*
|
||||
* <p>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])
|
||||
}
|
||||
Reference in New Issue
Block a user