<!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>Portfolio — sfpermits.ai</title>
{% include "fragments/head_obsidian.html" %}
<script nonce="{{ csp_nonce }}" src="/static/htmx.min.js"></script>
<style nonce="{{ csp_nonce }}">
.page-body { padding-top: 56px; }
/* Portfolio header */
.portfolio-header {
display: flex; align-items: baseline; justify-content: space-between;
padding: var(--space-6, 24px) 0 var(--space-4, 16px);
}
.portfolio-greeting {
font-family: var(--sans); font-size: 1.125rem; font-weight: 300;
color: var(--text-secondary);
}
.portfolio-greeting strong { font-weight: 400; color: var(--text-primary); }
.portfolio-stats {
font-family: var(--mono); font-size: 0.75rem; color: var(--text-tertiary);
display: flex; gap: 16px;
}
.portfolio-stats .ps-val { color: var(--text-secondary); }
/* Action queue — the hero */
.action-queue { margin-bottom: 24px; }
.aq-item {
display: flex; align-items: flex-start; gap: 16px;
padding: 10px 16px;
margin: 0 -16px;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s;
border-bottom: 1px solid var(--glass-border, rgba(255,255,255,0.06));
}
.aq-item:last-child { border-bottom: none; }
.aq-item:hover { background: rgba(255,255,255,0.04); }
.aq-item__dot {
width: 6px; height: 6px; border-radius: 9999px; flex-shrink: 0;
margin-top: 6px;
}
.aq-item__body { flex: 1; min-width: 0; }
.aq-item__headline {
font-family: var(--sans); font-size: 0.875rem; font-weight: 400;
color: var(--text-primary);
}
.aq-item__detail {
font-family: var(--mono); font-size: 0.75rem; color: var(--text-tertiary);
margin-top: 1px;
}
.aq-item__actions {
display: flex; gap: 8px; margin-top: 8px;
max-height: 0; overflow: hidden; opacity: 0;
transition: max-height 0.3s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.2s, margin 0.2s;
}
.aq-item:hover .aq-item__actions {
max-height: 40px; opacity: 1; margin-top: 8px;
}
.aq-action {
font-family: var(--mono); font-size: 10px; font-weight: 400;
color: var(--text-secondary); background: var(--obsidian-mid, #12121a);
border: 1px solid rgba(255,255,255,0.06); border-radius: 6px;
padding: 3px 8px; cursor: pointer; white-space: nowrap;
transition: color 0.2s, border-color 0.2s;
text-decoration: none;
display: inline-block;
}
.aq-action:hover { color: var(--accent, #5eead4); border-color: rgba(94,234,212,0.30); }
.aq-item__time {
font-family: var(--mono); font-size: 10px;
flex-shrink: 0; white-space: nowrap; margin-top: 2px;
}
.aq-item__time--red { color: var(--signal-red, #f87171); }
.aq-item__time--amber { color: var(--signal-amber, #fbbf24); }
.aq-item__time--green { color: var(--signal-green, #34d399); }
.aq-item__time--muted { color: var(--text-tertiary); }
.aq-item__arrow {
font-family: var(--mono); font-size: 12px; color: var(--text-tertiary);
opacity: 0; transition: opacity 0.15s;
flex-shrink: 0; margin-top: 2px;
}
.aq-item:hover .aq-item__arrow { opacity: 1; color: var(--accent, #5eead4); }
/* Permit table */
.permit-table { margin-bottom: 24px; }
.pt-header {
display: flex; align-items: baseline; justify-content: space-between;
margin-bottom: 12px;
}
.pt-filter {
display: flex; gap: 8px;
}
.pt-chip {
font-family: var(--mono); font-size: 10px; font-weight: 400;
color: var(--text-tertiary); background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.06);
padding: 3px 8px; border-radius: 9999px;
cursor: pointer; transition: border-color 0.2s, color 0.2s;
text-decoration: none;
}
.pt-chip:hover { border-color: rgba(255,255,255,0.10); color: var(--text-secondary); }
.pt-chip--active { border-color: rgba(94,234,212,0.30); color: var(--accent, #5eead4); background: rgba(94,234,212,0.08); }
.pt-row {
display: grid; grid-template-columns: 14px 1.4fr 0.8fr 1fr 0.6fr 14px;
align-items: center; gap: 12px;
padding: 9px 12px;
margin: 0 -12px;
border-bottom: 1px solid rgba(255,255,255,0.06);
border-radius: 6px;
cursor: pointer; transition: background 0.12s;
}
.pt-row:hover { background: rgba(255,255,255,0.04); }
.pt-row--header {
cursor: default; padding: 6px 12px;
}
.pt-row--header:hover { background: none; }
.pt-row--header span {
font-family: var(--mono); font-size: 9px; font-weight: 400;
letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-tertiary);
}
.pt-addr {
font-family: var(--mono); font-size: 0.875rem; font-weight: 300;
color: var(--text-primary);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
transition: color 0.15s;
text-decoration: none; display: block;
}
.pt-row:hover .pt-addr { color: var(--accent, #5eead4); }
.pt-type {
font-family: var(--sans); font-size: 0.75rem; color: var(--text-secondary);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.pt-station {
display: flex; align-items: center; gap: 8px;
}
.pt-station__bar { flex: 1; }
.pt-station__frac {
font-family: var(--mono); font-size: 10px; color: var(--text-tertiary);
flex-shrink: 0;
}
/* Mini gantt */
.mini-gantt {
display: flex; height: 4px; border-radius: 2px; overflow: hidden;
gap: 1px; flex: 1;
}
.mini-gantt__seg {
height: 100%; border-radius: 1px;
}
.mini-gantt__seg--done { background: var(--signal-green, #34d399); }
.mini-gantt__seg--active { background: var(--signal-amber, #fbbf24); animation: pulse-seg 2s ease-in-out infinite; }
.mini-gantt__seg--pending { background: rgba(255,255,255,0.06); }
.mini-gantt__seg--stalled { background: var(--signal-red, #f87171); animation: pulse-seg 2s ease-in-out infinite; }
@keyframes pulse-seg { 0%, 100% { opacity: 0.7; } 50% { opacity: 1; } }
/* Simple progress bar for properties without routing data */
.simple-progress {
display: flex; height: 4px; border-radius: 2px; overflow: hidden; flex: 1;
}
.simple-progress__fill {
height: 100%; border-radius: 2px;
}
.simple-progress__bg {
flex: 1; background: rgba(255,255,255,0.06);
}
.pt-status {
font-family: var(--mono); font-size: 0.75rem;
white-space: nowrap;
}
.status-text--red { color: var(--signal-red, #f87171); }
.status-text--amber { color: var(--signal-amber, #fbbf24); }
.status-text--green { color: var(--signal-green, #34d399); }
.status-text--muted { color: var(--text-tertiary); }
.pt-arrow {
font-family: var(--mono); font-size: 11px; color: var(--text-tertiary);
opacity: 0; transition: opacity 0.12s;
}
.pt-row:hover .pt-arrow { opacity: 1; color: var(--accent, #5eead4); }
/* Quick actions bar */
.quick-actions {
display: flex; gap: 8px; flex-wrap: wrap;
margin-bottom: 24px;
}
.qa-btn {
font-family: var(--mono); font-size: 0.75rem; font-weight: 400;
color: var(--text-secondary); background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.06); border-radius: 6px;
padding: 6px 12px; cursor: pointer;
display: flex; align-items: center; gap: 8px;
transition: border-color 0.2s, color 0.2s, background 0.2s;
text-decoration: none;
}
.qa-btn:hover {
border-color: rgba(94,234,212,0.30); color: var(--accent, #5eead4);
background: rgba(94,234,212,0.08);
}
.qa-btn__icon { font-size: 12px; }
/* Divider */
.divider {
border: none; border-top: 1px solid rgba(255,255,255,0.06);
margin: 0;
}
/* Section label */
.section-label {
font-family: var(--mono); font-size: 9px; font-weight: 400;
letter-spacing: 0.08em; text-transform: uppercase;
color: var(--text-tertiary); margin-bottom: 12px;
}
/* Freshness */
.freshness {
font-family: var(--mono); font-size: 10px; color: var(--text-tertiary);
display: flex; align-items: center; gap: 8px;
padding: 24px 0 16px;
}
.freshness-dot {
width: 5px; height: 5px; border-radius: 9999px;
background: var(--signal-green, #34d399);
display: inline-block; flex-shrink: 0;
}
/* Status dots */
.status-dot {
width: 6px; height: 6px; border-radius: 9999px; flex-shrink: 0;
display: inline-block;
}
.status-dot--red { background: var(--dot-red, #ef4444); }
.status-dot--amber { background: var(--dot-amber, #f59e0b); }
.status-dot--green { background: var(--dot-green, #22c55e); }
.status-dot--muted { background: var(--text-tertiary); }
/* Ghost CTA */
.ghost-cta {
font-family: var(--mono); font-size: 0.875rem; font-weight: 300;
color: var(--text-secondary); text-decoration: none;
border-bottom: 1px solid transparent;
transition: color 0.3s, border-color 0.3s;
}
.ghost-cta:hover { color: var(--accent, #5eead4); border-color: var(--accent, #5eead4); }
/* Empty state */
.portfolio-empty {
text-align: center;
padding: 64px 24px;
}
.portfolio-empty h2 {
font-family: var(--sans);
font-size: 1.125rem;
font-weight: 400;
color: var(--text-primary);
margin-bottom: 8px;
}
.portfolio-empty p {
font-family: var(--sans);
color: var(--text-secondary);
font-size: 0.875rem;
max-width: 420px;
margin: 0 auto 24px;
}
.portfolio-empty-actions {
display: flex;
gap: 12px;
justify-content: center;
flex-wrap: wrap;
}
.obsidian-btn {
font-family: var(--mono); font-size: 0.875rem; font-weight: 300;
padding: 10px 20px; border-radius: 6px;
cursor: pointer; text-decoration: none; display: inline-block;
transition: background 0.2s, border-color 0.2s, color 0.2s;
}
.obsidian-btn-primary {
background: var(--accent, #5eead4); color: #0a0a0f;
border: 1px solid var(--accent, #5eead4);
}
.obsidian-btn-primary:hover { background: rgba(94,234,212,0.85); }
.obsidian-btn-outline {
background: transparent; color: var(--text-secondary);
border: 1px solid rgba(255,255,255,0.14);
}
.obsidian-btn-outline:hover { border-color: rgba(255,255,255,0.30); color: var(--text-primary); }
/* obs-container */
.obs-container {
max-width: 720px;
margin: 0 auto;
padding: 0 20px;
}
/* Tier gate card (reused from tier_gate_teaser fragment) */
.tier-gate-card {
background: var(--obsidian-mid, #12121a);
border: 1px solid var(--glass-border, rgba(255,255,255,0.06));
border-radius: var(--radius-md, 12px);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
padding: var(--space-8);
max-width: 480px;
width: 100%;
text-align: center;
}
.tier-gate-badge {
display: inline-block;
font-family: var(--mono);
font-size: 0.65rem;
font-weight: 400;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--signal-blue, #60a5fa);
background: rgba(96, 165, 250, 0.08);
border: 1px solid rgba(96, 165, 250, 0.20);
border-radius: var(--radius-full, 9999px);
padding: 4px 12px;
margin-bottom: var(--space-4);
}
.tier-gate-title {
font-family: var(--sans);
font-size: var(--text-xl);
font-weight: 300;
color: var(--text-primary);
margin: 0 0 var(--space-4) 0;
line-height: 1.3;
}
.tier-gate-desc {
font-family: var(--sans);
font-size: var(--text-base);
color: var(--text-secondary);
line-height: 1.6;
margin: 0 0 var(--space-6) 0;
}
.tier-gate-cta {
display: block;
width: 100%;
font-family: var(--mono);
font-size: var(--text-base);
font-weight: 400;
color: var(--obsidian, #0a0a0f);
background: var(--accent, #5eead4);
border: none;
border-radius: var(--radius-md, 12px);
padding: 14px var(--space-6);
cursor: pointer;
text-decoration: none;
text-align: center;
transition: opacity 0.2s;
letter-spacing: 0.02em;
box-sizing: border-box;
}
.tier-gate-cta:hover { opacity: 0.88; }
.tier-gate-current {
margin-top: var(--space-4);
font-family: var(--mono);
font-size: var(--text-sm);
color: var(--text-tertiary);
}
@media (max-width: 480px) {
.tier-gate-card { padding: var(--space-6) var(--space-4); }
.tier-gate-title { font-size: var(--text-lg); }
}
/* Reveal animations */
.reveal { opacity: 0; transform: translateY(12px); transition: opacity 0.5s ease, transform 0.5s ease; }
.reveal.visible { opacity: 1; transform: translateY(0); }
.reveal-delay-1 { transition-delay: 0.1s; }
.reveal-delay-2 { transition-delay: 0.2s; }
.reveal-delay-3 { transition-delay: 0.3s; }
@media (max-width: 768px) {
.portfolio-header { flex-direction: column; gap: 8px; }
.pt-row { grid-template-columns: 10px 1fr 0.7fr 14px; }
.pt-row--header span:nth-child(4),
.pt-row--header span:nth-child(5),
.pt-row > :nth-child(4),
.pt-row > :nth-child(5) { display: none; }
.quick-actions { flex-wrap: wrap; }
}
</style>
</head>
<body class="obsidian">
{% set active_page = 'portfolio' %}
{% include 'fragments/nav.html' %}
{% if is_staging %}
<div style="background: var(--signal-amber, #fbbf24); color: #0a0a0f; text-align: center; padding: 8px 16px; font-size: 14px; font-weight: 600; letter-spacing: 0.02em; position: relative; z-index: 9999;">
STAGING ENVIRONMENT
</div>
{% endif %}
<div class="page-body">
<div class="obs-container">
{% if tier_locked %}
<!-- TIER GATE: free user sees upgrade teaser -->
<div style="min-height: 60vh; display: flex; align-items: center; justify-content: center; padding: var(--space-8) var(--space-4);">
<div class="tier-gate-card">
<span class="tier-gate-badge">Beta Feature</span>
<h1 class="tier-gate-title">Track all your properties in one place</h1>
<p class="tier-gate-desc">
Monitor permit activity across your entire portfolio. Get severity alerts,
inspection timelines, and health indicators for every property you watch
— all in a single morning brief.
</p>
{% if user %}
<a href="{{ url_for('auth.beta_join') }}" class="tier-gate-cta">
Join Beta →
</a>
<p class="tier-gate-current">Current plan: <span style="color: var(--text-secondary);">{{ current_tier }}</span></p>
{% else %}
<a href="{{ url_for('auth.auth_login') }}" class="tier-gate-cta">Sign In to Continue →</a>
{% endif %}
</div>
</div>
{% elif properties %}
<!-- HEADER -->
<div class="portfolio-header reveal">
<div class="portfolio-greeting">
{% set urgent_count = properties | selectattr('worst_health', 'in', ['at_risk', 'behind']) | list | length %}
{% if urgent_count > 0 %}
<strong>Good morning.</strong> {{ urgent_count }} {{ 'property needs' if urgent_count == 1 else 'properties need' }} attention.
{% else %}
<strong>Good morning.</strong> All properties are on track.
{% endif %}
</div>
<div class="portfolio-stats">
<span><span class="ps-val">{{ summary.total_properties }}</span> watching</span>
<span><span class="ps-val">{{ summary.total_active_permits }}</span> active</span>
{% if summary.action_needed > 0 %}
<span><span class="ps-val" style="color: var(--signal-red);">{{ summary.action_needed }}</span> urgent</span>
{% endif %}
</div>
</div>
<!-- QUICK ACTIONS -->
<div class="quick-actions reveal reveal-delay-1">
<a href="/brief" class="qa-btn"><span class="qa-btn__icon">✉</span> Morning brief</a>
<a href="/search" class="qa-btn"><span class="qa-btn__icon">+</span> New property lookup</a>
<a href="/search" class="qa-btn"><span class="qa-btn__icon">✦</span> Ask AI</a>
<a href="/portfolio?filter=action_needed&sort={{ sort_by }}" class="qa-btn"><span class="qa-btn__icon">⇅</span> Check stalled permits</a>
</div>
<!-- ACTION QUEUE — properties needing attention -->
{% set action_items = properties | selectattr('worst_health', 'in', ['at_risk', 'behind', 'slower']) | list %}
{% if action_items %}
<section class="action-queue reveal reveal-delay-1">
<div class="section-label">Needs attention</div>
{% for prop in action_items[:5] %}
{% set health = prop.worst_health %}
<div class="aq-item" onclick="window.location='/report/{{ prop.block }}/{{ prop.lot }}'">
<span class="aq-item__dot" style="background: {{ 'var(--dot-red)' if health == 'at_risk' else 'var(--dot-amber)' }};"></span>
<div class="aq-item__body">
<div class="aq-item__headline">
{% if health == 'at_risk' %}
{% if prop.open_violations and prop.open_violations > 0 %}
{{ prop.open_violations }} open violation{{ 's' if prop.open_violations != 1 }} at {{ prop.address }}
{% elif prop.open_complaints and prop.open_complaints > 0 %}
{{ prop.open_complaints }} open complaint{{ 's' if prop.open_complaints != 1 }} at {{ prop.address }}
{% elif prop.routing and prop.routing.stalled_stations %}
{{ prop.routing.stalled_stations | join(', ') }} stalled at {{ prop.address }}
{% else %}
Action needed at {{ prop.address }}
{% endif %}
{% elif health == 'behind' %}
{% if prop.routing and prop.routing.stalled_stations %}
{{ prop.routing.stalled_stations[0] }} pending at {{ prop.address }}
{% else %}
Behind schedule at {{ prop.address }}
{% endif %}
{% else %}
Moving slower than expected at {{ prop.address }}
{% endif %}
</div>
<div class="aq-item__detail">
{% if prop.permits %}
{{ prop.permits[0].permit_type or 'Permit' }}
{% if prop.routing %}
· {{ prop.routing.completed_stations }}/{{ prop.routing.total_stations }} stations cleared
{% elif prop.active_count > 0 %}
· {{ prop.active_count }} active permit{{ 's' if prop.active_count != 1 }}
{% endif %}
{% endif %}
{% if prop.health_reason %}
· <span style="color: {{ 'var(--signal-red)' if health == 'at_risk' else 'var(--signal-amber)' }};">{{ prop.health_reason }}</span>
{% endif %}
</div>
<div class="aq-item__actions">
<a href="/report/{{ prop.block }}/{{ prop.lot }}" class="aq-action" onclick="event.stopPropagation()">View report</a>
<a href="/search?q={{ prop.address | urlencode }}" class="aq-action" onclick="event.stopPropagation()">Search address</a>
</div>
</div>
{% if prop.days_since_activity is not none %}
<span class="aq-item__time {{ 'aq-item__time--red' if health == 'at_risk' else 'aq-item__time--amber' }}">
{% if prop.days_since_activity == 0 %}today
{% elif prop.days_since_activity == 1 %}yesterday
{% elif prop.days_since_activity <= 7 %}{{ prop.days_since_activity }}d ago
{% else %}{{ prop.days_since_activity }}d stalled
{% endif %}
</span>
{% endif %}
<span class="aq-item__arrow">→</span>
</div>
{% endfor %}
{% set on_track_items = properties | selectattr('worst_health', 'equalto', 'on_track') | list %}
{% for prop in on_track_items[:2] %}
<div class="aq-item" onclick="window.location='/report/{{ prop.block }}/{{ prop.lot }}'">
<span class="aq-item__dot" style="background: var(--dot-green);"></span>
<div class="aq-item__body">
<div class="aq-item__headline">
{% if prop.active_count == 0 and prop.permits %}
{% set last_permit = prop.permits[0] %}
{{ prop.address }} — {{ last_permit.status | title }}
{% else %}
{{ prop.address }} — on track
{% endif %}
</div>
<div class="aq-item__detail">
{% if prop.permits %}{{ prop.permits[0].permit_type }}{% endif %}
{% if prop.routing %}· {{ prop.routing.completed_stations }}/{{ prop.routing.total_stations }} stations cleared{% endif %}
</div>
<div class="aq-item__actions">
<a href="/report/{{ prop.block }}/{{ prop.lot }}" class="aq-action" onclick="event.stopPropagation()">View report</a>
<a href="/search?q={{ prop.address | urlencode }}" class="aq-action" onclick="event.stopPropagation()">Search address</a>
</div>
</div>
{% if prop.days_since_activity is not none %}
<span class="aq-item__time aq-item__time--green">
{% if prop.days_since_activity == 0 %}today
{% elif prop.days_since_activity == 1 %}yesterday
{% else %}{{ prop.days_since_activity }}d ago
{% endif %}
</span>
{% endif %}
<span class="aq-item__arrow">→</span>
</div>
{% endfor %}
</section>
<hr class="divider">
{% endif %}
<!-- PERMIT TABLE -->
<section class="permit-table reveal reveal-delay-2" style="padding-top: 24px;">
<div class="pt-header">
<div class="section-label" style="margin-bottom: 0;">Active permits</div>
<div class="pt-filter">
<a href="/portfolio?filter=all&sort={{ sort_by }}" class="pt-chip {{ 'pt-chip--active' if filter_by == 'all' or not filter_by }}">All</a>
<a href="/portfolio?filter=action_needed&sort={{ sort_by }}" class="pt-chip {{ 'pt-chip--active' if filter_by == 'action_needed' }}">Stalled</a>
<a href="/portfolio?filter=in_review&sort={{ sort_by }}" class="pt-chip {{ 'pt-chip--active' if filter_by == 'in_review' }}">In review</a>
<a href="/portfolio?filter=active&sort={{ sort_by }}" class="pt-chip {{ 'pt-chip--active' if filter_by == 'active' }}">Active</a>
</div>
</div>
<div class="pt-row pt-row--header">
<span></span>
<span>Address</span>
<span>Type</span>
<span>Progress</span>
<span>Status</span>
<span></span>
</div>
{% for prop in properties[:14] %}
{% set health = prop.worst_health %}
{% set dot_class = 'status-dot--red' if health == 'at_risk' else ('status-dot--amber' if health in ('behind', 'slower') else 'status-dot--green') %}
{% set status_class = 'status-text--red' if health == 'at_risk' else ('status-text--amber' if health in ('behind', 'slower') else 'status-text--green') %}
<div class="pt-row" onclick="window.location='/report/{{ prop.block }}/{{ prop.lot }}'">
<span class="status-dot {{ dot_class }}"></span>
<a href="/report/{{ prop.block }}/{{ prop.lot }}" class="pt-addr" onclick="event.stopPropagation()">{{ prop.address }}</a>
<span class="pt-type">{{ prop.permits[0].permit_type if prop.permits else '' }}</span>
<span class="pt-station">
{% if prop.routing %}
{# Mini-gantt from real routing data #}
<div class="mini-gantt">
{% set total_st = prop.routing.total_stations %}
{% set done_st = prop.routing.completed_stations %}
{% set stalled_names = prop.routing.stalled_stations or [] %}
{% set held_names = prop.routing.held_stations or [] %}
{% set pending_names = prop.routing.pending_station_names or [] %}
{% for i in range(total_st) %}
{% if i < done_st %}
<span class="mini-gantt__seg mini-gantt__seg--done" style="flex:1"></span>
{% elif (pending_names | length > 0) and (i == done_st) %}
{% set active_station = pending_names[0] if pending_names else '' %}
{% if active_station in stalled_names or active_station in held_names %}
<span class="mini-gantt__seg mini-gantt__seg--stalled" style="flex:1" title="{{ active_station }}"></span>
{% else %}
<span class="mini-gantt__seg mini-gantt__seg--active" style="flex:1" title="{{ active_station }}"></span>
{% endif %}
{% else %}
<span class="mini-gantt__seg mini-gantt__seg--pending" style="flex:1"></span>
{% endif %}
{% endfor %}
</div>
<span class="pt-station__frac">{{ prop.routing.completed_stations }}/{{ prop.routing.total_stations }}</span>
{% else %}
{# Simple progress bar from permit status #}
{% set active_p = prop.active_count %}
{% set total_p = prop.permits | length %}
{% set pct = (active_p / total_p * 100) | int if total_p > 0 else 0 %}
<div class="simple-progress">
<div class="simple-progress__fill"
style="width: {{ pct }}%; background: {{ 'var(--signal-green)' if health == 'on_track' else ('var(--signal-amber)' if health in ('behind', 'slower') else 'var(--signal-red)') }};"></div>
<div class="simple-progress__bg"></div>
</div>
<span class="pt-station__frac">{{ active_p }}/{{ total_p }}</span>
{% endif %}
</span>
<span class="pt-status {{ status_class }}">
{% if health == 'at_risk' %}
{% if prop.routing and prop.routing.stalled_stations %}
{{ prop.routing.stalled_stations[0] }} stalled
{% elif prop.open_violations %}
{{ prop.open_violations }} violation{{ 's' if prop.open_violations != 1 }}
{% else %}
At risk
{% endif %}
{% elif health in ('behind', 'slower') %}
{% if prop.routing and prop.routing.stalled_stations %}
{{ prop.routing.stalled_stations[0] }}
{% elif prop.routing and prop.routing.pending_station_names %}
{{ prop.routing.pending_station_names[0] }}
{% else %}
{{ 'Behind' if health == 'behind' else 'Slower' }}
{% endif %}
{% else %}
{% set last_permit = prop.permits[0] if prop.permits else none %}
{% if last_permit and last_permit.status == 'issued' %}
Issued
{% elif prop.active_count == 0 %}
Complete
{% else %}
On track
{% endif %}
{% endif %}
</span>
<span class="pt-arrow">→</span>
</div>
{% endfor %}
{% if properties | length > 14 %}
<div style="text-align: center; padding: 12px 0;">
<a href="/portfolio?filter={{ filter_by or 'all' }}&sort={{ sort_by or 'recent' }}&all=1" class="ghost-cta">All {{ properties | length }} active permits →</a>
</div>
{% endif %}
</section>
<hr class="divider">
<!-- UPCOMING / HEADS UP — properties with inspection or activity data -->
{% set recent_props = properties | sort(attribute='latest_activity', reverse=True) | list %}
{% if recent_props %}
<section class="upcoming reveal reveal-delay-3" style="padding-top: 24px; margin-bottom: 24px;">
<div class="section-label">Recent activity</div>
{% for prop in recent_props[:4] %}
{% if prop.days_since_activity is not none %}
<div class="up-item" style="display: flex; align-items: center; gap: 12px; padding: 8px 12px; margin: 0 -12px; border-radius: 6px; transition: background 0.12s; cursor: pointer;"
onclick="window.location='/report/{{ prop.block }}/{{ prop.lot }}'">
<span style="font-family: var(--mono); font-size: 0.75rem; width: 50px; flex-shrink: 0;
color: {{ 'var(--signal-red)' if prop.days_since_activity <= 3 else ('var(--signal-amber)' if prop.days_since_activity <= 14 else 'var(--text-secondary)') }};">
{% if prop.days_since_activity == 0 %}today
{% elif prop.days_since_activity == 1 %}1 day
{% elif prop.days_since_activity <= 7 %}{{ prop.days_since_activity }}d
{% else %}{{ prop.days_since_activity }}d
{% endif %}
</span>
<span style="font-family: var(--sans); font-size: 0.875rem; font-weight: 300; color: var(--text-secondary); flex: 1;">
<strong style="font-weight: 400; color: var(--text-primary);">{{ prop.address }}</strong>
{% if prop.last_inspection and prop.last_inspection.type %}
— {{ prop.last_inspection.type | lower | capitalize }}
{% if prop.last_inspection.result %}
<span style="color: {{ 'var(--signal-green)' if prop.last_inspection.result == 'PASSED' else ('var(--signal-red)' if prop.last_inspection.result == 'FAILED' else 'var(--text-secondary)') }};">{{ prop.last_inspection.result | lower }}</span>
{% endif %}
{% elif prop.permits %}
— {{ prop.permits[0].status | lower }}
{% endif %}
</span>
<span style="flex-shrink: 0; opacity: 0; transition: opacity 0.15s;" class="up-cta">
<a href="/report/{{ prop.block }}/{{ prop.lot }}" class="aq-action" onclick="event.stopPropagation()">View report</a>
</span>
<span style="font-family: var(--mono); font-size: 11px; color: var(--text-secondary); opacity: 0; transition: opacity 0.12s;" class="up-arrow">→</span>
</div>
{% endif %}
{% endfor %}
</section>
{% endif %}
{% else %}
<!-- Empty state -->
<div class="portfolio-empty">
<h2>Your portfolio is empty</h2>
<p>Start watching properties to track permit activity, health indicators, inspection timelines, and smart alerts — all in one place.</p>
<div class="portfolio-empty-actions">
<a href="/search" class="obsidian-btn obsidian-btn-primary">Search for a property →</a>
<a href="/account" class="obsidian-btn obsidian-btn-outline">Manage account →</a>
</div>
</div>
{% endif %}
<!-- FRESHNESS -->
<div class="freshness reveal">
<span class="freshness-dot"></span>
Data updated nightly · <a href="/about-data" style="color: var(--text-secondary); text-decoration: none; border-bottom: 1px solid transparent; transition: color 0.2s, border-color 0.2s;">sources →</a>
</div>
</div>
</div>
<script nonce="{{ csp_nonce }}" src="/static/admin-feedback.js"></script>
<script nonce="{{ csp_nonce }}" src="/static/admin-tour.js"></script>
<script nonce="{{ csp_nonce }}">
// Reveal animation
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
observer.unobserve(entry.target);
}
});
}, { threshold: 0.15, rootMargin: '0px 0px -40px 0px' });
document.querySelectorAll('.reveal').forEach(el => observer.observe(el));
// Up-item hover: show CTA and arrow
document.querySelectorAll('.up-item').forEach(item => {
item.addEventListener('mouseenter', () => {
const cta = item.querySelector('.up-cta');
const arrow = item.querySelector('.up-arrow');
if (cta) cta.style.opacity = '1';
if (arrow) arrow.style.opacity = '1';
});
item.addEventListener('mouseleave', () => {
const cta = item.querySelector('.up-cta');
const arrow = item.querySelector('.up-arrow');
if (cta) cta.style.opacity = '0';
if (arrow) arrow.style.opacity = '0';
});
});
// Up-item hover: background
document.querySelectorAll('.up-item').forEach(item => {
item.addEventListener('mouseenter', () => {
item.style.background = 'rgba(255,255,255,0.04)';
});
item.addEventListener('mouseleave', () => {
item.style.background = '';
});
});
</script>
{% if posthog_key %}
<script async nonce="{{ csp_nonce }}">
!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.async=!0,p.src=s.api_host+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled getFeatureFlag getFeatureFlagPayload reloadFeatureFlags group updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures getActiveMatchingSurveys getSurveys onFeatureFlags".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
posthog.init('{{ posthog_key }}', {api_host: '{{ posthog_host }}'});
</script>
{% endif %}
</body>
</html>