Files
photoanalyzer/frontend/js/views.js
domverse 457dd5bd90 US02-06: Deliver Workflow, Safety, Library, Analysis, and Stats Views
Ports the safety review and the Photo Analyzer Library/Analyze/Stats
experiences onto the shared API + service layer, and adds the Workflow
home, enforcing the pipeline gates and the one-mutating-job policy.

Backend
- migration 0005 + models: safety_reviews (append-only, latest row is the
  current decision) and analysis_results (donor photos schema re-keyed to
  asset_id).
- SafetyService: persist scores/decisions, review queue with filters, and
  the EXIF safety checkpoint (mutually-exclusive sfw/nsfw keyword written,
  read back, current_sha256 refreshed) that upload eligibility depends on.
- AnalysisService: the privacy gate — the vision provider is called ONLY for
  canonical, confirmed-SFW assets; nsfw/undecided are recorded skipped without
  a request. Provider is an injected adapter (real OpenAI-compatible Gemini
  call extracted from photo_analyzer.analyze_image; a fake in tests).
- LibraryService: Library search + Stats read model ported from webapp/query.py
  (LIKE search in place of FTS5; facets, top tags, years, albums, people).
- WorkflowService + GET /api/v1/workflow: per-stage readiness derived from the
  source tables — counts, blockers, last-run, action, and an active_job that
  drives read-only-during-jobs. Safety scoring and analysis run as durable jobs
  under the library_write lock via new domain handlers, so a second mutating
  job is refused.
- routes: workflow, safety (queue/counts/decisions/jobs), analysis
  (counts/results/jobs), library (assets/facets/stats).

Frontend
- five views (frontend/js/views.js) on the US02-05 shell: Workflow stepper
  (status text+icon, not colour alone; actions disabled with a reason while a
  job runs), Safety review (filter tabs, decide, persists across reload),
  Library (search + cards), Analyze (counts + live job log via the SSE
  adapter), Stats. Shared DOM helpers extracted to dom.js; Workflow is the home
  route.

Tests
- integration: provider-call privacy (nsfw never reaches the provider),
  sfw→nsfw flip drops analysis eligibility, decision persistence, one-mutating-
  job rejection, workflow counts, and the exiftool safety-keyword write/verify.
- e2e: Workflow cards, actions disabled+explained during a job, safety
  decide-persists-across-reload, Library search, Stats, Analyze counts.
- traceability map updated for US02-05 and US02-06.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 16:52:01 +02:00

