<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Stuck Permit Analyzer — sfpermits.ai</title>
{% include "fragments/head_obsidian.html" %}
<script src="https://unpkg.com/htmx.org@1.9.12" nonce="{{ csp_nonce }}"></script>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js" nonce="{{ csp_nonce }}"></script>
<style nonce="{{ csp_nonce }}">
body {
background: var(--obsidian);
color: var(--text-primary);
font-family: var(--sans);
margin: 0;
min-height: 100vh;
}
.page-header {
padding: var(--space-16) 0 var(--space-8);
}
.page-header h1 {
font-family: var(--sans);
font-size: var(--text-2xl);
font-weight: 300;
color: var(--text-primary);
margin: 0 0 var(--space-3) 0;
}
.page-header .subtitle {
font-family: var(--sans);
font-size: var(--text-base);
color: var(--text-secondary);
margin: 0;
}
.input-section {
margin-bottom: var(--space-8);
}
.input-row {
display: flex;
gap: var(--space-4);
align-items: flex-start;
}
.permit-input {
flex: 1;
padding: 16px 22px;
font-family: var(--mono);
font-size: 14px;
font-weight: 300;
color: var(--text-primary);
background: var(--glass);
border: 1px solid var(--glass-border);
border-radius: var(--radius-md);
outline: none;
transition: border-color 0.4s, background 0.4s, box-shadow 0.4s;
}
.permit-input::placeholder {
color: var(--text-tertiary);
font-weight: 300;
}
.permit-input:focus {
border-color: var(--accent-ring);
background: rgba(255, 255, 255, 0.06);
box-shadow: 0 0 40px var(--accent-glow);
}
.diagnose-btn {
font-family: var(--mono);
font-size: var(--text-sm);
font-weight: 400;
color: var(--text-secondary);
background: var(--glass);
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
padding: 16px 24px;
cursor: pointer;
white-space: nowrap;
transition: border-color 0.3s, color 0.3s, background 0.3s;
}
.diagnose-btn:hover {
border-color: var(--glass-hover);
color: var(--text-primary);
background: var(--obsidian-light);
}
.diagnose-btn:disabled {
color: var(--text-tertiary);
cursor: not-allowed;
opacity: 0.6;
}
.loading-indicator {
display: none;
align-items: center;
gap: var(--space-3);
font-family: var(--mono);
font-size: var(--text-sm);
color: var(--text-secondary);
margin-top: var(--space-4);
}
.loading-indicator.is-visible {
display: flex;
}
.loading-dot {
width: 6px;
height: 6px;
border-radius: var(--radius-full);
background: var(--signal-amber);
animation: pulse-dot 1s ease-in-out infinite alternate;
}
@keyframes pulse-dot {
from { opacity: 0.4; }
to { opacity: 1; }
}
.results-area {
margin-top: var(--space-8);
}
.results-area.glass-card {
background: var(--obsidian-mid);
border: 1px solid var(--glass-border);
border-radius: var(--radius-md);
padding: var(--space-8);
transition: border-color 0.3s;
}
.results-area.glass-card:hover {
border-color: var(--glass-hover);
}
.results-area.glass-card:empty {
display: none;
}
.empty-hint {
font-family: var(--sans);
font-size: var(--text-base);
color: var(--text-secondary);
text-align: center;
padding: var(--space-12) 0;
}
.error-message {
font-family: var(--mono);
font-size: var(--text-sm);
color: var(--signal-red);
padding: var(--space-4);
background: rgba(248, 113, 113, 0.06);
border: 1px solid rgba(248, 113, 113, 0.15);
border-radius: var(--radius-sm);
}
.auth-prompt {
text-align: center;
padding: var(--space-8) 0;
}
.auth-prompt p {
font-family: var(--sans);
font-size: var(--text-base);
color: var(--text-secondary);
margin: 0 0 var(--space-4) 0;
}
.auth-prompt a {
font-family: var(--mono);
font-size: var(--text-sm);
font-weight: 300;
color: var(--text-secondary);
border-bottom: 1px solid transparent;
text-decoration: none;
transition: color 0.3s, border-color 0.3s;
}
.auth-prompt a:hover {
color: var(--accent);
border-bottom-color: var(--accent);
}
/* Markdown playbook rendering */
.playbook-content {
font-family: var(--sans);
font-size: var(--text-base);
color: var(--text-secondary);
line-height: 1.7;
}
.playbook-content h1,
.playbook-content h2 {
font-family: var(--sans);
font-weight: 400;
color: var(--text-primary);
margin-top: var(--space-8);
margin-bottom: var(--space-3);
}
.playbook-content h3,
.playbook-content h4 {
font-family: var(--sans);
font-weight: 400;
color: var(--text-primary);
margin-top: var(--space-6);
margin-bottom: var(--space-2);
}
.playbook-content h1 { font-size: var(--text-xl); }
.playbook-content h2 { font-size: var(--text-lg); }
.playbook-content h3 { font-size: var(--text-base); }
.playbook-content p {
margin: 0 0 var(--space-4) 0;
}
.playbook-content ul,
.playbook-content ol {
padding-left: var(--space-6);
margin: 0 0 var(--space-4) 0;
}
.playbook-content li {
margin-bottom: var(--space-2);
}
.playbook-content code {
font-family: var(--mono);
font-size: var(--text-sm);
color: var(--text-primary);
background: var(--glass);
padding: 2px 6px;
border-radius: 3px;
}
.playbook-content strong {
color: var(--text-primary);
font-weight: 500;
}
/* Stuck state indicator row */
.status-indicator-row {
display: flex;
align-items: center;
gap: var(--space-3);
margin-bottom: var(--space-6);
padding-bottom: var(--space-6);
border-bottom: 1px solid var(--glass-border);
}
.status-label {
font-family: var(--mono);
font-size: var(--text-xs);
font-weight: 400;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.permit-number-display {
font-family: var(--mono);
font-size: var(--text-sm);
color: var(--text-primary);
}
/* Mobile */
@media (max-width: 768px) {
.page-header {
padding: var(--space-8) 0 var(--space-6);
}
.input-row {
flex-direction: column;
}
.diagnose-btn {
width: 100%;
text-align: center;
}
}
@media (max-width: 375px) {
.permit-input {
padding: 14px 16px;
font-size: 13px;
}
.diagnose-btn {
padding: 14px 16px;
}
}
</style>
</head>
<body>
{% include "fragments/nav.html" %}
<main>
<div class="obs-container">
<header class="page-header">
<h1>Stuck Permit Analyzer</h1>
<p class="subtitle">Diagnose delays and get a ranked intervention playbook for any SF permit.</p>
</header>
<div class="input-section">
<div class="input-row">
<input
type="text"
id="permit-number-input"
class="permit-input"
placeholder="Enter permit number (e.g. 202301015555)"
autocomplete="off"
maxlength="20"
aria-label="Permit number"
>
<button
class="diagnose-btn"
id="diagnose-btn"
onclick="diagnosedPermit()"
type="button"
>
Diagnose permit →
</button>
</div>
<div class="loading-indicator" id="loading-indicator" aria-live="polite">
<span class="loading-dot"></span>
<span>Analyzing permit — this may take a moment…</span>
</div>
</div>
<!-- Empty state shown before any query -->
<div id="empty-state" class="empty-hint">
Enter a permit number to diagnose delays and get intervention steps.
</div>
<!-- Results area — hidden until populated -->
<div id="results" class="results-area glass-card" style="display:none;"></div>
</div>
</main>
<script nonce="{{ csp_nonce }}">
(function() {
// Configure marked.js to use standard options
if (typeof marked !== 'undefined') {
marked.setOptions({ breaks: true, gfm: true });
}
function getCSRFToken() {
var meta = document.querySelector('meta[name="csrf-token"]');
return meta ? meta.getAttribute('content') : '';
}
function setLoading(isLoading) {
var btn = document.getElementById('diagnose-btn');
var indicator = document.getElementById('loading-indicator');
if (btn) btn.disabled = isLoading;
if (indicator) {
if (isLoading) {
indicator.classList.add('is-visible');
} else {
indicator.classList.remove('is-visible');
}
}
}
function showEmpty() {
document.getElementById('empty-state').style.display = '';
document.getElementById('results').style.display = 'none';
document.getElementById('results').innerHTML = '';
}
function showResults(html) {
document.getElementById('empty-state').style.display = 'none';
var resultsDiv = document.getElementById('results');
resultsDiv.style.display = '';
resultsDiv.innerHTML = html;
}
function renderMarkdown(text) {
if (typeof marked !== 'undefined') {
return marked.parse(text);
}
// Fallback: wrap in pre
return '<pre class="playbook-content">' + text.replace(/</g, '<').replace(/>/g, '>') + '</pre>';
}
function renderError(message) {
return '<div class="error-message">' + message + '</div>';
}
function renderAuthPrompt() {
return '<div class="auth-prompt">' +
'<p>This tool requires a free account.</p>' +
'<a href="/auth/login">Log in or create account →</a>' +
'</div>';
}
window.diagnosedPermit = function() {
var input = document.getElementById('permit-number-input');
if (!input) return;
var permitNumber = input.value.trim();
if (!permitNumber) {
input.focus();
return;
}
setLoading(true);
fetch('/api/stuck-permit/' + encodeURIComponent(permitNumber), {
method: 'GET',
headers: {
'Accept': 'application/json',
'X-CSRFToken': getCSRFToken()
},
credentials: 'same-origin'
})
.then(function(response) {
if (response.status === 401) {
setLoading(false);
showResults(renderAuthPrompt());
return null;
}
if (!response.ok) {
return response.json().then(function(data) {
throw new Error(data.error || 'Analysis failed. Please try again.');
});
}
return response.json();
})
.then(function(data) {
if (!data) return;
setLoading(false);
var html = '<div class="status-indicator-row">' +
'<span class="status-label">Permit</span>' +
'<span class="permit-number-display">' + (data.permit_number || permitNumber) + '</span>' +
'<span class="status-dot status-dot--amber" title="Delay detected" style="margin-left:auto;"></span>' +
'<span class="status-text--amber" style="font-family:var(--mono);font-size:var(--text-sm);">analyzing delays</span>' +
'</div>' +
'<div class="playbook-content">' + renderMarkdown(data.result || '') + '</div>';
showResults(html);
})
.catch(function(err) {
setLoading(false);
showResults(renderError(err.message || 'An unexpected error occurred. Please try again.'));
});
};
// Allow Enter key to submit
document.getElementById('permit-number-input').addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
window.diagnosedPermit();
}
});
})();
</script>
</body>
</html>