Jul 10 - Update QR public page

This commit is contained in:
2026-07-10 13:25:38 -04:00
parent 8d4b8b72eb
commit 008dd94962
2 changed files with 148 additions and 48 deletions
+63 -12
View File
@@ -62,12 +62,19 @@ def _rating_label(score):
def _build_summary(facility: Facility) -> dict: def _build_summary(facility: Facility) -> dict:
"""Assemble the occupant-facing summary for a facility.""" """Assemble the occupant-facing summary for a facility.
Occupant-safe (rule 74): aggregate rating, counts, a score trend, and
recent inspection DATES only — no checklist/template names, no
per-inspection scores, no issue descriptions, and no severity/SLA detail.
"""
fid = facility.id fid = facility.id
now = now_eastern() now = now_eastern()
cutoff = now - timedelta(days=90) cutoff_90 = now - timedelta(days=90)
cutoff_30 = now - timedelta(days=30)
cutoff_60 = now - timedelta(days=60)
# Most recent completed, scored inspection # Most recent completed, scored inspection (for last-inspected date)
last_insp = ( last_insp = (
Inspection.query Inspection.query
.filter(Inspection.facility_id == fid, .filter(Inspection.facility_id == fid,
@@ -77,15 +84,18 @@ def _build_summary(facility: Facility) -> dict:
.first() .first()
) )
# Average score over the last 90 days (fallback: all-time) for the rating def _avg_between(start, end=None):
avg_90 = ( q = (db.session.query(func.avg(Inspection.overall_score))
db.session.query(func.avg(Inspection.overall_score))
.filter(Inspection.facility_id == fid, .filter(Inspection.facility_id == fid,
Inspection.status == 'completed', Inspection.status == 'completed',
Inspection.overall_score.isnot(None), Inspection.overall_score.isnot(None),
Inspection.inspection_date >= cutoff) Inspection.inspection_date >= start))
.scalar() if end is not None:
) q = q.filter(Inspection.inspection_date < end)
return q.scalar()
# Average score over the last 90 days (fallback: all-time) for the rating
avg_90 = _avg_between(cutoff_90)
if avg_90 is None: if avg_90 is None:
avg_90 = ( avg_90 = (
db.session.query(func.avg(Inspection.overall_score)) db.session.query(func.avg(Inspection.overall_score))
@@ -96,6 +106,34 @@ def _build_summary(facility: Facility) -> dict:
) )
avg_score = round(float(avg_90), 1) if avg_90 is not None else None avg_score = round(float(avg_90), 1) if avg_90 is not None else None
# Completed-inspection count over the last 90 days
inspections_90 = (
Inspection.query
.filter(Inspection.facility_id == fid,
Inspection.status == 'completed',
Inspection.inspection_date >= cutoff_90)
.count()
)
# Score trend: last 30 days vs the prior 30 days (aggregate only)
avg_cur = _avg_between(cutoff_30)
avg_prior = _avg_between(cutoff_60, cutoff_30)
if avg_cur is not None and avg_prior is not None:
trend_delta = round(float(avg_cur) - float(avg_prior), 1)
else:
trend_delta = None
# Recent inspection DATES only (no checklist names, no scores)
recent = (
Inspection.query
.filter(Inspection.facility_id == fid,
Inspection.status == 'completed')
.order_by(Inspection.inspection_date.desc())
.limit(5)
.all()
)
recent_dates = [i.inspection_date for i in recent]
# Open-issue COUNT (linked directly or via an area) — no details exposed # Open-issue COUNT (linked directly or via an area) — no details exposed
open_issue_count = ( open_issue_count = (
Issue.query Issue.query
@@ -105,6 +143,17 @@ def _build_summary(facility: Facility) -> dict:
.count() .count()
) )
# Resolved-issue COUNT over the last 90 days — no details exposed
resolved_90 = (
Issue.query
.outerjoin(Area, Issue.area_id == Area.id)
.filter(Issue.status == 'resolved',
Issue.resolved_at.isnot(None),
Issue.resolved_at >= cutoff_90,
db.or_(Issue.facility_id == fid, Area.facility_id == fid))
.count()
)
label, colour = _rating_label(avg_score) label, colour = _rating_label(avg_score)
return { return {
@@ -112,10 +161,12 @@ def _build_summary(facility: Facility) -> dict:
'avg_score': avg_score, 'avg_score': avg_score,
'rating_label': label, 'rating_label': label,
'rating_colour': colour, 'rating_colour': colour,
'last_inspected': last_insp.inspection_date if last_insp else None, 'inspections_90': inspections_90,
'last_score': (round(float(last_insp.overall_score), 1)
if last_insp and last_insp.overall_score is not None else None),
'open_issue_count': open_issue_count, 'open_issue_count': open_issue_count,
'resolved_90': resolved_90,
'trend_delta': trend_delta,
'last_inspected': last_insp.inspection_date if last_insp else None,
'recent_dates': recent_dates,
} }
+83 -34
View File
@@ -8,11 +8,18 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
<style> <style>
body { background:#f6f8fa; } body { background:#eef1f5; }
.wrap { max-width: 640px; margin: 0 auto; padding: 16px; } .wrap { max-width: 640px; margin: 0 auto; padding: 16px; }
.hpot { position:absolute !important; left:-9999px !important; width:1px; height:1px; overflow:hidden; } .hpot { position:absolute !important; left:-9999px !important; width:1px; height:1px; overflow:hidden; }
.rating-num { font-size: 2.4rem; font-weight: 700; line-height: 1; } .card-soft { background:#fff; border:1px solid #e6e9ef; border-radius:.85rem; }
.stat { background:#fff; border-radius:.75rem; } .kpi-num { font-size: 1.9rem; font-weight: 700; line-height: 1; }
.kpi-label { font-size: .68rem; letter-spacing:.05em; color:#8a93a2; text-transform:uppercase; }
.kpi-sub { font-size: .62rem; letter-spacing:.05em; color:#aab1bd; text-transform:uppercase; }
.fac-icon { width:40px; height:40px; border-radius:.6rem; background:#e7efff; color:#2563eb;
display:flex; align-items:center; justify-content:center; font-size:1.3rem; flex:none; }
.sec-title { font-weight:700; font-size:1.02rem; }
.list-line { border-top:1px solid #eef0f4; }
.list-line:first-child { border-top:0; }
</style> </style>
</head> </head>
<body> <body>
@@ -27,49 +34,91 @@
{% endwith %} {% endwith %}
{# ── Header ── #} {# ── Header ── #}
<div class="text-center mt-2 mb-3"> <div class="d-flex align-items-start gap-3 mb-3 mt-1">
<div class="text-muted small text-uppercase" style="letter-spacing:.08em;">Facility Status</div> <div class="fac-icon"><i class="bi bi-building"></i></div>
<h1 class="h3 fw-bold mb-0">{{ facility.name }}</h1> <div>
{% if facility.address %} <h1 class="h4 fw-bold mb-1">{{ facility.name }}</h1>
<div class="text-muted small mt-1"><i class="bi bi-geo-alt"></i> {{ facility.address }}</div> <div class="text-muted small">
{% endif %} {% if facility.project %}{{ facility.project.name }}{% endif %}
{% if facility.project and facility.address %} &middot; {% endif %}
{% if facility.address %}{{ facility.address }}{% endif %}
</div>
</div>
</div> </div>
{# ── Quality rating ── #} {# ── KPI row ── #}
<div class="stat shadow-sm p-4 mb-3 text-center">
<div class="text-muted small text-uppercase mb-2">Cleaning Quality</div>
<span class="badge bg-{{ rating_colour }} fs-6 mb-2">{{ rating_label }}</span>
<div class="rating-num text-{{ rating_colour }}">
{{ ('%.0f' % avg_score) ~ '%' if avg_score is not none else '—' }}
</div>
<div class="text-muted small mt-1">Average score, last 90 days</div>
</div>
{# ── Facts ── #}
<div class="row g-2 mb-3"> <div class="row g-2 mb-3">
<div class="col-6"> <div class="col-6 col-md-3">
<div class="stat shadow-sm p-3 h-100"> <div class="card-soft p-3 text-center h-100">
<div class="text-muted small"><i class="bi bi-calendar-check"></i> Last Inspected</div> <div class="kpi-num text-{{ rating_colour }}">
<div class="fw-semibold"> {{ ('%.1f' % avg_score) ~ '%' if avg_score is not none else '—' }}
{{ last_inspected.strftime('%b %d, %Y') if last_inspected else 'Not yet' }}
</div> </div>
{% if last_score is not none %} <div class="kpi-label mt-2">Avg Score</div>
<div class="text-muted small">Scored {{ '%.0f' % last_score }}%</div> <div class="kpi-sub">90 Days</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card-soft p-3 text-center h-100">
<div class="kpi-num">{{ inspections_90 }}</div>
<div class="kpi-label mt-2">Inspections</div>
<div class="kpi-sub">90 Days</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card-soft p-3 text-center h-100">
<div class="kpi-num">{{ open_issue_count }}</div>
<div class="kpi-label mt-2">Open</div>
<div class="kpi-sub">Issues</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card-soft p-3 text-center h-100">
<div class="kpi-num">{{ resolved_90 }}</div>
<div class="kpi-label mt-2">Resolved</div>
<div class="kpi-sub">90 Days</div>
</div>
</div>
</div>
{# ── Score trend (aggregate only) ── #}
<div class="card-soft p-3 mb-3 d-flex align-items-center justify-content-between">
<div class="kpi-label">Score Trend — 30 Days vs Prior 30</div>
<div>
{% if trend_delta is none %}
<span class="text-muted small">Not enough data yet</span>
{% elif trend_delta > 0 %}
<span class="text-success fw-semibold"><i class="bi bi-arrow-up-right"></i> +{{ '%.1f' % trend_delta }} pts</span>
{% elif trend_delta < 0 %}
<span class="text-danger fw-semibold"><i class="bi bi-arrow-down-right"></i> {{ '%.1f' % trend_delta }} pts</span>
{% else %}
<span class="text-muted fw-semibold"><i class="bi bi-dash"></i> No change</span>
{% endif %} {% endif %}
</div> </div>
</div> </div>
<div class="col-6">
<div class="stat shadow-sm p-3 h-100"> {# ── Cleaning quality rating ── #}
<div class="text-muted small"><i class="bi bi-exclamation-triangle"></i> Open Issues</div> <div class="card-soft p-3 mb-3 d-flex align-items-center justify-content-between">
<div class="fw-semibold fs-4">{{ open_issue_count }}</div> <div class="sec-title"><i class="bi bi-stars me-1 text-{{ rating_colour }}"></i> Cleaning Quality</div>
<div class="text-muted small">Currently being tracked</div> <span class="badge bg-{{ rating_colour }} fs-6">{{ rating_label }}</span>
</div> </div>
{# ── Recent inspections (DATES ONLY — occupant-safe) ── #}
{% if recent_dates %}
<div class="card-soft p-3 mb-3">
<div class="sec-title mb-2"><i class="bi bi-clipboard-check me-1"></i> Recent Inspections</div>
{% for d in recent_dates %}
<div class="list-line py-2 d-flex align-items-center justify-content-between">
<span>{{ d.strftime('%b %d, %Y') }}</span>
<span class="text-success small"><i class="bi bi-check-circle-fill"></i> Completed</span>
</div> </div>
{% endfor %}
<div class="text-muted small mt-2">This facility is inspected regularly by our quality team.</div>
</div> </div>
{% endif %}
{# ── Report a problem ── #} {# ── Report a problem ── #}
<div class="stat shadow-sm p-4 mb-4"> <div class="card-soft p-4 mb-4">
<h2 class="h5 fw-bold"><i class="bi bi-megaphone"></i> Report a Problem</h2> <h2 class="sec-title"><i class="bi bi-megaphone me-1"></i> Report a Problem</h2>
<p class="text-muted small"> <p class="text-muted small">
Notice something that needs attention? Let the cleaning team know. Notice something that needs attention? Let the cleaning team know.
</p> </p>