<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<title>Plan Analysis History — sfpermits.ai</title>
<meta name="csrf-token" content="{{ csrf_token }}">
<script nonce="{{ csp_nonce }}" src="/static/htmx.min.js"></script>
<script nonce="{{ csp_nonce }}">
document.addEventListener('htmx:configRequest', function(e) {
var token = document.querySelector('meta[name="csrf-token"]');
if (token) e.detail.headers['X-CSRFToken'] = token.getAttribute('content');
});
</script>
<style nonce="{{ csp_nonce }}">
:root {
--bg: #0f1117;
--surface: #1a1d27;
--surface-2: #252834;
--border: #333749;
--text: #e4e6eb;
--text-muted: #8b8fa3;
--accent: #4f8ff7;
--accent-hover: #3a7ae0;
--success: #34d399;
--warning: #fbbf24;
--error: #f87171;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
min-height: 100vh;
}
.container {
max-width: 960px;
margin: 0 auto;
padding: 0 16px;
}
/* Page content */
.page-content {
padding: 32px 0 80px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
flex-wrap: wrap;
gap: 16px;
}
.page-header h1 {
font-size: 1.4rem;
margin: 0;
color: var(--accent);
}
.search-form {
display: flex;
gap: 8px;
}
.search-form input {
padding: 8px 14px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface-2);
color: var(--text);
font-size: 0.9rem;
width: 240px;
font-family: inherit;
}
.search-form input::placeholder {
color: var(--text-muted);
}
.search-form button {
padding: 8px 16px;
border: none;
border-radius: 6px;
background: var(--accent);
color: white;
cursor: pointer;
font-size: 0.85rem;
font-family: inherit;
}
.search-form button:hover {
background: var(--accent-hover);
}
/* Card grid */
.analysis-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 16px;
}
.analysis-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
transition: border-color 0.2s;
}
.analysis-card:hover {
border-color: var(--accent);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
gap: 12px;
}
.card-filename {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.card-meta {
display: flex;
justify-content: space-between;
font-size: 0.85rem;
color: var(--text-muted);
margin-bottom: 8px;
}
.card-details {
font-size: 0.85rem;
color: var(--text-muted);
padding: 8px 0;
border-top: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 4px;
}
.card-actions {
padding-top: 12px;
border-top: 1px solid var(--border);
margin-top: 8px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.status-badge {
display: inline-block;
padding: 2px 10px;
border-radius: 12px;
font-size: 0.8rem;
font-weight: 600;
flex-shrink: 0;
}
.status-completed { background: #1b5e20; color: #a5d6a7; }
.status-processing { background: #0d47a1; color: #90caf9; }
.status-pending { background: #4a4a00; color: #fff9c4; }
.status-failed { background: #b71c1c; color: #ef9a9a; }
.status-stale { background: #e65100; color: #ffcc80; }
.mode-badge {
display: inline-block;
padding: 1px 8px;
border-radius: 10px;
font-size: 0.75rem;
font-weight: 500;
border: 1px solid var(--border);
color: var(--text-muted);
}
.mode-compliance { border-color: #7c3aed; color: #a78bfa; }
.mode-sample { border-color: var(--accent); color: var(--accent); }
.mode-full { border-color: var(--success); color: var(--success); }
.mode-quick { border-color: var(--text-muted); color: var(--text-muted); }
.view-link {
color: var(--accent);
text-decoration: none;
font-weight: 600;
font-size: 0.9rem;
}
.view-link:hover {
text-decoration: underline;
}
.action-link {
font-size: 0.8rem;
text-decoration: none;
cursor: pointer;
padding: 4px 10px;
border-radius: 6px;
border: 1px solid var(--border);
background: none;
font-family: inherit;
}
.action-link:hover {
background: var(--surface-2);
}
.action-delete {
color: var(--error);
border-color: rgba(248, 113, 113, 0.3);
}
.action-delete:hover {
background: rgba(248, 113, 113, 0.1);
}
.empty-state {
text-align: center;
padding: 60px 24px;
color: var(--text-muted);
}
.empty-state p {
margin: 8px 0;
}
.duration-info {
font-size: 0.78rem;
color: var(--text-muted);
margin-top: 4px;
}
/* Filter chips */
.filter-bar {
display: flex;
gap: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
align-items: center;
}
.filter-label {
font-size: 0.8rem;
color: var(--text-muted);
margin-right: 4px;
}
.filter-chip {
padding: 4px 14px;
border: 1px solid var(--border);
border-radius: 16px;
font-size: 0.8rem;
cursor: pointer;
background: none;
color: var(--text-muted);
font-family: inherit;
transition: all 0.15s;
}
.filter-chip:hover {
border-color: var(--accent);
color: var(--text);
}
.filter-chip.active {
background: var(--accent);
border-color: var(--accent);
color: white;
}
.filter-chip .chip-count {
font-size: 0.7rem;
opacity: 0.7;
margin-left: 4px;
}
.filter-divider {
width: 1px;
height: 20px;
background: var(--border);
margin: 0 4px;
}
/* New Analysis toggle button */
.new-analysis-btn {
padding: 8px 16px;
border-radius: 6px;
background: var(--accent);
color: white;
font-size: 0.85rem;
font-weight: 600;
white-space: nowrap;
transition: background 0.2s;
border: none;
cursor: pointer;
font-family: inherit;
}
.new-analysis-btn:hover {
background: var(--accent-hover);
}
.new-analysis-btn.open {
background: var(--surface-2);
color: var(--text-muted);
border: 1px solid var(--border);
}
.new-analysis-btn.open:hover {
color: var(--text);
border-color: var(--accent);
}
/* Inline upload section */
.upload-section {
display: none;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 24px;
margin-bottom: 24px;
animation: slideDown 0.2s ease-out;
}
.upload-section.open {
display: block;
}
@keyframes slideDown {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
.upload-section h2 {
font-size: 1.1rem;
margin-bottom: 4px;
}
.upload-section .upload-subtitle {
font-size: 0.85rem;
color: var(--text-muted);
margin-bottom: 20px;
}
/* Upload form grid */
.upload-form-grid {
display: grid;
grid-template-columns: 1fr;
gap: 16px;
}
.upload-form-grid .uf-full {
grid-column: 1 / -1;
}
/* File upload area */
.uf-file-area {
position: relative;
border: 2px dashed var(--border);
border-radius: 10px;
padding: 28px 20px;
text-align: center;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
}
.uf-file-area:hover,
.uf-file-area.drag-over {
border-color: var(--accent);
background: rgba(79, 143, 247, 0.05);
}
.uf-file-input {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
width: 100%;
height: 100%;
}
.uf-file-label {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
color: var(--text-muted);
pointer-events: none;
}
.uf-file-label .uf-icon {
font-size: 2rem;
margin-bottom: 4px;
}
.uf-file-label .uf-hint {
font-size: 0.75rem;
color: var(--text-muted);
opacity: 0.7;
}
.uf-file-label.has-file {
color: var(--success);
}
/* Form inputs within upload section */
.upload-section label {
display: block;
font-size: 0.82rem;
font-weight: 600;
color: var(--text-muted);
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.upload-section input[type="text"],
.upload-section select,
.upload-section textarea {
width: 100%;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
padding: 10px 14px;
font-size: 0.9rem;
font-family: inherit;
transition: border-color 0.2s;
}
.upload-section input[type="text"]:focus,
.upload-section select:focus,
.upload-section textarea:focus {
outline: none;
border-color: var(--accent);
}
.upload-section textarea {
resize: vertical;
min-height: 80px;
}
/* More options toggle */
.uf-options-toggle {
display: flex;
align-items: center;
gap: 8px;
background: none;
border: none;
color: var(--accent);
font-size: 0.88rem;
cursor: pointer;
padding: 8px 0;
transition: color 0.2s;
font-family: inherit;
}
.uf-options-toggle:hover { color: var(--accent-hover); }
.uf-options-toggle .uf-toggle-icon {
font-size: 0.7rem;
transition: transform 0.2s;
}
.uf-options-toggle.open .uf-toggle-icon {
transform: rotate(90deg);
}
.uf-options-body {
display: none;
padding-top: 12px;
margin-top: 4px;
border-top: 1px solid var(--border);
}
.uf-options-body.open { display: block; }
/* Action buttons row */
.uf-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.uf-actions .uf-btn-wrap {
flex: 1;
min-width: 100px;
position: relative;
}
.uf-btn {
width: 100%;
background: transparent;
border: 1px solid var(--accent);
color: var(--accent);
border-radius: 8px;
padding: 12px 8px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
font-family: inherit;
transition: background 0.15s, color 0.15s;
}
.uf-btn:hover {
background: rgba(79, 143, 247, 0.1);
}
.uf-btn.uf-btn-active {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.uf-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.uf-btn .uf-btn-spinner {
display: none;
}
.uf-btn.loading .uf-btn-label { display: none; }
.uf-btn.loading .uf-btn-spinner { display: inline-block; }
/* Tooltip for action buttons */
.uf-btn-tooltip {
display: none;
position: absolute;
bottom: calc(100% + 10px);
left: 50%;
transform: translateX(-50%);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 14px;
font-size: 0.8rem;
font-weight: 400;
color: var(--text);
line-height: 1.5;
width: 240px;
z-index: 20;
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
pointer-events: none;
}
.uf-btn-tooltip::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: var(--border);
}
.uf-btn-wrap:hover .uf-btn-tooltip {
display: block;
}
/* Checkbox in upload form */
.uf-checkbox-row {
margin-top: -4px;
}
.uf-checkbox-label {
display: flex;
align-items: center;
gap: 10px;
font-size: 0.88rem;
color: var(--text-muted);
cursor: pointer;
text-transform: none;
letter-spacing: 0;
font-weight: 400;
}
.uf-checkbox-label input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: var(--accent);
}
@media (max-width: 640px) {
.analysis-grid {
grid-template-columns: 1fr;
}
.search-form input {
width: 160px;
}
.uf-btn-tooltip { display: none !important; }
.uf-actions {
flex-direction: column;
}
}
/* Bulk select mode */
.select-mode-btn {
padding: 4px 14px;
border: 1px solid var(--border);
border-radius: 16px;
font-size: 0.8rem;
cursor: pointer;
background: none;
color: var(--text-muted);
font-family: inherit;
transition: all 0.15s;
margin-left: auto;
}
.select-mode-btn:hover {
border-color: var(--accent);
color: var(--text);
}
.select-mode-btn.active {
background: var(--accent);
border-color: var(--accent);
color: white;
}
.bulk-checkbox {
display: none;
position: absolute;
top: 12px;
left: 12px;
width: 20px;
height: 20px;
accent-color: var(--accent);
cursor: pointer;
z-index: 2;
}
body.select-mode .bulk-checkbox {
display: block;
}
body.select-mode .analysis-card {
position: relative;
padding-left: 44px;
cursor: pointer;
}
body.select-mode .analysis-card.selected {
border-color: var(--accent);
background: rgba(59, 130, 246, 0.05);
}
/* Floating bulk action bar */
.bulk-action-bar {
display: none;
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 12px 20px;
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
z-index: 100;
align-items: center;
gap: 16px;
animation: slideUp 0.2s ease-out;
}
.bulk-action-bar.visible {
display: flex;
}
@keyframes slideUp {
from { opacity: 0; transform: translateX(-50%) translateY(12px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
.bulk-count {
font-size: 0.9rem;
font-weight: 600;
color: var(--text);
white-space: nowrap;
}
.bulk-select-all {
font-size: 0.8rem;
color: var(--accent);
cursor: pointer;
background: none;
border: none;
font-family: inherit;
text-decoration: underline;
}
.bulk-delete-btn {
padding: 8px 20px;
background: var(--error);
color: white;
border: none;
border-radius: 8px;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
font-family: inherit;
transition: background 0.15s;
}
.bulk-delete-btn:hover {
background: #dc2626;
}
.bulk-cancel-btn {
padding: 8px 16px;
background: none;
color: var(--text-muted);
border: 1px solid var(--border);
border-radius: 8px;
font-size: 0.85rem;
cursor: pointer;
font-family: inherit;
}
/* Undo toast */
.undo-toast {
display: none;
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
background: #1e293b;
color: white;
padding: 12px 24px;
border-radius: 10px;
font-size: 0.9rem;
z-index: 200;
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
align-items: center;
gap: 16px;
}
.undo-toast.visible {
display: flex;
}
.undo-toast button {
background: none;
border: 1px solid rgba(255,255,255,0.3);
color: var(--accent);
padding: 4px 14px;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
font-size: 0.85rem;
font-family: inherit;
}
/* ── Phase F1: Stats Banner ──────────────────────────── */
.stats-banner {
display: flex;
gap: 24px;
align-items: center;
flex-wrap: wrap;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 16px;
margin-bottom: 20px;
font-size: 0.82rem;
color: var(--text-muted);
}
.stats-banner .stat-item {
display: flex;
align-items: center;
gap: 6px;
}
.stats-banner .stat-value {
color: var(--text);
font-weight: 600;
}
.stats-sep {
color: var(--border);
font-size: 0.7rem;
}
/* ── Phase F2: Project Notes ─────────────────────────── */
.project-notes-wrap {
margin-top: 12px;
}
.project-notes-toggle {
background: none;
border: none;
color: var(--text-muted);
font-size: 0.78rem;
cursor: pointer;
padding: 0;
font-family: inherit;
display: flex;
align-items: center;
gap: 4px;
}
.project-notes-toggle:hover { color: var(--accent); }
.project-notes-body {
display: none;
margin-top: 8px;
}
.project-notes-body.open { display: block; }
.project-notes-preview {
font-size: 0.78rem;
color: var(--text-muted);
font-style: italic;
margin-left: 4px;
}
.notes-textarea {
width: 100%;
min-height: 80px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 0.82rem;
font-family: inherit;
padding: 8px 10px;
resize: vertical;
box-sizing: border-box;
}
.notes-textarea:focus { outline: 1px solid var(--accent); border-color: var(--accent); }
.notes-save-btn {
margin-top: 6px;
padding: 4px 14px;
background: var(--accent);
color: white;
border: none;
border-radius: 5px;
font-size: 0.78rem;
cursor: pointer;
font-family: inherit;
}
.notes-save-btn:hover { background: var(--accent-hover); }
.notes-saved-msg {
font-size: 0.75rem;
color: var(--success);
margin-left: 8px;
display: none;
}
</style>
{% import 'fragments/analysis_grouping.html' as grouping %}
{{ grouping.grouping_css() }}
{{ grouping.grouping_js() }}
<link rel="stylesheet" href="/static/mobile.css">
</head>
<body>
{% set active_page = 'analyses' %}
{% include 'fragments/nav.html' %}
<div class="container page-content">
<div class="page-header">
<h1>Plan Analysis History</h1>
<div style="display:flex; gap:12px; align-items:center; flex-wrap:wrap;">
<form class="search-form" method="get" action="/account/analyses">
<input type="text" name="q" placeholder="Search by address, permit, file..."
value="{{ search_q or '' }}">
<button type="submit">Search</button>
</form>
<button type="button" class="new-analysis-btn" id="new-analysis-toggle"
onclick="toggleUploadSection()">
+ New Analysis
</button>
</div>
</div>
<!-- Inline upload section (collapsible, hidden by default) -->
<div class="upload-section" id="upload-section">
<h2>Analyze Plans (AI Vision)</h2>
<p class="upload-subtitle">Upload a PDF plan set. Quick Check scans metadata instantly. AI Analysis and Full Analysis use AI vision to annotate your plans.</p>
<form method="post" action="/analyze-plans" enctype="multipart/form-data" id="upload-form"
class="upload-form-grid">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="uf-full">
<label for="uf-planfile">PDF Plan Set</label>
<div class="uf-file-area" id="uf-drop-zone">
<input type="file" id="uf-planfile" name="planfile" accept=".pdf"
class="uf-file-input" onchange="ufUpdateFileLabel(this)">
<div class="uf-file-label" id="uf-file-label">
<span class="uf-icon">📄</span>
<span>Drag & drop a PDF here, or <strong>click to browse</strong></span>
<span class="uf-hint">Max 400 MB — .pdf only</span>
</div>
</div>
</div>
<!-- Hidden inputs for mode selection -->
<input type="hidden" name="quick_check" id="uf-quick-check" value="on">
<input type="hidden" name="analysis_mode" id="uf-analysis-mode" value="sample">
<!-- Address + Permit Number (visible by default) -->
<div class="uf-full">
<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px;">
<div>
<label for="uf-address">Property Address</label>
<input type="text" id="uf-address" name="property_address"
placeholder="e.g., 123 Main St">
</div>
<div>
<label for="uf-permit-num">Permit Number</label>
<input type="text" id="uf-permit-num" name="permit_number"
placeholder="e.g., 202401015678">
</div>
</div>
</div>
<!-- More options toggle -->
<div class="uf-full">
<button type="button" class="uf-options-toggle" id="uf-options-toggle" onclick="ufToggleOptions()">
<span class="uf-toggle-icon">▶</span>
<span>More options</span>
</button>
</div>
<!-- Collapsible options -->
<div class="uf-full">
<div class="uf-options-body" id="uf-options-body">
<div style="display:grid; gap:16px;">
<div>
<label for="uf-project-desc">Project Description</label>
<textarea id="uf-project-desc" name="project_description"
placeholder="e.g., Interior remodel of 2-story residential, adding bathroom and kitchen..."
rows="3"></textarea>
</div>
<div>
<label for="uf-permit-type">Permit Type</label>
<select id="uf-permit-type" name="permit_type">
<option value="">-- Select --</option>
<option value="alterations">Alterations</option>
<option value="new_construction">New Construction</option>
<option value="demolition">Demolition</option>
<option value="additions">Additions</option>
</select>
</div>
<div>
<label for="uf-stage">Submission Stage</label>
<select id="uf-stage" name="submission_stage">
<option value="">-- Select --</option>
<option value="preliminary">Preliminary Plan Check</option>
<option value="permit">Permit Application (full set)</option>
<option value="resubmittal">Resubmittal (addressing comments)</option>
</select>
</div>
<div class="uf-checkbox-row">
<label class="uf-checkbox-label">
<input type="checkbox" name="is_addendum" id="uf-is-addendum">
<span>This is a Site Permit Addendum (allows up to 350 MB)</span>
</label>
</div>
</div>
</div>
</div>
<!-- Four action buttons -->
<div class="uf-full">
<div class="uf-actions">
<div class="uf-btn-wrap">
<button type="submit" class="uf-btn uf-btn-active" id="uf-quick-btn"
onclick="ufSetMode('quick')">
<span class="uf-btn-label">Quick Check</span>
<span class="uf-btn-spinner">Checking...</span>
</button>
<div class="uf-btn-tooltip">
<strong>Instant metadata scan</strong><br>
Checks file size, page dimensions, fonts, encryption, bookmarks, and filename conventions. No AI — results in seconds.
</div>
</div>
<div class="uf-btn-wrap">
<button type="submit" class="uf-btn" id="uf-compliance-btn"
onclick="ufSetMode('compliance')">
<span class="uf-btn-label">Compliance</span>
<span class="uf-btn-spinner">Analyzing...</span>
</button>
<div class="uf-btn-tooltip">
<strong>AI title block verification</strong><br>
Scans sample pages for addresses, sheet numbers, professional stamps, and set consistency. ~30 seconds.
</div>
</div>
<div class="uf-btn-wrap">
<button type="submit" class="uf-btn" id="uf-ai-btn"
onclick="ufSetMode('sample')">
<span class="uf-btn-label">AI Analysis</span>
<span class="uf-btn-spinner">Analyzing...</span>
</button>
<div class="uf-btn-tooltip">
<strong>AI markup on sample pages</strong><br>
Full AI vision analysis — EPR issues, annotations, hatching, code references, and compliance checks on a representative sample. ~1-2 minutes.
</div>
</div>
<div class="uf-btn-wrap">
<button type="submit" class="uf-btn" id="uf-full-btn"
onclick="ufSetMode('full')">
<span class="uf-btn-label">Full Analysis</span>
<span class="uf-btn-spinner">Analyzing...</span>
</button>
<div class="uf-btn-tooltip">
<strong>AI markup on every page</strong><br>
Same as AI Analysis but processes every page in the set. Best for final review before submission. ~3-5 minutes for large sets.
</div>
</div>
</div>
</div>
</form>
</div>
{% if jobs %}
<!-- Stats Banner -->
<div class="stats-banner">
{% if stats.awaiting_resubmittal > 0 %}
<div class="stat-item">
<span class="stat-value" style="color:var(--warning);">{{ stats.awaiting_resubmittal }}</span>
<span>awaiting resubmittal</span>
</div>
<span class="stats-sep">|</span>
{% endif %}
{% if stats.new_issues > 0 %}
<div class="stat-item">
<span class="stat-value" style="color:var(--error);">{{ stats.new_issues }}</span>
<span>new issue{{ 's' if stats.new_issues != 1 else '' }} since last scan</span>
</div>
<span class="stats-sep">|</span>
{% endif %}
<div class="stat-item">
<span>Last scan:</span>
<span class="stat-value">
{% if stats.last_scan_at %}
{{ (stats.last_scan_at|to_pst).strftime('%b %d at %I:%M %p PT') }}
{% else %}
—
{% endif %}
</span>
</div>
</div>
<!-- Filter Chips -->
<div class="filter-bar" id="filter-bar">
<span class="filter-label">Status:</span>
<button class="filter-chip active" data-filter="status" data-value="all" onclick="toggleFilter(this)">All</button>
<button class="filter-chip" data-filter="status" data-value="completed" onclick="toggleFilter(this)" title="Analyses that finished successfully">Completed</button>
<button class="filter-chip" data-filter="status" data-value="processing" onclick="toggleFilter(this)" title="Analyses currently running">Processing</button>
<button class="filter-chip" data-filter="status" data-value="failed" onclick="toggleFilter(this)" title="Analyses that encountered errors">Failed</button>
<button class="filter-chip" data-filter="status" data-value="stale" onclick="toggleFilter(this)" title="Analyses that timed out during processing">Stale</button>
<div class="filter-divider"></div>
<span class="filter-label">Mode:</span>
<button class="filter-chip active" data-filter="mode" data-value="all" onclick="toggleFilter(this)" title="Show all analysis types">All</button>
<button class="filter-chip" data-filter="mode" data-value="quick" onclick="toggleFilter(this)" title="Metadata-only checks (file size, fonts, bookmarks) — no AI vision">Quick</button>
<button class="filter-chip" data-filter="mode" data-value="compliance" onclick="toggleFilter(this)" title="AI vision checks for EPR compliance (title blocks, stamps, addresses)">Compliance</button>
<button class="filter-chip" data-filter="mode" data-value="sample" onclick="toggleFilter(this)" title="Full AI analysis on sampled pages — EPR checks, annotations, recommendations">AI Analysis</button>
<button class="filter-chip" data-filter="mode" data-value="full" onclick="toggleFilter(this)" title="AI analysis on every page — most thorough, longest processing time">Full</button>
<button class="select-mode-btn" id="select-mode-btn" onclick="toggleSelectMode()">Select</button>
</div>
{{ grouping.view_options_bar(group_mode, sort_by, search_q) }}
{% if group_mode == 'project' %}
{{ grouping.grouped_view(groups) }}
{% else %}
<div class="analysis-grid" id="analysis-grid">
{% for job in jobs %}
<div class="analysis-card" id="job-card-{{ job.job_id }}"
data-status="{{ job.status }}"
data-job-id="{{ job.job_id }}"
data-mode="{{ 'quick' if job.quick_check else (job.analysis_mode or 'sample') }}"
onclick="if(document.body.classList.contains('select-mode'))toggleCardSelect(this)">
<input type="checkbox" class="bulk-checkbox" onclick="event.stopPropagation();toggleCardSelect(this.parentElement)">
<div class="card-header">
<span class="card-filename" title="{{ job.filename }}">{{ job.filename }}</span>
<div style="display:flex; gap:6px; align-items:center; flex-shrink:0;">
{{ grouping.version_badge_flat(job) }}
{% if job.quick_check %}
<span class="mode-badge mode-quick">Quick</span>
{% elif job.analysis_mode == 'compliance' %}
<span class="mode-badge mode-compliance">Compliance</span>
{% elif job.analysis_mode == 'full' %}
<span class="mode-badge mode-full">Full</span>
{% elif job.analysis_mode == 'sample' %}
<span class="mode-badge mode-sample">AI Analysis</span>
{% endif %}
<span class="status-badge status-{{ job.status }}">{{ job.status|title }}</span>
</div>
</div>
<div class="card-meta">
<span>
{{ "%.1f"|format(job.file_size_mb) }} MB
{% if job.pages_analyzed %}
· {{ job.pages_analyzed }} pages analyzed
{% endif %}
</span>
<span>{{ (job.created_at|to_pst).strftime('%b %d, %Y %I:%M %p PT') if job.created_at else '' }}</span>
</div>
{% if job.property_address or job.permit_number %}
<div class="card-details">
{% if job.property_address %}<span>{{ job.property_address }}</span>{% endif %}
{% if job.permit_number %}<span>Permit #{{ job.permit_number }}</span>{% endif %}
</div>
{% endif %}
{% if job.completed_at and job.started_at %}
<div class="duration-info">
{% set duration = (job.completed_at - job.started_at).total_seconds() %}
{% if duration < 60 %}
Completed in {{ "%.0f"|format(duration) }}s
{% elif duration < 3600 %}
Completed in {{ "%.1f"|format(duration / 60) }} min
{% endif %}
</div>
{% elif job.completed_at and job.created_at %}
{# Fallback: use created_at if started_at not available #}
<div class="duration-info">
{% set duration = (job.completed_at - job.created_at).total_seconds() %}
{% if duration < 60 %}
Completed in {{ "%.0f"|format(duration) }}s
{% elif duration < 3600 %}
Completed in {{ "%.1f"|format(duration / 60) }} min
{% endif %}
</div>
{% elif job.status == 'processing' and job.started_at %}
<div class="duration-info" data-elapsed-start="{{ job.started_at.isoformat() }}">
Processing for <span class="elapsed-time">0s</span>...
</div>
{% endif %}
{% if job.status == 'failed' and job.error_message %}
<div style="font-size:0.8rem; color:var(--error); margin-top:6px; padding:6px 8px; background:rgba(248,113,113,0.08); border-radius:6px;">
{% set friendly = job.error_message|friendly_error %}
{{ friendly[:120] }}{% if friendly|length > 120 %}...{% endif %}
</div>
{% endif %}
<div class="card-actions">
<div>
{% if job.status == 'completed' and job.session_id %}
<a href="/plan-jobs/{{ job.job_id }}/results" class="view-link">View Results →</a>
{% elif job.status == 'completed' and job.quick_check %}
<a href="/plan-jobs/{{ job.job_id }}/results" class="view-link">View Report →</a>
{% elif job.status == 'processing' %}
<a href="/plan-jobs/{{ job.job_id }}/status" style="color:var(--accent);font-size:0.85rem;text-decoration:none;">
In progress...
</a>
{% elif job.status == 'failed' or job.status == 'stale' %}
<a href="#upload" class="view-link" style="color:var(--warning);"
onclick="retryAnalysis('{{ job.job_id }}'); return false;"
title="Re-analyze with same settings">Retry →</a>
{% else %}
<span style="color:var(--text-muted);font-size:0.85rem;">—</span>
{% endif %}
</div>
<div style="display:flex; gap:6px; align-items:center;">
{% if job.status == 'completed' and job.parent_job_id %}
<a href="/account/analyses/compare?a={{ job.parent_job_id }}&b={{ job.job_id }}"
style="color:var(--text-muted);font-size:0.8rem;text-decoration:none;border:1px solid var(--border);padding:3px 8px;border-radius:5px;"
title="Compare with previous version">
Compare ↔
</a>
{% endif %}
<button class="action-link action-delete"
hx-delete="/api/plan-jobs/{{ job.job_id }}"
hx-target="#job-card-{{ job.job_id }}"
hx-swap="outerHTML"
hx-confirm="Delete this analysis? This cannot be undone."
title="Delete this analysis">
Delete
</button>
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}{# end group_mode #}
{% else %}
<div class="empty-state">
{% if search_q %}
<p>No analyses found matching "{{ search_q }}"</p>
<p><a href="/account/analyses" style="color:var(--accent);">Clear search</a></p>
{% else %}
<p style="font-size:1.1rem; margin-bottom:4px;">No plan analyses yet.</p>
<p style="margin-bottom:20px;">Upload a PDF plan set to get AI-powered compliance and drawing analysis.</p>
<button type="button" onclick="toggleUploadSection()"
style="display:inline-block; background:var(--accent); color:white; padding:12px 28px;
border-radius:8px; border:none; font-weight:600; font-size:0.95rem;
cursor:pointer; font-family:inherit; transition:background 0.2s;"
onmouseover="this.style.background='var(--accent-hover)'"
onmouseout="this.style.background='var(--accent)'">
Analyze a Plan
</button>
{% endif %}
</div>
{% endif %}
</div>
<!-- Floating bulk action bar -->
<div class="bulk-action-bar" id="bulk-action-bar">
<span class="bulk-count" id="bulk-count">0 selected</span>
<button class="bulk-select-all" onclick="selectAllCards()">Select all</button>
<button class="bulk-delete-btn" onclick="bulkDeleteSelected()">Delete selected</button>
<button class="bulk-cancel-btn" onclick="toggleSelectMode()">Cancel</button>
</div>
<!-- Undo toast -->
<div class="undo-toast" id="undo-toast">
<span id="undo-message">Deleted 0 analyses</span>
<button id="undo-btn" onclick="undoDelete()">Undo</button>
</div>
<script nonce="{{ csp_nonce }}">
// --- Upload section toggle ---
function toggleUploadSection() {
var section = document.getElementById('upload-section');
var btn = document.getElementById('new-analysis-toggle');
var isOpen = section.classList.contains('open');
section.classList.toggle('open');
btn.classList.toggle('open');
if (!isOpen) {
// Scroll to upload section smoothly
setTimeout(function() {
section.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}, 50);
}
}
// --- File label update ---
function ufUpdateFileLabel(input) {
var label = document.getElementById('uf-file-label');
if (input.files && input.files[0]) {
var f = input.files[0];
var sizeMB = (f.size / (1024 * 1024)).toFixed(1);
label.innerHTML = '<span class="uf-icon">✅</span>'
+ '<span><strong>' + f.name + '</strong></span>'
+ '<span class="uf-hint">' + sizeMB + ' MB</span>';
label.classList.add('has-file');
} else {
label.innerHTML = '<span class="uf-icon">📄</span>'
+ '<span>Drag & drop a PDF here, or <strong>click to browse</strong></span>'
+ '<span class="uf-hint">Max 400 MB — .pdf only</span>';
label.classList.remove('has-file');
}
}
// --- Drag & drop ---
(function() {
var zone = document.getElementById('uf-drop-zone');
if (!zone) return;
zone.addEventListener('dragover', function(e) {
e.preventDefault();
zone.classList.add('drag-over');
});
zone.addEventListener('dragleave', function() {
zone.classList.remove('drag-over');
});
zone.addEventListener('drop', function(e) {
e.preventDefault();
zone.classList.remove('drag-over');
var fileInput = document.getElementById('uf-planfile');
if (e.dataTransfer.files.length > 0) {
fileInput.files = e.dataTransfer.files;
ufUpdateFileLabel(fileInput);
}
});
})();
// --- More options toggle ---
function ufToggleOptions() {
var btn = document.getElementById('uf-options-toggle');
var body = document.getElementById('uf-options-body');
btn.classList.toggle('open');
body.classList.toggle('open');
}
// --- Mode selection (set hidden fields before submit) ---
function ufSetMode(mode) {
var qcField = document.getElementById('uf-quick-check');
var modeField = document.getElementById('uf-analysis-mode');
// Update hidden fields based on selected mode
if (mode === 'quick') {
qcField.value = 'on';
modeField.value = 'sample';
} else {
qcField.value = '';
modeField.value = mode;
}
// Visual feedback: highlight the clicked button
var allBtns = ['uf-quick-btn', 'uf-compliance-btn', 'uf-ai-btn', 'uf-full-btn'];
allBtns.forEach(function(id) {
var b = document.getElementById(id);
if (b) b.classList.remove('uf-btn-active');
});
var btnMap = { quick: 'uf-quick-btn', compliance: 'uf-compliance-btn', sample: 'uf-ai-btn', full: 'uf-full-btn' };
var activeBtn = document.getElementById(btnMap[mode]);
if (activeBtn) activeBtn.classList.add('uf-btn-active');
}
// --- Show loading state on form submit ---
(function() {
var form = document.getElementById('upload-form');
if (!form) return;
form.addEventListener('submit', function() {
// Disable all buttons and show spinner on the active one
var allBtns = form.querySelectorAll('.uf-btn');
allBtns.forEach(function(b) {
b.disabled = true;
if (b.classList.contains('uf-btn-active')) {
b.classList.add('loading');
} else {
b.style.opacity = '0.4';
}
});
});
})();
// --- Client-side filtering for analysis history ---
var activeFilters = { status: 'all', mode: 'all' };
// Restore filter state from URL params on load
(function() {
var params = new URLSearchParams(window.location.search);
var s = params.get('status');
var m = params.get('mode');
if (s && s !== 'all') {
activeFilters.status = s;
document.querySelectorAll('.filter-chip[data-filter="status"]').forEach(function(c) {
c.classList.toggle('active', c.dataset.value === s);
});
}
if (m && m !== 'all') {
activeFilters.mode = m;
document.querySelectorAll('.filter-chip[data-filter="mode"]').forEach(function(c) {
c.classList.toggle('active', c.dataset.value === m);
});
}
if (s || m) applyFilters();
})();
function toggleFilter(chip) {
var filterType = chip.dataset.filter;
var value = chip.dataset.value;
// Update active state within this filter group
document.querySelectorAll('.filter-chip[data-filter="' + filterType + '"]').forEach(function(c) {
c.classList.remove('active');
});
chip.classList.add('active');
activeFilters[filterType] = value;
// Persist filter state in URL params
var url = new URL(window.location);
if (activeFilters.status !== 'all') {
url.searchParams.set('status', activeFilters.status);
} else {
url.searchParams.delete('status');
}
if (activeFilters.mode !== 'all') {
url.searchParams.set('mode', activeFilters.mode);
} else {
url.searchParams.delete('mode');
}
history.replaceState(null, '', url);
applyFilters();
}
function applyFilters() {
var cards = document.querySelectorAll('.analysis-card');
var visible = 0;
cards.forEach(function(card) {
var matchStatus = activeFilters.status === 'all' || card.dataset.status === activeFilters.status;
var matchMode = activeFilters.mode === 'all' || card.dataset.mode === activeFilters.mode;
if (matchStatus && matchMode) {
card.style.display = '';
visible++;
} else {
card.style.display = 'none';
}
});
// Show/hide empty state for filtered results
var emptyMsg = document.getElementById('filter-empty');
if (visible === 0 && cards.length > 0) {
if (!emptyMsg) {
emptyMsg = document.createElement('div');
emptyMsg.id = 'filter-empty';
emptyMsg.style.cssText = 'text-align:center; padding:40px 24px; color:var(--text-muted); grid-column:1/-1;';
emptyMsg.innerHTML = '<p>No analyses match the selected filters.</p>';
document.getElementById('analysis-grid').appendChild(emptyMsg);
}
emptyMsg.style.display = '';
} else if (emptyMsg) {
emptyMsg.style.display = 'none';
}
}
// --- Live elapsed timer for processing jobs ---
(function() {
var elapsedEls = document.querySelectorAll('[data-elapsed-start]');
if (elapsedEls.length === 0) return;
function updateTimers() {
var now = new Date();
elapsedEls.forEach(function(el) {
var start = new Date(el.dataset.elapsedStart);
var diffSec = Math.max(0, Math.floor((now - start) / 1000));
var span = el.querySelector('.elapsed-time');
if (!span) return;
if (diffSec < 60) {
span.textContent = diffSec + 's';
} else {
var mins = Math.floor(diffSec / 60);
var secs = diffSec % 60;
span.textContent = mins + 'm ' + secs + 's';
}
});
}
updateTimers();
setInterval(updateTimers, 1000);
})();
// --- Bulk select mode ---
function toggleSelectMode() {
var body = document.body;
var btn = document.getElementById('select-mode-btn');
var bar = document.getElementById('bulk-action-bar');
var isActive = body.classList.toggle('select-mode');
btn.classList.toggle('active', isActive);
if (!isActive) {
// Exiting select mode — clear all selections
document.querySelectorAll('.analysis-card.selected').forEach(function(c) {
c.classList.remove('selected');
var cb = c.querySelector('.bulk-checkbox');
if (cb) cb.checked = false;
});
bar.classList.remove('visible');
}
btn.textContent = isActive ? 'Cancel' : 'Select';
}
function toggleCardSelect(card) {
if (!document.body.classList.contains('select-mode')) return;
card.classList.toggle('selected');
var cb = card.querySelector('.bulk-checkbox');
if (cb) cb.checked = card.classList.contains('selected');
updateBulkBar();
}
function updateBulkBar() {
var selected = document.querySelectorAll('.analysis-card.selected');
var bar = document.getElementById('bulk-action-bar');
var count = document.getElementById('bulk-count');
if (selected.length > 0) {
bar.classList.add('visible');
count.textContent = selected.length + ' selected';
} else {
bar.classList.remove('visible');
}
}
function selectAllCards() {
var cards = document.querySelectorAll('.analysis-card');
var allSelected = document.querySelectorAll('.analysis-card.selected').length === cards.length;
cards.forEach(function(c) {
if (allSelected) {
c.classList.remove('selected');
var cb = c.querySelector('.bulk-checkbox');
if (cb) cb.checked = false;
} else {
c.classList.add('selected');
var cb = c.querySelector('.bulk-checkbox');
if (cb) cb.checked = true;
}
});
updateBulkBar();
var btn = document.querySelector('.bulk-select-all');
btn.textContent = allSelected ? 'Select all' : 'Deselect all';
}
function bulkDeleteSelected() {
var selected = document.querySelectorAll('.analysis-card.selected');
if (selected.length === 0) return;
if (!confirm('Delete ' + selected.length + ' analyses? This cannot be undone.')) return;
var jobIds = [];
selected.forEach(function(c) { jobIds.push(c.dataset.jobId); });
fetch('/api/plan-jobs/bulk-delete', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': document.querySelector('meta[name="csrf-token"]')?.content || ''
},
body: JSON.stringify({ job_ids: jobIds })
})
.then(function(r) { return r.json(); })
.then(function(data) {
// Remove cards from DOM
selected.forEach(function(c) { c.remove(); });
// Exit select mode
toggleSelectMode();
// Store for undo
window._lastDeletedIds = data.job_ids || jobIds;
window._undoTimer = setTimeout(function() {
var toast = document.getElementById('undo-toast');
toast.classList.remove('visible');
window._lastDeletedIds = null;
}, 30000);
// Show undo toast
var toast = document.getElementById('undo-toast');
var msg = document.getElementById('undo-message');
msg.textContent = 'Deleted ' + (data.deleted || 0) + ' analyses';
toast.classList.add('visible');
})
.catch(function(err) {
alert('Failed to delete: ' + err.message);
});
}
// --- Undo delete (#14) ---
function undoDelete() {
var ids = window._lastDeletedIds;
if (!ids || ids.length === 0) return;
clearTimeout(window._undoTimer);
// Restore each job
Promise.all(ids.map(function(id) {
return fetch('/api/plan-jobs/' + id + '/restore', {
method: 'POST',
headers: { 'X-CSRFToken': document.querySelector('meta[name="csrf-token"]')?.content || '' }
});
})).then(function() {
var toast = document.getElementById('undo-toast');
toast.classList.remove('visible');
window._lastDeletedIds = null;
// Reload to show restored cards
location.reload();
});
}
// Single delete undo support
document.body.addEventListener('htmx:afterRequest', function(evt) {
if (evt.detail.verb === 'delete' && evt.detail.successful) {
var path = evt.detail.pathInfo.requestPath;
var match = path.match(/\/api\/plan-jobs\/([^/]+)$/);
if (match) {
var jobId = match[1];
window._lastDeletedIds = [jobId];
var toast = document.getElementById('undo-toast');
var msg = document.getElementById('undo-message');
msg.textContent = 'Analysis deleted';
toast.classList.add('visible');
clearTimeout(window._undoTimer);
window._undoTimer = setTimeout(function() {
toast.classList.remove('visible');
window._lastDeletedIds = null;
}, 30000);
}
}
});
// --- Retry failed analysis (#3) ---
function retryAnalysis(jobId) {
fetch('/api/plan-jobs/' + jobId + '/prefill')
.then(function(r) { return r.json(); })
.then(function(data) {
// Open upload section
var section = document.getElementById('upload-section');
if (!section.classList.contains('open')) toggleUploadSection();
// Pre-fill fields
var addr = document.getElementById('uf-address');
var permit = document.getElementById('uf-permit-num');
var stage = document.getElementById('uf-stage');
var desc = document.getElementById('uf-project-desc');
var ptype = document.getElementById('uf-permit-type');
if (addr && data.property_address) addr.value = data.property_address;
if (permit && data.permit_number) permit.value = data.permit_number;
if (stage && data.submission_stage) stage.value = data.submission_stage;
if (desc && data.project_description) desc.value = data.project_description;
if (ptype && data.permit_type) ptype.value = data.permit_type;
// Open more options if we have data to show
if (data.property_address || data.permit_number || data.submission_stage || data.project_description) {
var optBody = document.getElementById('uf-options-body');
var optToggle = document.getElementById('uf-options-toggle');
if (optBody && !optBody.classList.contains('open')) {
optBody.classList.add('open');
if (optToggle) optToggle.classList.add('open');
}
}
// Scroll to upload
setTimeout(function() {
section.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 100);
})
.catch(function() {
// Fallback: just open upload section
toggleUploadSection();
});
}
// Auto-open upload section from URL hash (e.g., /account/analyses#upload)
(function() {
if (window.location.hash === '#upload') {
toggleUploadSection();
}
})();
</script>
</body>
</html>