Say which change-log entries are yours

The version this was built against carries 127 adjustments from five accounts,
one of them the reader's, so finding your own meant reading the By column down
the whole list.

Own entries are now marked in place -- a blue rule down the left of the row, the
By cell in blue -- and the modal header carries an Everyone / Mine control with
a count on each. Marked as well as filterable on purpose: the mark tells you
without hiding anyone, the filter is for when you want only yours.

The filter resets to Everyone on every open and is not persisted. A filter that
hides other people's work is fine as a thing you asked for and a trap as a thing
you inherited from last week.

`isMine()` is strict authorship, deliberately not `canUndo()` -- an admin can
undo everything, so marking every row as theirs would say nothing. The fetch
stays unfiltered, since the counts on the control need the whole set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-22 14:30:03 -04:00
parent 7fd07e52d2
commit 8bc7d65fce
2 changed files with 62 additions and 7 deletions

View File

@ -755,6 +755,22 @@ Enforced at:
Moving a row between territories is reassignment, not forecasting, and it Moving a row between territories is reassignment, not forecasting, and it
would vanish from the view that would have shown what happened. would vanish from the view that would have shown what happened.
**Whose entries the change log lists.** Everyone's, as before, with an
**Everyone / Mine** control in the modal header and each account's own entries
marked in place — a blue rule down the left of the row and its `By` cell in
blue. On the version this was built against that is 127 adjustments from five
accounts, one of them the reader's, so finding your own meant reading the `By`
column down the whole list.
Marked *and* filterable on purpose: the mark is how you tell without hiding
anybody, the filter is for when you want only yours. It resets to Everyone on
every open and is not persisted — a filter that hides other people's work is
fine as a thing you asked for and a trap as a thing you inherited from last
week. `isMine()` is strict authorship, deliberately not `canUndo()`: an admin
can undo everything, and marking every row as theirs would say nothing.
The fetch stays unfiltered, since the counts on the control need the whole set.
**The change log shows an entry's full impact**, not the reader's share. The **The change log shows an entry's full impact**, not the reader's share. The
totals stamped on `pf.log` are company-wide, so an admin's version-wide scale totals stamped on `pf.log` are company-wide, so an admin's version-wide scale
reads the same in every account — deliberate, and labelled, rather than reads the same in every account — deliberate, and labelled, rather than

View File

