Records page: sortable headers and short date formatting

- Click any column header to sort asc/desc (⇅ / ▲ / ▼ indicators)
- Sort is client-side within the current page, numeric-aware
- Dates matching ISO format are displayed as e.g. "Apr 5, 26"
- Sort resets on source change

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-04-05 17:57:27 -04:00
parent 6b7f1c1334
commit b311092987

View File

@ -1,16 +1,30 @@
import { useState, useEffect } from 'react' import { useState, useEffect, useMemo } from 'react'
import { api } from '../api' import { api } from '../api'
const DATE_RE = /^\d{4}-\d{2}-\d{2}(T[\d:.Z+-]+)?$/
function formatVal(val) {
if (val === null || val === undefined) return null
const s = String(val)
if (DATE_RE.test(s)) {
const d = new Date(s)
if (!isNaN(d)) return d.toLocaleDateString(undefined, { year: '2-digit', month: 'short', day: 'numeric' })
}
return s
}
export default function Records({ source }) { export default function Records({ source }) {
const [rows, setRows] = useState([]) const [rows, setRows] = useState([])
const [exists, setExists] = useState(null) const [exists, setExists] = useState(null)
const [offset, setOffset] = useState(0) const [offset, setOffset] = useState(0)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [sort, setSort] = useState({ col: null, dir: 'asc' })
const LIMIT = 100 const LIMIT = 100
useEffect(() => { useEffect(() => {
if (!source) return if (!source) return
setOffset(0) setOffset(0)
setSort({ col: null, dir: 'asc' })
load(0) load(0)
}, [source]) }, [source])
@ -27,11 +41,30 @@ export default function Records({ source }) {
} }
} }
function toggleSort(col) {
setSort(s => s.col === col
? { col, dir: s.dir === 'asc' ? 'desc' : 'asc' }
: { col, dir: 'asc' }
)
}
const sorted = useMemo(() => {
if (!sort.col) return rows
return [...rows].sort((a, b) => {
const av = a[sort.col] ?? ''
const bv = b[sort.col] ?? ''
const cmp = String(av).localeCompare(String(bv), undefined, { numeric: true })
return sort.dir === 'asc' ? cmp : -cmp
})
}, [rows, sort])
function prev() { const o = Math.max(0, offset - LIMIT); setOffset(o); load(o) } function prev() { const o = Math.max(0, offset - LIMIT); setOffset(o); load(o) }
function next() { const o = offset + LIMIT; setOffset(o); load(o) } function next() { const o = offset + LIMIT; setOffset(o); load(o) }
if (!source) return <div className="p-6 text-sm text-gray-400">Select a source first.</div> if (!source) return <div className="p-6 text-sm text-gray-400">Select a source first.</div>
const cols = rows.length > 0 ? Object.keys(rows[0]) : []
return ( return (
<div className="p-6"> <div className="p-6">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
@ -59,19 +92,34 @@ export default function Records({ source }) {
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="text-left text-xs text-gray-400 border-b border-gray-100 bg-gray-50"> <tr className="text-left text-xs text-gray-400 border-b border-gray-100 bg-gray-50">
{Object.keys(rows[0]).map(col => ( {cols.map(col => {
<th key={col} className="px-3 py-2 font-medium whitespace-nowrap">{col}</th> const active = sort.col === col
))} return (
<th
key={col}
onClick={() => toggleSort(col)}
className="px-3 py-2 font-medium whitespace-nowrap cursor-pointer select-none hover:text-gray-600"
>
{col}
<span className="ml-1 text-gray-300">
{active ? (sort.dir === 'asc' ? '▲' : '▼') : '⇅'}
</span>
</th>
)
})}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rows.map((row, i) => ( {sorted.map((row, i) => (
<tr key={i} className="border-t border-gray-50 hover:bg-gray-50"> <tr key={i} className="border-t border-gray-50 hover:bg-gray-50">
{Object.values(row).map((val, j) => ( {cols.map((col, j) => {
const formatted = formatVal(row[col])
return (
<td key={j} className="px-3 py-2 text-xs text-gray-600 whitespace-nowrap max-w-48 truncate"> <td key={j} className="px-3 py-2 text-xs text-gray-600 whitespace-nowrap max-w-48 truncate">
{val === null ? <span className="text-gray-300"></span> : String(val)} {formatted === null ? <span className="text-gray-300"></span> : formatted}
</td> </td>
))} )
})}
</tr> </tr>
))} ))}
</tbody> </tbody>