prepare repo

This commit is contained in:
2026-02-10 02:18:38 +01:00
parent ef5e98ad5d
commit 781a4c3a4e
10 changed files with 835 additions and 123 deletions

View File

@@ -5,10 +5,15 @@ import Link from "next/link"
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 = {
@@ -29,7 +34,22 @@ type ProgressCounts = {
planned: number
}
const metaSections = new Set(["Status Legend"])
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
@@ -49,6 +69,22 @@ function toTaskState(token: string): TodoState {
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
@@ -125,9 +161,12 @@ function parseTodo(content: string): TodoSection[] {
continue
}
const task = parsePriority(taskMatch[2].trim())
ensureBlock().tasks.push({
state: toTaskState(taskMatch[1]),
text: taskMatch[2].trim(),
text: task.text,
priority: task.priority,
})
continue
}
@@ -220,12 +259,166 @@ function statusLabel(state: TodoState): string {
return "Planned"
}
export default async function AdminTodoPage() {
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 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">
@@ -278,59 +471,118 @@ export default async function AdminTodoPage() {
</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">
{sections.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>
{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}
{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>
<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.text}`}
className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-neutral-200 bg-white px-3 py-2"
>
<p className="text-sm text-neutral-800">{task.text}</p>
<span
className={`rounded-full border px-2.5 py-1 text-xs font-medium ${statusBadgeClass(task.state)}`}
<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"
>
{statusLabel(task.state)}
</span>
</li>
))}
</ul>
<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>
{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>
<span
className={`rounded-full border px-2.5 py-1 text-xs font-medium ${statusBadgeClass(task.state)}`}
>
{statusLabel(task.state)}
</span>
</li>
))}
</ul>
) : null}
</section>
))}
</div>
</article>
))}
{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">