364 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Phase B operational views: Workflow home, Safety review, Library, Analyze, Stats.
// Each render function fills the app root from the JSON API — no server-rendered
// HTML. Mutating actions are disabled while a job holds the library_write lock and
// say why (concept §shared interaction rules: read-only browsing stays available).
import { api } from "./api.js";
import { el, errorBanner, setActiveNav } from "./dom.js";
import { navigate } from "./router.js";
import { subscribeJob } from "./events.js";
// Status is never color-only: an icon + label accompany every colored badge.
const STATUS_ICON = { complete: "✓", ready: "○", attention: "!", blocked: "●" };
function jobBanner(activeJob) {
if (!activeJob) return null;
return el(
"div",
{ class: "alert job-running", role: "status", "data-testid": "job-running" },
`A ${activeJob.job_type} job is ${activeJob.state}. Mutating actions are paused; browsing stays available.`
);
}
// ── Workflow home ────────────────────────────────────────────────────────────
export async function renderWorkflow(root) {
setActiveNav("workflow");
let data;
try {
data = await api.workflow();
} catch (error) {
root.replaceChildren(errorBanner(`Failed to load workflow: ${error.message}`));
return;
}
const active = data.active_job;
const cards = data.stages.map((stage) => {
const action = stage.action || {};
// A stage whose action starts a job is disabled while any job runs.
const disabled = Boolean(active && action.job_type);
const button = action.label
? el(
"button",
{
"data-testid": `action-${stage.key}`,
disabled: disabled ? "disabled" : false,
title: disabled ? "a job is already running" : false,
onclick: () => triggerStageAction(stage),
},
action.label
)
: null;
return el(
"div",
{ class: `card stage stage-${stage.status}`, "data-testid": `stage-${stage.key}` },
el(
"div",
{ class: "stage-head" },
el("span", { class: "stage-icon", "aria-hidden": "true" }, STATUS_ICON[stage.status] || "○"),
el("strong", {}, stage.title),
el("span", { class: `badge ${stage.status}`, "data-testid": `status-${stage.key}` }, stage.status_label)
),
el("div", { class: "muted counts", "data-testid": `counts-${stage.key}` }, countsLine(stage.counts)),
stage.blocker ? el("div", { class: "blocker", "data-testid": `blocker-${stage.key}` }, `⚠ ${stage.blocker}`) : null,
el("div", { class: "muted last-run" }, stage.last_run ? `Last run: ${stage.last_run}` : "Not run yet"),
el(
"div",
{ class: "stage-actions" },
button,
action.route ? el("a", { class: "link", href: action.route }, "Details →") : null
)
);
});
root.replaceChildren(
el("h1", {}, "Workflow"),
jobBanner(active),
el("div", { class: "stepper" }, ...cards)
);
}
async function triggerStageAction(stage) {
const action = stage.action || {};
if (action.job_type === "safety_score") await startJob(api.safetyScoreJob);
else if (action.job_type === "analysis") await startJob(api.analysisJob);
if (action.route) navigate(action.route.replace(/^#/, ""));
}
async function startJob(call) {
try {
await call();
} catch (error) {
if (error.status !== 409) alert(`Could not start job: ${error.message}`);
}
}
function countsLine(counts) {
return Object.entries(counts || {})
.map(([key, value]) => `${value} ${key}`)
.join(" · ");
}
// ── Safety review ────────────────────────────────────────────────────────────
const SAFETY_FILTERS = ["undecided", "sfw", "nsfw", "deferred"];
export async function renderSafety(root, params) {
setActiveNav("safety");
const state = params.state || "undecided";
let data;
try {
data = await api.safetyQueue({ state });
} catch (error) {
root.replaceChildren(errorBanner(`Failed to load safety queue: ${error.message}`));
return;
}
const tabs = el(
"div",
{ class: "toolbar", role: "tablist" },
...SAFETY_FILTERS.map((filter) =>
el(
"button",
{
role: "tab",
"data-testid": `filter-${filter}`,
"aria-selected": filter === state ? "true" : "false",
onclick: () => navigate("/safety", { state: filter }),
},
filter
)
)
);
const rows = data.items.map((item) =>
el(
"tr",
{ "data-testid": "safety-row", "data-asset-id": item.asset_id },
el("td", {}, item.current_path || item.asset_id),
el("td", { class: "muted" }, item.score == null ? "—" : item.score.toFixed(3)),
el("td", { class: "muted" }, item.suggested || "—"),
el(
"td",
{},
el("span", { class: `badge ${item.decision || ""}` }, item.decision || "undecided"),
item.exif_verified ? el("span", { class: "badge sfw", title: "EXIF verified" }, "✓ exif") : null
),
el(
"td",
{ class: "decision-bar" },
decideButton(item, "sfw", "SFW"),
decideButton(item, "nsfw", "NSFW"),
decideButton(item, "deferred", "Defer")
)
)
);
root.replaceChildren(
el("h1", {}, "Safety review"),
tabs,
el(
"table",
{ "aria-label": "Safety queue" },
el(
"thead",
{},
el("tr", {}, el("th", {}, "Path"), el("th", {}, "Score"), el("th", {}, "Suggested"), el("th", {}, "Decision"), el("th", {}, "Actions"))
),
el("tbody", { "data-testid": "safety-body" }, ...rows)
),
data.items.length ? null : el("p", { class: "muted" }, "Nothing in this queue.")
);
}
function decideButton(item, decision, label) {
return el(
"button",
{
"data-testid": `decide-${decision}`,
class: item.decision === decision ? "primary" : "",
onclick: async (event) => {
event.target.disabled = true;
try {
await api.safetyDecide({ asset_id: item.asset_id, decision });
render();
} catch (error) {
event.target.disabled = false;
alert(`Decision failed: ${error.message}`);
}
},
},
label
);
}
// ── Library ──────────────────────────────────────────────────────────────────
export async function renderLibrary(root, params) {
setActiveNav("library");
const q = params.q || "";
const offset = Number(params.offset || 0);
const limit = 60;
let data;
try {
data = await api.libraryAssets({ q, offset, limit, sort: params.sort || "path" });
} catch (error) {
root.replaceChildren(errorBanner(`Failed to load library: ${error.message}`));
return;
}
const search = el("input", {
type: "search",
placeholder: "Search descriptions and tags…",
value: q,
"aria-label": "Search library",
onchange: (event) => navigate("/library", { q: event.target.value, offset: 0 }),
});
const cards = data.rows.map((row) =>
el(
"div",
{ class: "card lib-card", "data-testid": "library-card" },
el("img", { src: api.thumbnailUrl(row.asset_id, 256), alt: row.description || row.current_path, loading: "lazy" }),
el("div", { class: "lib-desc" }, row.description || el("span", { class: "muted" }, "(not analyzed)")),
el("div", { class: "muted" }, row.album || ""),
el("div", { class: "tags" }, ...(row.tags || []).slice(0, 8).map((t) => el("span", { class: "badge" }, t)))
)
);
root.replaceChildren(
el("h1", {}, "Library"),
el("div", { class: "toolbar" }, search, el("span", { class: "muted", "data-testid": "library-total" }, `${data.total} photos`)),
data.rows.length ? el("div", { class: "cluster-grid" }, ...cards) : el("p", { class: "muted" }, "No matching photos."),
pager("/library", params, offset, limit, data.total)
);
}
// ── Analyze ──────────────────────────────────────────────────────────────────
export async function renderAnalyze(root) {
setActiveNav("analyze");
let counts, workflow;
try {
[counts, workflow] = await Promise.all([api.analysisCounts(), api.workflow()]);
} catch (error) {
root.replaceChildren(errorBanner(`Failed to load analysis: ${error.message}`));
return;
}
const active = workflow.active_job;
const log = el("pre", { class: "activity-log", "data-testid": "analyze-log" });
const runButton = el(
"button",
{
class: "primary",
"data-testid": "run-analysis",
disabled: active || counts.eligible === 0 ? "disabled" : false,
title: active ? "a job is already running" : counts.eligible === 0 ? "no confirmed-SFW assets" : false,
onclick: async () => {
runButton.disabled = true;
try {
const job = await api.analysisJob();
log.textContent = `Started analysis job ${job.id}\n`;
subscribeJob(job.id, {
onEvent: (event) => (log.textContent += `${event.type}${event.message ? ": " + event.message : ""}\n`),
onDone: () => {
log.textContent += "done\n";
renderAnalyze(root);
},
});
} catch (error) {
runButton.disabled = false;
log.textContent = `Could not start: ${error.message}\n`;
}
},
},
"Analyze eligible SFW assets"
);
root.replaceChildren(
el("h1", {}, "Analyze"),
jobBanner(active),
el(
"div",
{ class: "counts-grid", "data-testid": "analyze-counts" },
stat("Eligible (SFW)", counts.eligible),
stat("Analyzed", counts.analyzed),
stat("Pending", counts.pending),
stat("Errors", counts.error)
),
el("div", { class: "stage-actions" }, runButton),
log
);
}
// ── Stats ────────────────────────────────────────────────────────────────────
export async function renderStats(root) {
setActiveNav("stats");
let data;
try {
data = await api.libraryStats();
} catch (error) {
root.replaceChildren(errorBanner(`Failed to load stats: ${error.message}`));
return;
}
root.replaceChildren(
el("h1", {}, "Stats"),
el("div", { class: "counts-grid", "data-testid": "stats-status" }, ...Object.entries(data.status).map(([k, v]) => stat(k, v))),
facetBlock("Top tags", data.top_tags),
facetBlock("Settings", data.setting),
facetBlock("Years", data.years),
el(
"div",
{ "data-testid": "stats-albums" },
el("h2", {}, "Albums"),
el(
"table",
{ "aria-label": "Albums" },
el("thead", {}, el("tr", {}, el("th", {}, "Album"), el("th", {}, "Done"), el("th", {}, "Total"))),
el(
"tbody",
{},
...data.albums.map((album) =>
el("tr", {}, el("td", {}, album.album), el("td", {}, String(album.done)), el("td", {}, String(album.total)))
)
)
)
)
);
}
// ── shared bits ───────────────────────────────────────────────────────────────
function stat(label, value) {
return el("div", { class: "stat card" }, el("div", { class: "stat-value" }, String(value ?? 0)), el("div", { class: "muted" }, label));
}
function facetBlock(title, items) {
if (!items || !items.length) return null;
return el(
"div",
{},
el("h2", {}, title),
el("div", { class: "tags" }, ...items.map((item) => el("span", { class: "badge" }, `${item.value} (${item.count})`)))
);
}
function pager(path, params, offset, limit, total) {
const start = total === 0 ? 0 : offset + 1;
const end = Math.min(offset + limit, total);
return el(
"div",
{ class: "pager" },
el(
"button",
{ disabled: offset === 0 ? "disabled" : false, onclick: () => navigate(path, { ...params, offset: Math.max(offset - limit, 0) }) },
"Previous"
),
el("span", { class: "muted" }, `${start}–${end} of ${total}`),
el(
"button",
{ disabled: end >= total ? "disabled" : false, onclick: () => navigate(path, { ...params, offset: offset + limit }) },
"Next"
)
);
}
// Re-render the current route (used after a mutation). Set by app.js.
let render = () => {};
export function setRender(fn) {
render = fn;
}