613 lines
17 KiB
TypeScript
613 lines
17 KiB
TypeScript
import { readFile } from "node:fs/promises"
|
|
import path from "node:path"
|
|
import { hasPermission } from "@cms/content/rbac"
|
|
import Link from "next/link"
|
|
import { redirect } from "next/navigation"
|
|
|
|
import { resolveRoleFromServerContext } from "@/lib/access-server"
|
|
|
|
export const dynamic = "force-dynamic"
|
|
|
|
type TodoState = "done" | "partial" | "planned"
|
|
type TodoPriority = "P1" | "P2" | "P3" | "NONE"
|
|
type StateFilter = "all" | TodoState
|
|
type PriorityFilter = "all" | Exclude<TodoPriority, "NONE">
|
|
type MetaFilter = "show" | "hide"
|
|
|
|
type TodoTask = {
|
|
state: TodoState
|
|
text: string
|
|
priority: TodoPriority
|
|
}
|
|
|
|
type TodoBlock = {
|
|
title: string
|
|
tasks: TodoTask[]
|
|
notes: string[]
|
|
}
|
|
|
|
type TodoSection = {
|
|
title: string
|
|
blocks: TodoBlock[]
|
|
notes: string[]
|
|
}
|
|
|
|
type ProgressCounts = {
|
|
done: number
|
|
partial: number
|
|
planned: number
|
|
}
|
|
|
|
type SearchParamsInput = Record<string, string | string[] | undefined>
|
|
|
|
const metaSections = new Set(["Status Legend", "Priority Legend", "How We Use This File"])
|
|
|
|
const stateOrder: Record<TodoState, number> = {
|
|
partial: 1,
|
|
planned: 2,
|
|
done: 3,
|
|
}
|
|
|
|
const priorityOrder: Record<TodoPriority, number> = {
|
|
P1: 1,
|
|
P2: 2,
|
|
P3: 3,
|
|
NONE: 4,
|
|
}
|
|
|
|
function isMetaSection(section: TodoSection | null): boolean {
|
|
return section ? metaSections.has(section.title) : false
|
|
}
|
|
|
|
function toTaskState(token: string): TodoState {
|
|
const value = token.toLowerCase()
|
|
|
|
if (value === "x") {
|
|
return "done"
|
|
}
|
|
|
|
if (value === "~") {
|
|
return "partial"
|
|
}
|
|
|
|
return "planned"
|
|
}
|
|
|
|
function parsePriority(rawText: string): { priority: TodoPriority; text: string } {
|
|
const match = rawText.match(/^\[(P[1-3])\]\s+(.+)$/i)
|
|
|
|
if (!match) {
|
|
return {
|
|
priority: "NONE",
|
|
text: rawText.trim(),
|
|
}
|
|
}
|
|
|
|
return {
|
|
priority: match[1].toUpperCase() as Exclude<TodoPriority, "NONE">,
|
|
text: match[2].trim(),
|
|
}
|
|
}
|
|
|
|
function parseTodo(content: string): TodoSection[] {
|
|
const sections: TodoSection[] = []
|
|
let currentSection: TodoSection | null = null
|
|
let currentBlock: TodoBlock | null = null
|
|
|
|
const ensureSection = (title = "General"): TodoSection => {
|
|
if (!currentSection) {
|
|
currentSection = {
|
|
title,
|
|
blocks: [],
|
|
notes: [],
|
|
}
|
|
sections.push(currentSection)
|
|
}
|
|
|
|
return currentSection
|
|
}
|
|
|
|
const ensureBlock = (title = "Overview"): TodoBlock => {
|
|
const section = ensureSection()
|
|
|
|
if (!currentBlock) {
|
|
currentBlock = {
|
|
title,
|
|
tasks: [],
|
|
notes: [],
|
|
}
|
|
section.blocks.push(currentBlock)
|
|
}
|
|
|
|
return currentBlock
|
|
}
|
|
|
|
for (const rawLine of content.split("\n")) {
|
|
const line = rawLine.trim()
|
|
|
|
if (!line) {
|
|
continue
|
|
}
|
|
|
|
if (line.startsWith("# ")) {
|
|
continue
|
|
}
|
|
|
|
if (line.startsWith("## ")) {
|
|
currentSection = {
|
|
title: line.replace("## ", "").trim(),
|
|
blocks: [],
|
|
notes: [],
|
|
}
|
|
sections.push(currentSection)
|
|
currentBlock = null
|
|
continue
|
|
}
|
|
|
|
if (line.startsWith("### ")) {
|
|
const section = ensureSection()
|
|
|
|
currentBlock = {
|
|
title: line.replace("### ", "").trim(),
|
|
tasks: [],
|
|
notes: [],
|
|
}
|
|
|
|
section.blocks.push(currentBlock)
|
|
continue
|
|
}
|
|
|
|
const taskMatch = line.match(/^- \[([ x~X])\] (.+)$/)
|
|
|
|
if (taskMatch) {
|
|
if (isMetaSection(currentSection)) {
|
|
ensureSection().notes.push(taskMatch[0].replace("- ", ""))
|
|
continue
|
|
}
|
|
|
|
const task = parsePriority(taskMatch[2].trim())
|
|
|
|
ensureBlock().tasks.push({
|
|
state: toTaskState(taskMatch[1]),
|
|
text: task.text,
|
|
priority: task.priority,
|
|
})
|
|
continue
|
|
}
|
|
|
|
const bulletMatch = line.match(/^- (.+)$/)
|
|
|
|
if (bulletMatch) {
|
|
if (currentBlock) {
|
|
currentBlock.notes.push(bulletMatch[1].trim())
|
|
} else {
|
|
ensureSection().notes.push(bulletMatch[1].trim())
|
|
}
|
|
}
|
|
}
|
|
|
|
return sections
|
|
}
|
|
|
|
function getProgressCounts(sections: TodoSection[]): ProgressCounts {
|
|
const counts: ProgressCounts = {
|
|
done: 0,
|
|
partial: 0,
|
|
planned: 0,
|
|
}
|
|
|
|
for (const section of sections) {
|
|
if (isMetaSection(section)) {
|
|
continue
|
|
}
|
|
|
|
for (const block of section.blocks) {
|
|
for (const task of block.tasks) {
|
|
counts[task.state] += 1
|
|
}
|
|
}
|
|
}
|
|
|
|
return counts
|
|
}
|
|
|
|
function getProgressPercent(progress: ProgressCounts): number {
|
|
const total = progress.done + progress.partial + progress.planned
|
|
|
|
if (total === 0) {
|
|
return 0
|
|
}
|
|
|
|
return Math.round(((progress.done + progress.partial * 0.5) / total) * 100)
|
|
}
|
|
|
|
async function getTodoMarkdown(): Promise<string> {
|
|
const candidates = [
|
|
path.resolve(process.cwd(), "TODO.md"),
|
|
path.resolve(process.cwd(), "../TODO.md"),
|
|
path.resolve(process.cwd(), "../../TODO.md"),
|
|
]
|
|
|
|
for (const filePath of candidates) {
|
|
try {
|
|
return await readFile(filePath, "utf8")
|
|
} catch {
|
|
// Try next candidate path.
|
|
}
|
|
}
|
|
|
|
return "# TODO file not found\n\nCreate `/TODO.md` in the repository root to track progress."
|
|
}
|
|
|
|
function statusBadgeClass(state: TodoState): string {
|
|
if (state === "done") {
|
|
return "bg-emerald-50 text-emerald-700 border-emerald-200"
|
|
}
|
|
|
|
if (state === "partial") {
|
|
return "bg-amber-50 text-amber-700 border-amber-200"
|
|
}
|
|
|
|
return "bg-slate-50 text-slate-700 border-slate-200"
|
|
}
|
|
|
|
function statusLabel(state: TodoState): string {
|
|
if (state === "done") {
|
|
return "Done"
|
|
}
|
|
|
|
if (state === "partial") {
|
|
return "Partial"
|
|
}
|
|
|
|
return "Planned"
|
|
}
|
|
|
|
function priorityBadgeClass(priority: TodoPriority): string {
|
|
if (priority === "P1") {
|
|
return "bg-rose-50 text-rose-700 border-rose-200"
|
|
}
|
|
|
|
if (priority === "P2") {
|
|
return "bg-indigo-50 text-indigo-700 border-indigo-200"
|
|
}
|
|
|
|
if (priority === "P3") {
|
|
return "bg-teal-50 text-teal-700 border-teal-200"
|
|
}
|
|
|
|
return "bg-neutral-100 text-neutral-700 border-neutral-200"
|
|
}
|
|
|
|
function normalizeStateFilter(value: string | undefined): StateFilter {
|
|
if (value === "done" || value === "partial" || value === "planned") {
|
|
return value
|
|
}
|
|
|
|
return "all"
|
|
}
|
|
|
|
function normalizePriorityFilter(value: string | undefined): PriorityFilter {
|
|
if (value === "P1" || value === "P2" || value === "P3") {
|
|
return value
|
|
}
|
|
|
|
return "all"
|
|
}
|
|
|
|
function normalizeMetaFilter(value: string | undefined): MetaFilter {
|
|
if (value === "show") {
|
|
return "show"
|
|
}
|
|
|
|
return "hide"
|
|
}
|
|
|
|
function toSingleQueryValue(value: string | string[] | undefined): string | undefined {
|
|
if (Array.isArray(value)) {
|
|
return value[0]
|
|
}
|
|
|
|
return value
|
|
}
|
|
|
|
function sortTasks(tasks: TodoTask[]): TodoTask[] {
|
|
return [...tasks].sort((left, right) => {
|
|
const priorityDelta = priorityOrder[left.priority] - priorityOrder[right.priority]
|
|
|
|
if (priorityDelta !== 0) {
|
|
return priorityDelta
|
|
}
|
|
|
|
const stateDelta = stateOrder[left.state] - stateOrder[right.state]
|
|
|
|
if (stateDelta !== 0) {
|
|
return stateDelta
|
|
}
|
|
|
|
return left.text.localeCompare(right.text)
|
|
})
|
|
}
|
|
|
|
function applyFilters(
|
|
sections: TodoSection[],
|
|
stateFilter: StateFilter,
|
|
priorityFilter: PriorityFilter,
|
|
metaFilter: MetaFilter,
|
|
): TodoSection[] {
|
|
return sections
|
|
.filter((section) => metaFilter === "show" || !isMetaSection(section))
|
|
.map((section) => {
|
|
const filteredBlocks = section.blocks
|
|
.map((block) => {
|
|
const filteredTasks = sortTasks(
|
|
block.tasks.filter((task) => {
|
|
const stateMatches = stateFilter === "all" || task.state === stateFilter
|
|
const priorityMatches = priorityFilter === "all" || task.priority === priorityFilter
|
|
|
|
return stateMatches && priorityMatches
|
|
}),
|
|
)
|
|
|
|
return {
|
|
...block,
|
|
tasks: filteredTasks,
|
|
}
|
|
})
|
|
.filter((block) => block.tasks.length > 0 || block.notes.length > 0)
|
|
|
|
return {
|
|
...section,
|
|
blocks: filteredBlocks,
|
|
}
|
|
})
|
|
.filter((section) => section.blocks.length > 0 || section.notes.length > 0)
|
|
}
|
|
|
|
function buildFilterHref(
|
|
current: { state: StateFilter; priority: PriorityFilter; meta: MetaFilter },
|
|
overrides: Partial<{ state: StateFilter; priority: PriorityFilter; meta: MetaFilter }>,
|
|
): string {
|
|
const next = {
|
|
...current,
|
|
...overrides,
|
|
}
|
|
|
|
const params = new URLSearchParams()
|
|
|
|
if (next.state !== "all") {
|
|
params.set("state", next.state)
|
|
}
|
|
|
|
if (next.priority !== "all") {
|
|
params.set("priority", next.priority)
|
|
}
|
|
|
|
if (next.meta !== "hide") {
|
|
params.set("meta", next.meta)
|
|
}
|
|
|
|
const query = params.toString()
|
|
|
|
if (!query) {
|
|
return "/todo"
|
|
}
|
|
|
|
return `/todo?${query}`
|
|
}
|
|
|
|
function filterButtonClass(active: boolean): string {
|
|
return active
|
|
? "bg-neutral-900 text-white border-neutral-900"
|
|
: "bg-white text-neutral-700 border-neutral-300 hover:bg-neutral-100"
|
|
}
|
|
|
|
export default async function AdminTodoPage(props: {
|
|
searchParams?: SearchParamsInput | Promise<SearchParamsInput>
|
|
}) {
|
|
const role = await resolveRoleFromServerContext()
|
|
|
|
if (!role) {
|
|
redirect("/login?next=/todo")
|
|
}
|
|
|
|
if (!hasPermission(role, "roadmap:read", "global")) {
|
|
redirect("/unauthorized?required=roadmap:read&scope=global")
|
|
}
|
|
|
|
const content = await getTodoMarkdown()
|
|
const sections = parseTodo(content)
|
|
const progress = getProgressCounts(sections)
|
|
const completionPercent = getProgressPercent(progress)
|
|
|
|
const rawSearchParams = props.searchParams ? await props.searchParams : {}
|
|
const stateFilter = normalizeStateFilter(toSingleQueryValue(rawSearchParams.state))
|
|
const priorityFilter = normalizePriorityFilter(toSingleQueryValue(rawSearchParams.priority))
|
|
const metaFilter = normalizeMetaFilter(toSingleQueryValue(rawSearchParams.meta))
|
|
|
|
const filteredSections = applyFilters(sections, stateFilter, priorityFilter, metaFilter)
|
|
|
|
const currentFilters = {
|
|
state: stateFilter,
|
|
priority: priorityFilter,
|
|
meta: metaFilter,
|
|
}
|
|
|
|
return (
|
|
<main className="mx-auto flex min-h-screen w-full max-w-6xl flex-col gap-8 px-6 py-12">
|
|
<header className="space-y-4">
|
|
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">Admin App</p>
|
|
<div className="flex flex-wrap items-end justify-between gap-4">
|
|
<div className="space-y-2">
|
|
<h1 className="text-4xl font-semibold tracking-tight">Roadmap and Progress</h1>
|
|
<p className="text-neutral-600">
|
|
Structured view from root `TODO.md` (single source of truth).
|
|
</p>
|
|
</div>
|
|
|
|
<Link
|
|
href="/"
|
|
className="inline-flex rounded-md border border-neutral-300 px-4 py-2 text-sm font-medium hover:bg-neutral-100"
|
|
>
|
|
Back to dashboard
|
|
</Link>
|
|
</div>
|
|
</header>
|
|
|
|
<section className="rounded-xl border border-neutral-200 bg-neutral-50 p-5">
|
|
<div className="mb-4 flex items-center justify-between gap-4">
|
|
<p className="text-sm font-medium text-neutral-600">Weighted completion</p>
|
|
<p className="text-sm font-semibold text-neutral-900">{completionPercent}%</p>
|
|
</div>
|
|
|
|
<div className="h-2 w-full rounded-full bg-neutral-200">
|
|
<div
|
|
className="h-2 rounded-full bg-neutral-900"
|
|
style={{ width: `${completionPercent}%` }}
|
|
/>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="grid gap-4 sm:grid-cols-3">
|
|
<article className="rounded-xl border border-emerald-200 bg-emerald-50/50 p-4">
|
|
<p className="text-sm text-emerald-700">Completed items</p>
|
|
<p className="mt-2 text-3xl font-semibold text-emerald-900">{progress.done}</p>
|
|
</article>
|
|
|
|
<article className="rounded-xl border border-amber-200 bg-amber-50/60 p-4">
|
|
<p className="text-sm text-amber-700">Partially done items</p>
|
|
<p className="mt-2 text-3xl font-semibold text-amber-900">{progress.partial}</p>
|
|
</article>
|
|
|
|
<article className="rounded-xl border border-slate-300 bg-slate-50 p-4">
|
|
<p className="text-sm text-slate-700">Planned items</p>
|
|
<p className="mt-2 text-3xl font-semibold text-slate-900">{progress.planned}</p>
|
|
</article>
|
|
</section>
|
|
|
|
<section className="rounded-xl border border-neutral-200 p-5">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<p className="mr-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
|
Status
|
|
</p>
|
|
|
|
{(["all", "planned", "partial", "done"] as const).map((value) => (
|
|
<Link
|
|
key={value}
|
|
href={buildFilterHref(currentFilters, { state: value })}
|
|
className={`rounded-full border px-3 py-1.5 text-xs font-medium ${filterButtonClass(stateFilter === value)}`}
|
|
>
|
|
{value === "all" ? "All" : statusLabel(value)}
|
|
</Link>
|
|
))}
|
|
</div>
|
|
|
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
|
<p className="mr-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
|
Priority
|
|
</p>
|
|
|
|
{(["all", "P1", "P2", "P3"] as const).map((value) => (
|
|
<Link
|
|
key={value}
|
|
href={buildFilterHref(currentFilters, { priority: value })}
|
|
className={`rounded-full border px-3 py-1.5 text-xs font-medium ${filterButtonClass(priorityFilter === value)}`}
|
|
>
|
|
{value}
|
|
</Link>
|
|
))}
|
|
|
|
<Link
|
|
href={buildFilterHref(currentFilters, {
|
|
meta: metaFilter === "show" ? "hide" : "show",
|
|
})}
|
|
className={`ml-auto rounded-full border px-3 py-1.5 text-xs font-medium ${filterButtonClass(metaFilter === "show")}`}
|
|
>
|
|
{metaFilter === "show" ? "Hide Meta Sections" : "Show Meta Sections"}
|
|
</Link>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="grid gap-5">
|
|
{filteredSections.length === 0 ? (
|
|
<article className="rounded-xl border border-neutral-200 p-5 text-sm text-neutral-600">
|
|
No items match the current filter.
|
|
</article>
|
|
) : (
|
|
filteredSections.map((section) => (
|
|
<article key={section.title} className="rounded-xl border border-neutral-200 p-5">
|
|
<h2 className="text-xl font-semibold tracking-tight text-neutral-900">
|
|
{section.title}
|
|
</h2>
|
|
|
|
{section.notes.length > 0 ? (
|
|
<ul className="mt-3 list-disc space-y-1 pl-5 text-sm text-neutral-600">
|
|
{section.notes.map((note) => (
|
|
<li key={note}>{note}</li>
|
|
))}
|
|
</ul>
|
|
) : null}
|
|
|
|
<div className="mt-4 grid gap-4">
|
|
{section.blocks.map((block) => (
|
|
<section
|
|
key={`${section.title}-${block.title}`}
|
|
className="rounded-lg bg-neutral-50 p-4"
|
|
>
|
|
<h3 className="text-sm font-semibold uppercase tracking-wide text-neutral-500">
|
|
{block.title}
|
|
</h3>
|
|
|
|
<ul className="mt-3 space-y-2">
|
|
{block.tasks.map((task) => (
|
|
<li
|
|
key={`${block.title}-${task.priority}-${task.text}`}
|
|
className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-neutral-200 bg-white px-3 py-2"
|
|
>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
{task.priority !== "NONE" ? (
|
|
<span
|
|
className={`rounded-full border px-2 py-0.5 text-[11px] font-semibold ${priorityBadgeClass(task.priority)}`}
|
|
>
|
|
{task.priority}
|
|
</span>
|
|
) : null}
|
|
<p className="text-sm text-neutral-800">{task.text}</p>
|
|
</div>
|
|
|
|
<span
|
|
className={`rounded-full border px-2.5 py-1 text-xs font-medium ${statusBadgeClass(task.state)}`}
|
|
>
|
|
{statusLabel(task.state)}
|
|
</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
|
|
{block.notes.length > 0 ? (
|
|
<ul className="mt-3 list-disc space-y-1 pl-5 text-sm text-neutral-600">
|
|
{block.notes.map((note) => (
|
|
<li key={note}>{note}</li>
|
|
))}
|
|
</ul>
|
|
) : null}
|
|
</section>
|
|
))}
|
|
</div>
|
|
</article>
|
|
))
|
|
)}
|
|
</section>
|
|
|
|
<details className="rounded-xl border border-neutral-200 p-5">
|
|
<summary className="cursor-pointer text-sm font-medium text-neutral-700">
|
|
View raw markdown source (`TODO.md`)
|
|
</summary>
|
|
<pre className="mt-4 overflow-x-auto whitespace-pre-wrap rounded-lg bg-neutral-50 p-4 text-sm leading-6 text-neutral-800">
|
|
{content}
|
|
</pre>
|
|
</details>
|
|
</main>
|
|
)
|
|
}
|