<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Station Predictor — sfpermits.ai</title>
{% include "fragments/head_obsidian.html" %}
<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-12) 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 p {
font-family: var(--sans);
font-size: var(--text-base);
color: var(--text-secondary);
margin: 0;
max-width: 560px;
}
/* Input form card */
.predictor-form-card {
background: var(--obsidian-mid);
border: 1px solid var(--glass-border);
border-radius: var(--radius-md);
padding: var(--space-8);
margin-bottom: var(--space-6);
transition: border-color 0.3s;
}
.predictor-form-card:hover {
border-color: var(--glass-hover);
}
.form-label {
display: block;
font-family: var(--mono);
font-size: var(--text-xs);
font-weight: 400;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: var(--space-3);
}
.input-row {
display: flex;
gap: var(--space-3);
align-items: stretch;
}
.permit-input {
flex: 1;
padding: 14px 20px;
font-family: var(--mono);
font-size: var(--text-base);
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;
min-width: 0;
}
.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);
}
.predict-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: 0 var(--space-6);
cursor: pointer;
white-space: nowrap;
transition: border-color 0.3s, color 0.3s, background 0.3s;
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.predict-btn:hover {
border-color: var(--glass-hover);
color: var(--text-primary);
}
.predict-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.predict-btn.loading {
color: var(--accent);
border-color: var(--accent-ring);
}
/* Loading spinner */
.spinner {
display: inline-block;
width: 12px;
height: 12px;
border: 2px solid var(--glass-border);
border-top-color: var(--accent);
border-radius: var(--radius-full);
animation: spin 0.7s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Results area */
.results-area {
background: var(--obsidian-mid);
border: 1px solid var(--glass-border);
border-radius: var(--radius-md);
padding: var(--space-8);
transition: border-color 0.3s;
min-height: 120px;
}
.results-area:hover {
border-color: var(--glass-hover);
}
.results-hint {
font-family: var(--sans);
font-size: var(--text-sm);
color: var(--text-tertiary);
margin: 0;
text-align: center;
padding: var(--space-6) 0;
}
.results-label {
font-family: var(--mono);
font-size: var(--text-xs);
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.08em;
margin: 0 0 var(--space-4) 0;
}
.results-permit-number {
font-family: var(--mono);
font-size: var(--text-sm);
color: var(--accent);
margin: 0 0 var(--space-6) 0;
}
/* Markdown output styling */
.results-content {
font-family: var(--sans);
font-size: var(--text-base);
color: var(--text-secondary);
line-height: 1.7;
}
.results-content h1,
.results-content h2,
.results-content h3 {
font-family: var(--sans);
font-weight: 400;
color: var(--text-primary);
margin: var(--space-6) 0 var(--space-3) 0;
}
.results-content h1 { font-size: var(--text-xl); }
.results-content h2 { font-size: var(--text-lg); }
.results-content h3 { font-size: var(--text-base); }
.results-content p {
margin: 0 0 var(--space-4) 0;
}
.results-content ul,
.results-content ol {
margin: 0 0 var(--space-4) 0;
padding-left: var(--space-6);
}
.results-content li {
margin-bottom: var(--space-2);
}
.results-content code {
font-family: var(--mono);
font-size: var(--text-sm);
background: var(--glass);
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
padding: 2px 6px;
color: var(--accent);
}
.results-content pre {
background: var(--glass);
border: 1px solid var(--glass-border);
border-radius: var(--radius-md);
padding: var(--space-6);
overflow-x: auto;
}
.results-content pre code {
background: none;
border: none;
padding: 0;
color: var(--text-primary);
}
.results-content strong {
font-weight: 500;
color: var(--text-primary);
}
.results-content blockquote {
border-left: 2px solid var(--accent-ring);
margin: 0 0 var(--space-4) 0;
padding: var(--space-3) var(--space-6);
color: var(--text-secondary);
}
/* Error state */
.error-message {
font-family: var(--sans);
font-size: var(--text-sm);
color: var(--signal-red);
background: rgba(248, 113, 113, 0.07);
border: 1px solid rgba(248, 113, 113, 0.20);
border-radius: var(--radius-sm);
padding: var(--space-4) var(--space-6);
}
.auth-prompt {
font-family: var(--sans);
font-size: var(--text-sm);
color: var(--text-secondary);
text-align: center;
padding: var(--space-6) 0;
}
.auth-prompt a {
color: var(--accent);
text-decoration: none;
transition: opacity 0.2s;
}
.auth-prompt a:hover {
opacity: 0.75;
}
/* Mobile */
@media (max-width: 480px) {
.page-header {
padding: var(--space-8) 0 var(--space-6);
}
.input-row {
flex-direction: column;
}
.predict-btn {
width: 100%;
justify-content: center;
padding: var(--space-4) var(--space-6);
}
}
</style>
</head>
<body>
{% include "fragments/nav.html" %}
<main>
<div class="obs-container">
<div class="page-header">
<h1>Station Predictor</h1>
<p>See the likely next review stations for any active SF permit.</p>
</div>
<div class="predictor-form-card">
<label class="form-label" for="permit-number-input">Permit Number</label>
<div class="input-row">
<input
type="text"
id="permit-number-input"
class="permit-input"
placeholder="e.g. 202401015555"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
>
<button id="predict-btn" class="predict-btn" onclick="runPrediction()">
<span id="btn-text">Predict next stations →</span>
<span id="btn-spinner" class="spinner" style="display:none;"></span>
</button>
</div>
</div>
<div id="results" class="results-area">
<p class="results-hint">Enter a permit number above to see predicted routing.</p>
</div>
</div>
</main>
<script nonce="{{ csp_nonce }}">
(function () {
var input = document.getElementById('permit-number-input');
var btn = document.getElementById('predict-btn');
var btnText = document.getElementById('btn-text');
var btnSpinner = document.getElementById('btn-spinner');
var results = document.getElementById('results');
// Allow pressing Enter in the input to submit
if (input) {
input.addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
runPrediction();
}
});
}
window.runPrediction = function () {
var permitNumber = (input ? input.value : '').trim();
if (!permitNumber) {
showError('Please enter a permit number.');
return;
}
setLoading(true);
fetch('/api/predict-next/' + encodeURIComponent(permitNumber), {
method: 'GET',
headers: {
'Accept': 'application/json',
'X-CSRFToken': document.querySelector('meta[name="csrf-token"]') ?
document.querySelector('meta[name="csrf-token"]').getAttribute('content') : ''
}
})
.then(function (response) {
if (response.status === 401) {
showAuthPrompt();
return null;
}
return response.json();
})
.then(function (data) {
if (data === null) return;
if (data.error) {
showError(data.error);
return;
}
showResult(data.permit_number, data.result);
})
.catch(function (err) {
showError('Unable to reach the server. Please try again.');
})
.finally(function () {
setLoading(false);
});
};
function setLoading(active) {
if (!btn) return;
btn.disabled = active;
if (active) {
btn.classList.add('loading');
if (btnText) btnText.textContent = 'Analyzing...';
if (btnSpinner) btnSpinner.style.display = 'inline-block';
} else {
btn.classList.remove('loading');
if (btnText) btnText.textContent = 'Predict next stations \u2192';
if (btnSpinner) btnSpinner.style.display = 'none';
}
}
function showResult(permitNumber, markdownText) {
var html = '';
try {
html = typeof marked !== 'undefined' ? marked.parse(markdownText) : escapeHtml(markdownText);
} catch (e) {
html = '<pre>' + escapeHtml(markdownText) + '</pre>';
}
results.innerHTML =
'<p class="results-label">Predicted Routing</p>' +
'<p class="results-permit-number">' + escapeHtml(permitNumber) + '</p>' +
'<div class="results-content">' + html + '</div>';
}
function showError(message) {
results.innerHTML =
'<div class="error-message">' + escapeHtml(message) + '</div>';
}
function showAuthPrompt() {
results.innerHTML =
'<div class="auth-prompt">' +
'Please <a href="/auth/login">log in</a> to use this tool.' +
'</div>';
}
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
})();
</script>
</body>
</html>