Make the change log's slice column readable

A multi-slice operation logs an array of slices, and Object.entries over an
array yields its indices -- so the column read "0 = [object Object], 1 =
[object Object], ...", which says nothing about what was adjusted.

Each slice now gets a line. And since a dragged region is one dimension
walked across a fixed set of others, the shared part is pulled out: log 118
is five slices differing only in omon, which now reads as one line of the
four fixed fields and one listing Dec through Apr, rather than the same four
fields five times over.

Only collapsed when exactly one key varies. Two independent axes would lose
their pairing when flattened, so those stay listed slice by slice.

Dates arrive as epoch millis and are shown as dates; the raw payload is on
the cell's title for anything the rendering has flattened.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-16 23:36:57 -04:00
parent 5376c25e04
commit c566aab014

View File

@ -1424,7 +1424,24 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
{entry.operation}
</span>
</td>
<td className="px-4 py-2 text-gray-600 font-mono">{fmtSlice(entry.slice)}</td>
<td className="px-4 py-2 text-gray-600 font-mono align-top">
{(() => {
const lines = fmtSliceLines(entry.slice)
if (lines.length === 0) return '—'
// the raw payload is on the title, for the case where the
// rendering has flattened something worth seeing
return (
<div title={JSON.stringify(entry.slice, null, 2)}>
{lines.map((l, i) => (
<div key={i} className="whitespace-nowrap">
{lines.length > 1 && <span className="text-gray-400 mr-1">{i + 1}.</span>}
{l}
</div>
))}
</div>
)
})()}
</td>
<LogCell entry={entry} field="tag" placeholder="add tag"
editing={editingCell} setEditing={setEditingCell} onSave={saveLogField}
listId="pf-tag-options"
@ -1657,9 +1674,48 @@ function fmtStamp(stamp) {
return new Date(stamp).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
}
function fmtSlice(slice) {
if (!slice || !Object.keys(slice).length) return '—'
return Object.entries(slice).map(([k, v]) => `${k} = ${v}`).join(', ')
// A multi-slice operation logs an array of slices, and Object.entries over an
// array yields its indices -- which is where "0 = [object Object]" came from.
// Each slice gets its own line; a date arrives as epoch millis and is useless
// as a number.
function fmtSliceLines(slice) {
if (!slice) return []
const one = (sl) => Object.entries(sl)
.map(([k, v]) => `${k} = ${fmtSliceValue(v)}`)
.join(' · ')
if (!Array.isArray(slice)) return Object.keys(slice).length ? [one(slice)] : []
const list = slice.filter(sl => sl && Object.keys(sl).length)
if (list.length <= 1) return list.map(one)
// A dragged region is one dimension walked across a fixed set of others -- the
// five slices of log 118 differ only in omon. Printing the other four fields
// five times buries the one that actually varies, so pull the shared part out
// and list the varying values on their own line.
const keys = [...new Set(list.flatMap(sl => Object.keys(sl)))]
const varying = keys.filter(k => new Set(list.map(sl => JSON.stringify(sl[k]))).size > 1)
// Two or more independent axes would lose their pairing if flattened this way,
// so only collapse when a single dimension is doing the varying.
if (varying.length !== 1) return list.map(one)
const [vk] = varying
const fixed = Object.fromEntries(keys.filter(k => k !== vk).map(k => [k, list[0][k]]))
const values = [...new Set(list.map(sl => fmtSliceValue(sl[vk])))]
return [
...(Object.keys(fixed).length ? [one(fixed)] : []),
`${vk} = ${values.join(', ')}`,
]
}
function fmtSliceValue(v) {
if (v === null || v === undefined || v === '') return '∅'
if (typeof v === 'number' && v > 1e11) {
const d = new Date(v)
if (!isNaN(d)) return d.toISOString().slice(0, 10)
}
if (typeof v === 'object') return JSON.stringify(v)
return String(v)
}
const OP_BADGE = {