@ -76,6 +76,9 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
// account that made it, or an admin. Mirrored here to say so before the click // account that made it, or an admin. Mirrored here to say so before the click
// rather than after the 403. // rather than after the 403.
const canUndo = (entry) => !!user && (user.is_admin || entry.pf_user === user.username) const canUndo = (entry) => !!user && (user.is_admin || entry.pf_user === user.username)
// Strictly authorship, which is not the same test as canUndo: an admin can
// undo everything, and marking every row as theirs would say nothing.
const isMine = (entry) => !!user && entry.pf_user === user.username
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [largeDataset, setLargeDataset] = useState(false) const [largeDataset, setLargeDataset] = useState(false)
// The pivot's own filter, refreshed whenever the ledger recomputes. A ref // The pivot's own filter, refreshed whenever the ledger recomputes. A ref
@ -252,6 +255,10 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
const [showBridge, setShowBridge] = useState(false) const [showBridge, setShowBridge] = useState(false)
const [logEntries, setLogEntries] = useState([]) const [logEntries, setLogEntries] = useState([])
const [logLoading, setLogLoading] = useState(false) const [logLoading, setLogLoading] = useState(false)
// Whose entries the log lists. Not persisted, and it resets to everyone on
// every open: a filter that hides other people's work is fine as a thing you
// asked for and a trap as a thing you inherited from last week.
const [logWho, setLogWho] = useState('all') // 'all' | 'mine'
const [editingCell, setEditingCell] = useState(null) // { id, field: 'note'|'tag', text } const [editingCell, setEditingCell] = useState(null) // { id, field: 'note'|'tag', text }
const [undoingId, setUndoingId] = useState(null) const [undoingId, setUndoingId] = useState(null)
@ -1725,6 +1732,10 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
return out return out
} }
// What the history modal lists. The fetch is unfiltered -- the counts on the
// Everyone/Mine control need the whole set -- so the narrowing happens here.
const visibleLog = logWho === 'mine' ? logEntries.filter(isMine) : logEntries
// The scope is read from the ref rather than passed in: the request preview in // The scope is read from the ref rather than passed in: the request preview in
// the panel calls this too, and when it was a parameter the preview defaulted // the panel calls this too, and when it was a parameter the preview defaulted
// it away -- showing a payload with no scope for a write that had one. // it away -- showing a payload with no scope for a write that had one.
@ -1817,6 +1828,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
async function openLog() { async function openLog() {
setShowLog(true) setShowLog(true)
setExpandedLog(null) setExpandedLog(null)
setLogWho('all')
setLogLoading(true) setLogLoading(true)
try { try {
// Baseline and reference loads are segment construction, not forecasting. // Baseline and reference loads are segment construction, not forecasting.
@ -2043,15 +2055,38 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={() => setShowLog(false)}> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={() => setShowLog(false)}>
<div className="bg-white rounded-lg shadow-xl w-full max-w-6xl mx-4 flex flex-col max-h-[85vh]" onClick={e => e.stopPropagation()}> <div className="bg-white rounded-lg shadow-xl w-full max-w-6xl mx-4 flex flex-col max-h-[85vh]" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-200 shrink-0"> <div className="flex items-center justify-between px-5 py-3 border-b border-gray-200 shrink-0">
<div className="flex items-baseline gap-4 min-w-0">
<span className="font-medium text-gray-700 text-sm">Change History</span> <span className="font-medium text-gray-700 text-sm">Change History</span>
{/* Whose work this is. A version under active use carries
everyone's adjustments, and finding your own in it meant
reading the By column down a list that is mostly other
people. Own entries are marked in place as well, so the
filter is a way to narrow rather than the only way to tell. */}
{!!user && logEntries.length > 0 && (
<div className="inline-flex rounded border border-gray-200 overflow-hidden text-xs">
{[['all', `Everyone`, logEntries.length],
['mine', `Mine`, logEntries.filter(isMine).length]].map(([val, label, n]) => (
<button key={val} onClick={() => setLogWho(val)}
className={`px-2.5 py-0.5 whitespace-nowrap transition-colors ${
logWho === val ? 'bg-blue-600 text-white' : 'bg-white text-gray-600 hover:bg-gray-50'}`}>
{label} <span className={logWho === val ? 'text-blue-100' : 'text-gray-400'}>{n}</span>
</button>
))}
</div>
)}
</div>
<button onClick={() => setShowLog(false)} className="text-gray-400 hover:text-gray-600 text-lg leading-none">×</button> <button onClick={() => setShowLog(false)} className="text-gray-400 hover:text-gray-600 text-lg leading-none">×</button>
</div> </div>
<div className="overflow-y-auto flex-1"> <div className="overflow-y-auto flex-1">
{logLoading ? ( {logLoading ? (
<div className="p-8 text-center text-sm text-gray-400">Loading…</div> <div className="p-8 text-center text-sm text-gray-400">Loading…</div>
) : logEntries.length === 0 ? ( ) : visibleLog.length === 0 ? (
<div className="p-8 text-center text-sm text-gray-400">No log entries yet.</div> <div className="p-8 text-center text-sm text-gray-400">
{logEntries.length === 0
? 'No log entries yet.'
: `None of the ${logEntries.length} entries here are yours.`}
</div>
) : ( ) : (
<table className="w-full text-xs border-collapse table-fixed"> <table className="w-full text-xs border-collapse table-fixed">
<thead className="sticky top-0 bg-gray-50 text-gray-400 uppercase tracking-wide" style={{fontSize:'10px'}}> <thead className="sticky top-0 bg-gray-50 text-gray-400 uppercase tracking-wide" style={{fontSize:'10px'}}>
@ -2068,9 +2103,12 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{logEntries.map(entry => ( {visibleLog.map(entry => (
<tr key={entry.id} className="border-t border-gray-100 hover:bg-gray-50"> <tr key={entry.id} className="border-t border-gray-100 hover:bg-gray-50">
<td className="px-3 py-2 text-gray-400 whitespace-nowrap">{fmtStamp(entry.stamp)}</td> <td className={`px-3 py-2 text-gray-400 whitespace-nowrap ${
isMine(entry) ? 'border-l-2 border-blue-500' : 'border-l-2 border-transparent'}`}>
{fmtStamp(entry.stamp)}
</td>
<td className="px-3 py-2"> <td className="px-3 py-2">
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${opBadge(entry.operation)}`}> <span className={`px-1.5 py-0.5 rounded text-xs font-medium ${opBadge(entry.operation)}`}>
{entry.operation} {entry.operation}
@ -2095,7 +2133,8 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
)} /> )} />
<LogCell entry={entry} field="note" canEdit={canUndo(entry)} placeholder="add note" <LogCell entry={entry} field="note" canEdit={canUndo(entry)} placeholder="add note"
editing={editingCell} setEditing={setEditingCell} onSave={saveLogField} /> editing={editingCell} setEditing={setEditingCell} onSave={saveLogField} />
<td className="px-3 py-2 text-gray-500 truncate" title={entry.pf_user || ''}> <td className={`px-3 py-2 truncate ${isMine(entry) ? 'text-blue-700 font-medium' : 'text-gray-500'}`}
title={entry.pf_user || ''}>
{entry.pf_user || '—'} {entry.pf_user || '—'}
</td> </td>
<td className={`px-3 py-2 text-right tabular-nums whitespace-nowrap ${ <td className={`px-3 py-2 text-right tabular-nums whitespace-nowrap ${
@ -2121,7 +2160,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
</td> </td>
</tr> </tr>
)).flatMap((row, i) => { )).flatMap((row, i) => {
const entry = logEntries[i] const entry = visibleLog[i]
if (expandedLog !== entry.id) return [row] if (expandedLog !== entry.id) return [row]
return [row, ( return [row, (
<tr key={`${entry.id}-detail`} className="bg-gray-50 border-t border-gray-100"> <tr key={`${entry.id}-detail`} className="bg-gray-50 border-t border-gray-100">