Add Regrade: instant re-run when photos are stored, fallback re-pick otherwise

Stores the original photo(s) for every card graded from now on, so an
existing history row can be re-graded in place without re-uploading -- most
useful right after a grading-logic fix like the last few commits. Cards
graded before this feature (or saved without images) fall back to the same
photo picker as a fresh grade, then start getting instant regrades from then
on. list_grades/get_grade now select an explicit column list rather than
SELECT * so the stored images are never pulled into memory just to be
discarded for the browser-facing response.
This commit is contained in:
Barely Removable 2026-08-22 11:17:06 -07:00
parent 552f862f4c
commit e586bc92e7
4 changed files with 302 additions and 39 deletions

View file

@ -363,7 +363,11 @@ function renderHistory() {
g.estimated_grade === null ? 'n/a' : `PSA ${g.estimated_grade}`}</span></td>
<td class="cardcell-meta">${esc(g.confidence || '')}</td>
<td class="cardcell-meta">${esc(fmtWhen(g.created_at))}</td>
<td class="num"><button class="btn btn-quiet btn-sm" data-history-delete="${g.id}">Delete</button></td>
<td class="num">
<button class="btn btn-quiet btn-sm" data-history-regrade="${g.id}"
data-history-has-images="${g.has_source_images ? '1' : '0'}">Regrade</button>
<button class="btn btn-quiet btn-sm" data-history-delete="${g.id}">Delete</button>
</td>
</tr>`).join('');
}
@ -380,6 +384,8 @@ function renderEntryModal(g) {
<input class="input" id="entry-label" placeholder="Rename this card…" value="${esc(g.label || '')}" style="flex:1 1 200px">
<button class="btn btn-quiet btn-sm" id="entry-save-label">Save name</button>
<span class="spacer"></span>
<button class="btn btn-quiet btn-sm" data-history-regrade="${g.id}"
data-history-has-images="${g.has_source_images ? '1' : '0'}">Regrade</button>
<button class="btn btn-danger btn-sm" data-history-delete="${g.id}">Delete</button>
</div>`;
$('#entry-modal').hidden = false;
@ -391,6 +397,64 @@ function closeEntryModal() {
$('#entry-scrim').hidden = true;
}
/* --------------------------------------------------------------- regrade */
//
// Instant when the card's original photo(s) were saved (everything graded
// from now on); otherwise falls back to picking the photo(s) again, same as
// grading fresh, just updating this row instead of creating a new one.
let regradeTargetId = null;
function applyRegradeResult(g) {
const idx = state.history.findIndex((row) => row.id === g.id);
if (idx === -1) state.history.unshift(g); else state.history[idx] = g;
// created_at was bumped server-side to reflect the just-finished regrade,
// so re-sort to match — otherwise the card sits wherever it used to be
// until the next full reload.
state.history.sort((a, b) =>
(b.created_at || '').localeCompare(a.created_at || '') || b.id - a.id);
renderHistory();
const openDelete = $('#entry-modal').querySelector('[data-history-delete]');
if (!$('#entry-modal').hidden && openDelete && Number(openDelete.dataset.historyDelete) === g.id) {
renderEntryModal(g);
}
}
async function runRegrade(id, body) {
banner('Regrading…');
try {
const data = await api(`/api/history/${id}/regrade`, { method: 'POST', body });
applyRegradeResult(data.grade);
banner('Regraded.');
} catch (err) {
banner(`Regrade failed: ${err.message}`, true);
}
}
function startRegrade(id, hasImages) {
if (hasImages) {
runRegrade(id, {});
} else {
regradeTargetId = id;
$('#regrade-file').click();
}
}
$('#regrade-file').addEventListener('change', async (e) => {
const id = regradeTargetId;
regradeTargetId = null;
if (!id) return;
let picked;
try {
picked = await readPickedImages(e.target, 6);
} catch (err) {
banner(err.message, true);
return;
}
if (!picked.length) return;
await runRegrade(id, { images: pickedToPayload(picked) });
});
$('#history-body').addEventListener('click', async (e) => {
const del = e.target.closest('[data-history-delete]');
if (del) {
@ -399,6 +463,12 @@ $('#history-body').addEventListener('click', async (e) => {
await loadHistory();
return;
}
const regrade = e.target.closest('[data-history-regrade]');
if (regrade) {
e.stopPropagation();
startRegrade(Number(regrade.dataset.historyRegrade), regrade.dataset.historyHasImages === '1');
return;
}
const row = e.target.closest('[data-history-row]');
if (row) {
try {
@ -419,6 +489,11 @@ $('#entry-modal').addEventListener('click', async (e) => {
await loadHistory();
return;
}
const regrade = e.target.closest('[data-history-regrade]');
if (regrade) {
startRegrade(Number(regrade.dataset.historyRegrade), regrade.dataset.historyHasImages === '1');
return;
}
if (e.target.closest('#entry-save-label')) {
const id = $('#entry-modal').querySelector('[data-history-delete]').dataset.historyDelete;
const label = $('#entry-label').value.trim();