Files
WinStudentGoalTracker/docs/technical.html
T
2026-04-17 12:55:10 -07:00

1968 lines
78 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>WIN Student Goal Tracker Developer Documentation</title>
<meta name="description" content="Developer documentation for the WIN Student Goal Tracker project." />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=DM+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root{
--bg:#eef3f9;
--surface:#ffffff;
--surface-2:#f8fbff;
--sidebar:#0f172a;
--sidebar-text:#cbd5e1;
--text:#1f2937;
--heading:#0f172a;
--accent:#2563eb;
--accent-soft:#dbeafe;
--border:#dbe3f0;
--shadow:0 10px 30px rgba(15,23,42,.08);
--radius:16px;
}
*{box-sizing:border-box}
html{scroll-behavior:smooth}
body{
margin:0;
font-family: Inter, Arial, Helvetica, sans-serif;
background:linear-gradient(180deg,#f8fbff 0%, #eef3f9 100%);
color:var(--text);
line-height:1.65;
}
.page{
display:grid;
grid-template-columns:280px minmax(0,1fr);
gap:24px;
max-width:1320px;
margin:auto;
padding:24px;
align-items:start;
}
.sidebar{
position:sticky;
top:20px;
background:var(--sidebar);
color:var(--sidebar-text);
padding:22px 18px;
border-radius:20px;
box-shadow:var(--shadow);
height:fit-content;
}
.sidebar .brand{
font-size:1rem;
font-weight:700;
color:#fff;
margin-bottom:18px;
padding-bottom:14px;
border-bottom:1px solid rgba(255,255,255,.08);
}
.sidebar a{
display:block;
padding:10px 12px;
margin:4px 0;
border-radius:10px;
text-decoration:none;
color:var(--sidebar-text);
font-size:.95rem;
transition:all .2s ease;
}
.sidebar a:hover{
background:rgba(255,255,255,.08);
color:#fff;
}
.content{
min-width:0;
}
.hero{
background:linear-gradient(135deg, rgba(37,99,235,.95), rgba(15,23,42,.95));
color:white;
padding:38px 36px;
border-radius:22px;
box-shadow:var(--shadow);
margin-bottom:22px;
}
.hero h1{
margin:0 0 10px;
font-size:2.1rem;
line-height:1.15;
letter-spacing:-0.02em;
color:#fff;
}
.hero p{
margin:4px 0;
color:rgba(255,255,255,.9);
}
.meta{
display:inline-block;
background:rgba(255,255,255,.14);
color:#fff;
border-radius:999px;
padding:6px 12px;
font-size:.88rem;
font-weight:600;
margin-bottom:12px;
}
section{
background:var(--surface);
padding:28px;
border-radius:20px;
margin-bottom:20px;
border:1px solid var(--border);
box-shadow:var(--shadow);
scroll-margin-top:20px;
}
h2{
margin:0 0 14px;
color:var(--heading);
font-size:1.45rem;
letter-spacing:-0.01em;
padding-bottom:10px;
border-bottom:2px solid var(--accent-soft);
}
h3{
margin:22px 0 10px;
color:var(--heading);
font-size:1.05rem;
}
p{
margin:10px 0 0;
}
ul, ol{
padding-left:22px;
margin:10px 0 0;
}
li{
margin:8px 0;
}
.stack{
display:grid;
grid-template-columns:repeat(auto-fit,minmax(220px,1fr));
gap:14px;
margin-top:14px;
}
.card{
border:1px solid var(--border);
background:var(--surface-2);
padding:16px;
border-radius:14px;
box-shadow:0 4px 14px rgba(15,23,42,.04);
}
.card strong{
display:block;
margin-bottom:6px;
color:var(--heading);
}
.flow{
background:linear-gradient(90deg,#eff6ff,#f8fbff);
border:1px solid #bfdbfe;
border-left:5px solid var(--accent);
color:#1e3a8a;
padding:14px 16px;
border-radius:12px;
margin:10px 0 14px;
font-family:"Courier New", Courier, monospace;
font-size:.95rem;
overflow:auto;
}
pre{
background:#0b1220;
color:#e5e7eb;
padding:16px 18px;
border-radius:14px;
overflow:auto;
border:1px solid #1e293b;
box-shadow:inset 0 1px 0 rgba(255,255,255,.03);
margin-top:12px;
}
code{
font-family:"Courier New", Courier, monospace;
}
.pill{
display:inline-block;
background:var(--accent-soft);
color:var(--accent);
border-radius:999px;
padding:6px 12px;
font-size:.88rem;
font-weight:600;
margin:10px 8px 0 0;
}
@media (max-width: 980px){
.page{
grid-template-columns:1fr;
}
.sidebar{
position:relative;
top:0;
}
}
@media (max-width: 640px){
.page{
padding:14px;
gap:16px;
}
.hero{
padding:28px 22px;
}
.hero h1{
font-size:1.7rem;
}
section{
padding:20px;
}
}
</style>
<style>
/* ── Appendix C Architecture Overview (all scoped to #appendix-c) ── */
.arch-link {
display: inline-flex; align-items: center; gap: .4rem; font-size: .88rem;
color: var(--accent); text-decoration: none; font-weight: 600;
margin-bottom: 1.2rem;
}
.arch-link:hover { text-decoration: underline; }
#appendix-c .asec-title {
font-size: 1rem; font-weight: 700; color: var(--heading);
margin: 1.8rem 0 .8rem; padding-bottom: .35rem; border-bottom: 2px solid var(--border);
display: flex; align-items: center; gap: .4rem;
}
/* tier diagram */
#appendix-c .arch-diagram { display: flex; flex-direction: column; gap: 0; align-items: stretch; }
#appendix-c .tier { display: flex; align-items: stretch; gap: .75rem; position: relative; }
#appendix-c .tier-label {
writing-mode: vertical-rl; text-orientation: mixed; transform: rotate(180deg);
font-size: .65rem; font-weight: 700; letter-spacing: .08em; text-transform: uppercase;
color: white; padding: .5rem .35rem; border-radius: 8px 0 0 8px;
display: flex; align-items: center; justify-content: center; min-width: 28px;
}
#appendix-c .tier-body { flex: 1; display: flex; gap: .75rem; flex-wrap: wrap; padding: .9rem; border-radius: 0 12px 12px 0; }
#appendix-c .tier--client .tier-label { background: #2563eb; }
#appendix-c .tier--client .tier-body { background: #eff6ff; border: 1.5px solid #bfdbfe; }
#appendix-c .tier--proxy .tier-label { background: #64748b; }
#appendix-c .tier--proxy .tier-body { background: #f8fafc; border: 1.5px solid #cbd5e1; }
#appendix-c .tier--api .tier-label { background: #16a34a; }
#appendix-c .tier--api .tier-body { background: #f0fdf4; border: 1.5px solid #bbf7d0; }
#appendix-c .tier--data .tier-label { background: #d97706; }
#appendix-c .tier--data .tier-body { background: #fffbeb; border: 1.5px solid #fde68a; }
#appendix-c .tier--ext .tier-label { background: #7c3aed; }
#appendix-c .tier--ext .tier-body { background: #faf5ff; border: 1.5px solid #ddd6fe; }
#appendix-c .tier-arrow {
display: flex; align-items: center; justify-content: center;
height: 28px; gap: .4rem; color: #6b7280; font-size: .7rem; margin-left: 28px;
}
#appendix-c .tier-arrow svg { flex-shrink: 0; }
/* boxes */
#appendix-c .box {
background: white; border-radius: 10px; padding: .55rem .8rem;
box-shadow: 0 2px 6px rgba(0,0,0,.06); border: 1px solid rgba(0,0,0,.06); min-width: 130px;
}
#appendix-c .box-title { font-size: .78rem; font-weight: 700; margin: 0 0 .25rem; color: var(--heading); }
#appendix-c .box ul { list-style: none; font-size: .7rem; color: #4b5563; padding: 0; margin: 0; }
#appendix-c .box ul li { margin-bottom: .12rem; }
#appendix-c .box ul li::before { content: "• "; color: #9ca3af; }
#appendix-c .box--blue { border-top: 3px solid #2563eb; }
#appendix-c .box--green { border-top: 3px solid #16a34a; }
#appendix-c .box--amber { border-top: 3px solid #d97706; }
#appendix-c .box--purple { border-top: 3px solid #7c3aed; }
#appendix-c .box--slate { border-top: 3px solid #64748b; }
/* badges */
#appendix-c .badge {
display: inline-block; font-size: .6rem; font-weight: 700;
padding: .1rem .35rem; border-radius: 4px; margin-left: .2rem; vertical-align: middle;
}
#appendix-c .badge--blue { background: #dbeafe; color: #1d4ed8; }
#appendix-c .badge--green { background: #dcfce7; color: #15803d; }
#appendix-c .badge--amber { background: #fef3c7; color: #b45309; }
#appendix-c .badge--purple { background: #ede9fe; color: #6d28d9; }
/* auth flow — renamed .aflow* to avoid conflict with technical.html's .flow rule */
#appendix-c .aflow { display: flex; align-items: flex-start; gap: 0; overflow-x: auto; padding-bottom: .5rem; }
#appendix-c .aflow-step { display: flex; flex-direction: column; align-items: center; min-width: 150px; max-width: 165px; }
#appendix-c .aflow-bubble {
background: white; border-radius: 10px; padding: .55rem .7rem;
box-shadow: 0 2px 6px rgba(0,0,0,.07); border: 1.5px solid var(--border);
font-size: .7rem; text-align: center; width: 100%;
}
#appendix-c .aflow-bubble strong { display: block; font-size: .78rem; margin: 0 0 .2rem; color: var(--heading); font-weight: 700; }
#appendix-c .aflow-bubble .note { color: #6b7280; margin-top: .2rem; font-style: italic; }
#appendix-c .aflow-num {
width: 22px; height: 22px; border-radius: 50%; background: var(--heading); color: white;
font-size: .7rem; font-weight: 700; display: flex; align-items: center; justify-content: center; margin-bottom: .35rem;
}
#appendix-c .aflow-connector { display: flex; align-items: center; padding-top: 11px; flex-shrink: 0; }
#appendix-c .aflow-connector svg { color: #9ca3af; }
/* data model */
#appendix-c .entity-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: .7rem; }
#appendix-c .entity { background: white; border-radius: 10px; box-shadow: 0 2px 6px rgba(0,0,0,.06); border: 1px solid var(--border); overflow: hidden; }
#appendix-c .entity-header { padding: .4rem .7rem; font-size: .75rem; font-weight: 700; color: white; }
#appendix-c .entity-fields { padding: .45rem .7rem; font-size: .68rem; color: #4b5563; }
#appendix-c .entity-fields div { padding: .1rem 0; border-bottom: 1px solid #f3f4f6; }
#appendix-c .entity-fields div:last-child { border-bottom: none; }
#appendix-c .pk { color: #d97706; font-weight: 700; }
#appendix-c .fk { color: var(--accent); }
#appendix-c .field-note { color: #9ca3af; font-size: .63rem; margin-left: .2rem; }
#appendix-c .rel-summary {
margin-top: .8rem; background: white; border-radius: 10px;
padding: .8rem 1rem; border: 1px solid var(--border); font-size: .73rem; color: var(--text);
}
/* role pyramid + permission matrix */
#appendix-c .role-and-matrix { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
@media (max-width: 720px) { #appendix-c .role-and-matrix { grid-template-columns: 1fr; } }
#appendix-c .role-pane { background: white; border-radius: 12px; padding: 1rem; box-shadow: 0 2px 6px rgba(0,0,0,.06); border: 1px solid var(--border); }
#appendix-c .pane-label { font-size: .8rem; font-weight: 700; margin: 0 0 .8rem; color: var(--heading); }
#appendix-c .role-pyramid { display: flex; flex-direction: column; align-items: center; gap: .35rem; padding: .25rem 0; }
#appendix-c .role-row { display: flex; justify-content: center; }
#appendix-c .role-chip { border-radius: 8px; padding: .45rem 1rem; font-size: .75rem; font-weight: 700; color: white; text-align: center; min-width: 140px; }
#appendix-c table.matrix { width: 100%; border-collapse: collapse; font-size: .69rem; }
#appendix-c table.matrix th, #appendix-c table.matrix td { border: 1px solid var(--border); padding: .3rem .45rem; text-align: center; }
#appendix-c table.matrix th { background: var(--surface-2); font-weight: 700; color: var(--text); }
#appendix-c table.matrix td:first-child { text-align: left; font-weight: 600; color: var(--text); }
#appendix-c .allow { background: #dcfce7; color: #15803d; font-weight: 700; border-radius: 4px; padding: .08rem .28rem; font-size: .63rem; display: inline-block; }
#appendix-c .mine { background: #fef3c7; color: #b45309; font-weight: 700; border-radius: 4px; padding: .08rem .28rem; font-size: .63rem; display: inline-block; }
#appendix-c .deny { background: #fee2e2; color: #b91c1c; font-weight: 700; border-radius: 4px; padding: .08rem .28rem; font-size: .63rem; display: inline-block; }
/* info cards */
#appendix-c .info-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: .7rem; }
#appendix-c .info-card { background: white; border-radius: 10px; padding: .9rem 1rem; box-shadow: 0 2px 6px rgba(0,0,0,.06); border: 1px solid var(--border); }
#appendix-c .info-card h3 { font-size: .82rem; font-weight: 700; margin: 0 0 .45rem; color: var(--heading); border: none; padding: 0; }
#appendix-c .info-card ul { list-style: none; font-size: .72rem; color: #4b5563; padding: 0; margin: 0; }
#appendix-c .info-card ul li { padding: .18rem 0; border-bottom: 1px solid #f3f4f6; }
#appendix-c .info-card ul li:last-child { border-bottom: none; }
#appendix-c .info-card ul li::before { content: "→ "; color: #9ca3af; }
</style>
<style>
/* ─── ERD Embed ─── */
#erd-embed {
--erd-bg: #f5f7fb;
--erd-surface: #ffffff;
--erd-surface-hover: #f0f2f7;
--erd-border: #e2e4ed;
--erd-border-highlight: #b0b6d0;
--erd-text: #374151;
--erd-text-muted: #9ca3af;
--erd-text-bright: #111827;
--erd-pk: #b45309;
--erd-pk-bg: rgba(180, 83, 9, 0.08);
--erd-fk: #1d4ed8;
--erd-fk-bg: rgba(29, 78, 216, 0.08);
--erd-col: #4b5563;
--erd-type: #9ca3af;
--erd-line-color: #94a3b8;
--erd-line-highlight: #2563eb;
--erd-shadow: 0 2px 12px rgba(0,0,0,0.08);
--erd-shadow-lg: 0 4px 24px rgba(0,0,0,0.14);
--erd-radius: 10px;
position: relative;
height: 720px;
overflow: hidden;
background: var(--erd-bg);
color: var(--erd-text);
font-family: 'DM Sans', sans-serif;
border-radius: 12px;
margin-top: 16px;
}
#erd-embed #toolbar {
position: absolute;
top: 0; left: 0; right: 0;
height: 52px;
background: var(--erd-surface);
border-bottom: 1px solid var(--erd-border);
display: flex;
align-items: center;
padding: 0 20px;
z-index: 10;
gap: 16px;
border-radius: 0;
}
#erd-embed #toolbar h1 {
font-family: 'IBM Plex Mono', monospace;
font-size: 14px;
font-weight: 600;
color: var(--erd-text-bright);
letter-spacing: -0.02em;
margin: 0;
padding: 0;
border: none;
}
#erd-embed #toolbar .separator { width: 1px; height: 24px; background: var(--erd-border); }
#erd-embed #toolbar .stat { font-family: 'IBM Plex Mono', monospace; font-size: 11px; color: var(--erd-text-muted); margin: 0; }
#erd-embed #toolbar .stat span { color: var(--erd-text); font-weight: 600; }
#erd-embed .toolbar-actions { margin-left: auto; display: flex; gap: 8px; }
#erd-embed .toolbar-btn {
background: var(--erd-border);
border: none;
color: var(--erd-text);
font-family: 'DM Sans', sans-serif;
font-size: 12px;
padding: 6px 14px;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s;
}
#erd-embed .toolbar-btn:hover { background: var(--erd-border-highlight); color: var(--erd-text-bright); }
#erd-embed #canvas-container {
position: absolute;
top: 52px; left: 0; right: 0; bottom: 0;
overflow: hidden;
cursor: grab;
}
#erd-embed #canvas-container:active { cursor: grabbing; }
#erd-embed #canvas { position: absolute; transform-origin: 0 0; }
#erd-embed svg#lines { position: absolute; top: 0; left: 0; pointer-events: none; overflow: visible; }
#erd-embed .table-node {
position: absolute;
background: var(--erd-surface);
border: 1px solid var(--erd-border);
border-radius: var(--erd-radius);
min-width: 240px;
box-shadow: var(--erd-shadow);
cursor: move;
user-select: none;
transition: box-shadow 0.2s, border-color 0.2s;
}
#erd-embed .table-node:hover { border-color: var(--erd-border-highlight); box-shadow: var(--erd-shadow-lg); z-index: 10; }
#erd-embed .table-node.dragging { z-index: 100; border-color: var(--erd-fk); box-shadow: 0 0 0 1px var(--erd-fk), var(--erd-shadow-lg); }
#erd-embed .table-header { padding: 10px 14px; border-bottom: 1px solid var(--erd-border); display: flex; align-items: center; gap: 8px; }
#erd-embed .table-icon { width: 20px; height: 20px; border-radius: 5px; display: flex; align-items: center; justify-content: center; font-size: 10px; font-weight: 700; flex-shrink: 0; }
#erd-embed .table-header .table-name { font-family: 'IBM Plex Mono', monospace; font-size: 13px; font-weight: 600; color: var(--erd-text-bright); letter-spacing: -0.01em; }
#erd-embed .table-body { padding: 4px 0; }
#erd-embed .column-row { display: flex; align-items: center; padding: 3px 14px; gap: 8px; font-family: 'IBM Plex Mono', monospace; font-size: 11.5px; line-height: 1.6; transition: background 0.1s; margin: 0; }
#erd-embed .column-row:hover { background: var(--erd-surface-hover); }
#erd-embed .col-badge { font-size: 8px; font-weight: 700; padding: 1px 4px; border-radius: 3px; letter-spacing: 0.05em; min-width: 20px; text-align: center; flex-shrink: 0; }
#erd-embed .col-badge.pk { background: var(--erd-pk-bg); color: var(--erd-pk); }
#erd-embed .col-badge.fk { background: var(--erd-fk-bg); color: var(--erd-fk); }
#erd-embed .col-badge.empty { min-width: 20px; }
#erd-embed .col-name { color: var(--erd-col); flex: 1; }
#erd-embed .col-name.pk-name { color: var(--erd-pk); }
#erd-embed .col-name.fk-name { color: var(--erd-fk); }
#erd-embed .col-type { color: var(--erd-type); font-size: 10px; text-align: right; }
#erd-embed #legend {
position: absolute;
bottom: 16px; left: 16px;
background: var(--erd-surface);
border: 1px solid var(--erd-border);
border-radius: var(--erd-radius);
padding: 12px 16px;
z-index: 10;
font-size: 11px;
display: flex;
gap: 16px;
}
#erd-embed .legend-item { display: flex; align-items: center; gap: 6px; color: var(--erd-text-muted); margin: 0; }
#erd-embed .legend-dot { width: 8px; height: 8px; border-radius: 2px; }
#erd-embed #zoom-controls { position: absolute; bottom: 16px; right: 16px; display: flex; gap: 4px; z-index: 10; }
#erd-embed #zoom-controls button {
width: 36px; height: 36px;
background: var(--erd-surface);
border: 1px solid var(--erd-border);
border-radius: 8px;
color: var(--erd-text);
font-size: 16px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
#erd-embed #zoom-controls button:hover { background: var(--erd-surface-hover); border-color: var(--erd-border-highlight); }
#erd-embed #minimap {
position: absolute;
bottom: 60px; right: 16px;
width: 180px; height: 120px;
background: var(--erd-surface);
border: 1px solid var(--erd-border);
border-radius: 8px;
z-index: 10;
overflow: hidden;
}
#erd-embed #minimap canvas { width: 100%; height: 100%; }
#erd-embed .rel-line { fill: none; stroke: var(--erd-line-color); stroke-width: 1.5; opacity: 0.5; transition: opacity 0.2s, stroke 0.2s; }
#erd-embed .rel-line.highlighted { stroke: var(--erd-line-highlight); opacity: 1; stroke-width: 2; }
#erd-embed .rel-marker { fill: var(--erd-line-color); transition: fill 0.2s; }
#erd-embed .rel-marker.highlighted { fill: var(--erd-line-highlight); }
#erd-embed .rel-label { font-family: 'IBM Plex Mono', monospace; font-size: 9px; fill: var(--erd-text-muted); pointer-events: none; }
</style>
</head>
<body>
<div class="page">
<aside class="sidebar">
<div class="brand">WIN Developer Docs</div>
<a href="#desc">1. Project Description</a>
<a href="#arch">2. Architecture</a>
<a href="#flow">3. Data Flow</a>
<a href="#hosting">4. Recommended Hosting</a>
<a href="#install">5. Application Installation</a>
<a href="#auth">6. Authentication &amp; Authorization</a>
<a href="#backup">7. Database Backup</a>
<a href="#env">8. Environment Variables</a>
<a href="#partner">9. Partner Statement</a>
<a href="#walk">10. Installation Walkthrough</a>
<a href="#analysis">11. Performance &amp; UX Analysis</a>
<a href="#improve">12. Known Liabilities &amp; Improvements</a>
<a href="#sustain">13. Sustainability Considerations</a>
<a href="#appendix-a">Appendix A ERD</a>
<a href="#appendix-b">Appendix B Repository Structure</a>
<a href="#appendix-c">Appendix C Architecture Overview</a>
</aside>
<main class="content">
<div class="hero">
<div class="meta">Developer Documentation</div>
<h1>WIN Student Goal Tracker</h1>
<p>April 2026</p>
</div>
<section id="desc">
<h2>1. Project Description</h2>
<p>
The WIN Student Goal Tracker is a web-based case management system designed to support
organizations working with adults with special needs. The platform enables staff to track
student goals, document services, and log critical incidents while maintaining strong privacy,
auditability, and compliance with FERPA, IDEA, and FAPE.
</p>
<p>
The system is designed with sustainability in mind, ensuring that future developers can
easily maintain, extend, and redeploy the application.
</p>
</section>
<section id="arch">
<h2>2. Architecture</h2>
<h3>Technology Stack &amp; Infrastructure</h3>
<div class="stack">
<div class="card">
<strong>Frontend</strong>
Angular 20
</div>
<div class="card">
<strong>Backend</strong>
.NET 9.0 (C#) with Dapper ORM
</div>
<div class="card">
<strong>Authentication</strong>
JWT + Refresh Tokens
</div>
<div class="card">
<strong>Database</strong>
MySQL
</div>
<div class="card">
<strong>Infrastructure</strong>
Docker + VPS + Traefik
</div>
</div>
<h3>Architecture Description</h3>
<ol>
<li>Presentation Layer (Angular)</li>
<li>Application Layer (.NET API)</li>
<li>Data Layer (MySQL)</li>
</ol>
<p>Traefik manages routing and HTTPS traffic.</p>
</section>
<section id="flow">
<h2>3. Data Flow</h2>
<h3>Create Goal</h3>
<div class="flow">User → Angular → API → Validation → Database → Response → UI</div>
<h3>View Dashboard</h3>
<div class="flow">User → Angular → API → Database → Response → UI</div>
</section>
<section id="hosting">
<h2>4. Recommended Hosting</h2>
<ul>
<li>Frontend: GitHub Pages, Netlify</li>
<li>Backend: Render, Railway</li>
<li>Database: PlanetScale, Railway MySQL</li>
<li>Alternative: VPS with Docker</li>
</ul>
</section>
<section id="install">
<h2>5. Application Installation</h2>
<h3>Prerequisites</h3>
<p>Node.js, .NET 9 SDK, MySQL, Docker</p>
<h3>Frontend</h3>
<pre><code>cd frontend
npm install
npm start</code></pre>
<h3>Backend</h3>
<pre><code>cd backend
dotnet restore
dotnet run</code></pre>
<h3>Database</h3>
<p>Create DB, import schema, update <code>.env</code>.</p>
<h3>Docker</h3>
<pre><code>docker-compose up --build</code></pre>
</section>
<section id="auth">
<h2>6. Authentication &amp; Authorization</h2>
<p>JWT-based authentication with refresh tokens.</p>
<p><strong>Key files:</strong> AuthController.cs, JwtService.cs, middleware</p>
</section>
<section id="backup">
<h2>7. Database Backup</h2>
<h3>Backup</h3>
<pre><code>mysqldump -u username -p dbname &gt; backup.sql</code></pre>
<h3>Restore</h3>
<pre><code>mysql -u username -p dbname &lt; backup.sql</code></pre>
</section>
<section id="env">
<h2>8. Environment Variables</h2>
<pre><code>DB_CONNECTION=...
JWT_SECRET=...
JWT_EXPIRATION=3600</code></pre>
<p>Ensure <code>.env</code> is in <code>.gitignore</code>.</p>
</section>
<section id="partner">
<h2>9. Partner Statement</h2>
<p>The partner has reviewed the developer documentation and understands the system.</p>
<p><strong>Date:</strong> April 2026</p>
</section>
<section id="walk">
<h2>10. Installation Walkthrough Statement</h2>
<p>A walkthrough was conducted with the partner, who successfully followed the documentation.</p>
<p><strong>Date:</strong> April 2026</p>
</section>
<section id="analysis">
<h2>11. Performance &amp; UX Analysis</h2>
<h3>Lighthouse Results</h3>
<ul>
<li>Mobile Performance: 100</li>
<li>Desktop Performance: 100</li>
<li>Accessibility: 100</li>
<li>Best Practices: 100</li>
<li>SEO: 100</li>
</ul>
<h3>Form Factor Analysis</h3>
<ul>
<li>Mobile Portrait: Fully responsive and functional</li>
<li>Mobile Landscape: Improved readability and layout</li>
<li>Desktop: Optimal user experience</li>
</ul>
<h3>UX Observations</h3>
<p>Clean navigation with consistent user workflows across devices.</p>
<span class="pill">Responsive</span>
<span class="pill">Accessible</span>
<span class="pill">High Performance</span>
<span class="pill">SEO Ready</span>
</section>
<section id="improve">
<h2>12. Known Liabilities &amp; Improvements</h2>
<p><strong>Issues:</strong> Potential performance scaling and mobile optimization opportunities</p>
<p><strong>Improvements:</strong> Lazy loading, bundle optimization, responsive enhancements</p>
</section>
<section id="sustain">
<h2>13. Sustainability Considerations</h2>
<p>Docker deployment, free-tier hosting, and modular design support long-term maintainability.</p>
</section>
<section id="appendix-a">
<h2>Appendix A ERD</h2>
<p>
The system includes a structured relational database supporting users, roles, permissions,
programs, students, goals, progress tracking, and incident logging. The interactive diagram
below shows all 17 tables and their relationships. Drag tables to rearrange, scroll to zoom,
and hover a table to highlight its connections.
</p>
<a href="erd.html" class="arch-link">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
Open full-page ERD
</a>
<div id="erd-embed">
<div id="toolbar">
<h1>⬡ ERD</h1>
<div class="separator"></div>
<div class="stat">Tables <span id="table-count">17</span></div>
<div class="separator"></div>
<div class="stat">Relations <span id="rel-count">0</span></div>
<div class="toolbar-actions">
<button class="toolbar-btn" onclick="resetView()">Reset View</button>
<button class="toolbar-btn" onclick="toggleLabels()">Toggle Labels</button>
</div>
</div>
<div id="canvas-container">
<div id="canvas">
<svg id="lines" width="6000" height="4000"></svg>
</div>
</div>
<div id="legend">
<div class="legend-item"><div class="legend-dot" style="background:#b45309"></div> Primary Key</div>
<div class="legend-item"><div class="legend-dot" style="background:#1d4ed8"></div> Foreign Key</div>
<div class="legend-item">
<svg width="30" height="10"><line x1="0" y1="5" x2="30" y2="5" stroke="#94a3b8" stroke-width="1.5"/></svg>
Relationship
</div>
</div>
<div id="zoom-controls">
<button onclick="zoomIn()">+</button>
<button onclick="zoomOut()"></button>
<button onclick="resetView()" style="font-size:12px"></button>
</div>
<div id="minimap"><canvas id="minimap-canvas"></canvas></div>
</div>
</section>
<section id="appendix-b">
<h2>Appendix B Repository Structure</h2>
<pre><code>WinStudentGoalTracker/
├── api/ .NET 9 backend — controllers, services, repositories, Dockerfile
├── db/ MySQL schema and stored procedures; docker-init scripts for container startup
├── docs/ HTML documentation (technical spec, user manual, ERD, architecture, API)
├── ui/ Angular 20 frontend SPA
├── prototype/ Early role-assignment prototypes (not production code)
├── docker-compose.yml Orchestrates API, Angular/Nginx, MySQL, and Traefik containers
└── WinStudentGoalTracker.sln Visual Studio solution file</code></pre>
</section>
<section id="appendix-c">
<h2>Appendix C Architecture Overview</h2>
<p>High-level diagrams of the system tiers, two-phase auth flow, data model, role hierarchy, deployment, and key design decisions.</p>
<a href="architecture.html" class="arch-link">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
Open full-page Architecture Overview
</a>
<!-- ── 1. System Architecture ── -->
<div class="asec-title"><span>🏗</span> System Architecture</div>
<div class="arch-diagram">
<div class="tier tier--client">
<div class="tier-label">Client</div>
<div class="tier-body">
<div class="box box--blue">
<div class="box-title">Angular SPA <span class="badge badge--blue">v20</span></div>
<ul>
<li>TypeScript 5.8 + SCSS</li>
<li>Angular Signals (state)</li>
<li>Lazy-loaded routes</li>
<li>JWT auth interceptor</li>
<li>Proactive token refresh</li>
</ul>
</div>
<div class="box box--blue">
<div class="box-title">Desktop Layout</div>
<ul>
<li>Home / Workspace</li>
<li>Student detail view</li>
<li>Goal &amp; Benchmark modals</li>
<li>Progress event modals</li>
<li>Report generation UI</li>
<li>Admin panel</li>
</ul>
</div>
<div class="box box--blue">
<div class="box-title">Mobile Layout</div>
<ul>
<li>Student card list</li>
<li>Add progress event</li>
<li>Toggle benchmark</li>
</ul>
</div>
<div class="box box--blue">
<div class="box-title">Shared Services</div>
<ul>
<li>AuthService (signals)</li>
<li>ApiService (HTTP)</li>
<li>StudentService</li>
<li>AdminService</li>
<li>ReportPromptService</li>
<li>PlatformService</li>
</ul>
</div>
</div>
</div>
<div class="tier-arrow">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12l7 7 7-7"/></svg>
<span>HTTPS / REST + JWT Bearer — win.opelly.me → winapi.opelly.me</span>
</div>
<div class="tier tier--proxy">
<div class="tier-label">Proxy</div>
<div class="tier-body">
<div class="box box--slate">
<div class="box-title">Traefik Reverse Proxy</div>
<ul>
<li>win.opelly.me → Angular (Nginx :8006)</li>
<li>winapi.opelly.me → API (:8005)</li>
<li>Let's Encrypt TLS</li>
<li>Gzip compression</li>
<li>Security headers</li>
</ul>
</div>
</div>
</div>
<div class="tier-arrow">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12l7 7 7-7"/></svg>
<span>HTTP (internal Docker network)</span>
</div>
<div class="tier tier--api">
<div class="tier-label">API</div>
<div class="tier-body">
<div class="box box--green">
<div class="box-title">ASP.NET Core 9 <span class="badge badge--green">C#</span></div>
<ul>
<li>JWT authentication middleware</li>
<li>Swagger / OpenAPI</li>
<li>CORS policy</li>
<li>Dependency injection</li>
</ul>
</div>
<div class="box box--green">
<div class="box-title">Controllers</div>
<ul>
<li>/api/Auth — login &amp; tokens</li>
<li>/api/Student — CRUD</li>
<li>/api/Admin — district/users</li>
<li>/api/ReportPrompt — prompts</li>
</ul>
</div>
<div class="box box--green">
<div class="box-title">Services</div>
<ul>
<li>TokenService (JWT)</li>
<li>PermissionService</li>
<li>PasswordHasher (PBKDF2)</li>
<li>RecommendationService</li>
<li>TranscriptionService</li>
<li>ProgressReportBuilder</li>
</ul>
</div>
<div class="box box--green">
<div class="box-title">Repositories <span class="badge badge--green">Dapper</span></div>
<ul>
<li>StudentRepository</li>
<li>UserRepository</li>
<li>AuthRepository</li>
<li>AdminRepository</li>
<li>ReportPromptRepository</li>
</ul>
</div>
</div>
</div>
<div class="tier-arrow">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12l7 7 7-7"/></svg>
<span>Stored procedures via Dapper — TCP :3309 (Docker internal)</span>
</div>
<div class="tier tier--data">
<div class="tier-label">Data</div>
<div class="tier-body">
<div class="box box--amber">
<div class="box-title">MySQL 8 <span class="badge badge--amber">Docker</span></div>
<ul>
<li>winstudentgoaltracker DB</li>
<li>17 tables</li>
<li>51 stored procedures</li>
<li>Initialised from db/docker-init/</li>
</ul>
</div>
<div class="box box--amber">
<div class="box-title">Core Tables</div>
<ul>
<li>user, school_district, program</li>
<li>user_program (roles junction)</li>
<li>student, user_student</li>
<li>goal (recursive), benchmark</li>
<li>progress_event + junction</li>
<li>refresh_token</li>
</ul>
</div>
<div class="box box--amber">
<div class="box-title">Procedure Groups</div>
<ul>
<li>sp_Student_* (CRUD)</li>
<li>sp_Goal_* / sp_Benchmark_*</li>
<li>sp_ProgressEvent_*</li>
<li>sp_RefreshToken_*</li>
<li>sp_Program_* / sp_User_*</li>
<li>sp_ProgressReport_*</li>
</ul>
</div>
</div>
</div>
<div class="tier-arrow">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12l7 7 7-7"/></svg>
<span>Outbound HTTP from API tier only</span>
</div>
<div class="tier tier--ext">
<div class="tier-label">External</div>
<div class="tier-body">
<div class="box box--purple">
<div class="box-title">Ollama LLM <span class="badge badge--purple">AI</span></div>
<ul>
<li>llm.opelly.me</li>
<li>Model: gemma4:e2b</li>
<li>Benchmark recommendations</li>
<li>5-min timeout</li>
</ul>
</div>
<div class="box box--purple">
<div class="box-title">STT Service <span class="badge badge--purple">AI</span></div>
<ul>
<li>stt.opelly.me</li>
<li>Speech-to-text transcription</li>
<li>Progress event dictation</li>
<li>5-min timeout</li>
</ul>
</div>
</div>
</div>
</div><!-- /arch-diagram -->
<!-- ── 2. Auth Flow ── -->
<div class="asec-title"><span>🔐</span> Two-Phase Authentication Flow</div>
<div style="overflow-x:auto; padding-bottom:.5rem;">
<div class="aflow" style="min-width:900px;">
<div class="aflow-step">
<div class="aflow-num">1</div>
<div class="aflow-bubble">
<strong>User Submits Credentials</strong>
email + password<br>
<span class="note">POST /api/Auth/Login</span>
</div>
</div>
<div class="aflow-connector">
<svg width="28" height="16" viewBox="0 0 28 16" fill="none" stroke="currentColor" stroke-width="2"><path d="M0 8h24M18 2l6 6-6 6"/></svg>
</div>
<div class="aflow-step">
<div class="aflow-num">2</div>
<div class="aflow-bubble">
<strong>Phase 1 Response</strong>
Session token (5 min)<br>+ program list<br>
<span class="note">Stored in localStorage</span>
</div>
</div>
<div class="aflow-connector">
<svg width="28" height="16" viewBox="0 0 28 16" fill="none" stroke="currentColor" stroke-width="2"><path d="M0 8h24M18 2l6 6-6 6"/></svg>
</div>
<div class="aflow-step">
<div class="aflow-num">3</div>
<div class="aflow-bubble">
<strong>User Selects Program</strong>
session token + program ID<br>
<span class="note">POST /api/Auth/SelectProgram</span>
</div>
</div>
<div class="aflow-connector">
<svg width="28" height="16" viewBox="0 0 28 16" fill="none" stroke="currentColor" stroke-width="2"><path d="M0 8h24M18 2l6 6-6 6"/></svg>
</div>
<div class="aflow-step">
<div class="aflow-num">4</div>
<div class="aflow-bubble">
<strong>Phase 2 Response</strong>
JWT (1 min) +<br>Refresh token (30 days)<br>
<span class="note">JWT includes role + program_id</span>
</div>
</div>
<div class="aflow-connector">
<svg width="28" height="16" viewBox="0 0 28 16" fill="none" stroke="currentColor" stroke-width="2"><path d="M0 8h24M18 2l6 6-6 6"/></svg>
</div>
<div class="aflow-step">
<div class="aflow-num">5</div>
<div class="aflow-bubble">
<strong>Authenticated Requests</strong>
Bearer JWT on all API calls<br>
<span class="note">Auth interceptor injects token</span>
</div>
</div>
<div class="aflow-connector">
<svg width="28" height="16" viewBox="0 0 28 16" fill="none" stroke="currentColor" stroke-width="2"><path d="M0 8h24M18 2l6 6-6 6"/></svg>
</div>
<div class="aflow-step">
<div class="aflow-num">6</div>
<div class="aflow-bubble">
<strong>Proactive Refresh</strong>
10 s before JWT expiry:<br>POST /api/Auth/RefreshToken<br>
<span class="note">Rotated refresh token returned</span>
</div>
</div>
</div>
</div>
<div class="card" style="margin-top:.75rem;font-size:.8rem;color:#4b5563;">
<strong>Token storage:</strong> <code>auth_jwt</code>, <code>auth_refresh_token</code>, <code>auth_session_token</code> — all in <code>localStorage</code>.
&nbsp;|&nbsp;
<strong>401 interceptor:</strong> attempts one silent refresh; if that fails, redirects to <code>/login</code>.
&nbsp;|&nbsp;
<strong>Refresh token rotation:</strong> each use replaces the old token (tracked via <code>replaced_by_token_id</code>).
</div>
<!-- ── 3. Data Model ── -->
<div class="asec-title"><span>🗄</span> Core Data Model</div>
<div class="entity-grid">
<div class="entity">
<div class="entity-header" style="background:#64748b;">school_district</div>
<div class="entity-fields">
<div><span class="pk">PK</span> id_school_district</div>
<div>name</div>
<div>contact_email</div>
<div><span class="field-note">Top-level tenant</span></div>
</div>
</div>
<div class="entity">
<div class="entity-header" style="background:#2563eb;">program</div>
<div class="entity-fields">
<div><span class="pk">PK</span> id_program</div>
<div><span class="fk">FK</span> id_school_district</div>
<div>name, description</div>
<div><span class="field-note">Scope boundary for data</span></div>
</div>
</div>
<div class="entity">
<div class="entity-header" style="background:#0d9488;">user</div>
<div class="entity-fields">
<div><span class="pk">PK</span> id_user</div>
<div>email, name</div>
<div>password_hash, password_salt</div>
<div>locked_until</div>
</div>
</div>
<div class="entity">
<div class="entity-header" style="background:#7c3aed;">user_program <span style="font-size:.6rem;opacity:.8;">(junction)</span></div>
<div class="entity-fields">
<div><span class="pk">PK</span> id_user_program</div>
<div><span class="fk">FK</span> id_user</div>
<div><span class="fk">FK</span> id_program</div>
<div><span class="fk">FK</span> id_role</div>
<div>is_primary, status</div>
</div>
</div>
<div class="entity">
<div class="entity-header" style="background:#d97706;">student</div>
<div class="entity-fields">
<div><span class="pk">PK</span> id_student</div>
<div><span class="fk">FK</span> id_program</div>
<div>identifier, program_year</div>
<div>enrollment_date</div>
<div>next_iep_date</div>
</div>
</div>
<div class="entity">
<div class="entity-header" style="background:#7c3aed;">user_student <span style="font-size:.6rem;opacity:.8;">(junction)</span></div>
<div class="entity-fields">
<div><span class="pk">PK</span> id_user_student</div>
<div><span class="fk">FK</span> id_user</div>
<div><span class="fk">FK</span> id_student</div>
<div>is_primary</div>
</div>
</div>
<div class="entity">
<div class="entity-header" style="background:#16a34a;">goal</div>
<div class="entity-fields">
<div><span class="pk">PK</span> id_goal</div>
<div><span class="fk">FK</span> id_goal_parent <span class="field-note">(self-ref)</span></div>
<div><span class="fk">FK</span> id_student</div>
<div>description, category</div>
<div>baseline, target_completion_date</div>
<div>close_date, achieved</div>
</div>
</div>
<div class="entity">
<div class="entity-header" style="background:#0d9488;">benchmark</div>
<div class="entity-fields">
<div><span class="pk">PK</span> id_benchmark</div>
<div><span class="fk">FK</span> id_goal</div>
<div>benchmark (description)</div>
<div>short_name</div>
</div>
</div>
<div class="entity">
<div class="entity-header" style="background:#e11d48;">progress_event</div>
<div class="entity-fields">
<div><span class="pk">PK</span> id_progress_event</div>
<div><span class="fk">FK</span> id_goal</div>
<div><span class="fk">FK</span> id_user_created</div>
<div>content (rich text)</div>
<div>is_sensitive</div>
</div>
</div>
<div class="entity">
<div class="entity-header" style="background:#7c3aed;">progress_event_benchmark <span style="font-size:.6rem;opacity:.8;">(junction)</span></div>
<div class="entity-fields">
<div><span class="fk">FK</span> id_progress_event</div>
<div><span class="fk">FK</span> id_benchmark</div>
<div><span class="field-note">Links events to benchmarks</span></div>
</div>
</div>
<div class="entity">
<div class="entity-header" style="background:#64748b;">refresh_token</div>
<div class="entity-fields">
<div><span class="pk">PK</span> id_refresh_token</div>
<div><span class="fk">FK</span> id_user, id_program</div>
<div>token_hash, token_salt</div>
<div>expires_at, revoked_at</div>
<div>replaced_by_token_id</div>
</div>
</div>
<div class="entity">
<div class="entity-header" style="background:#4f46e5;">report_prompt</div>
<div class="entity-fields">
<div><span class="pk">PK</span> id_report_prompt</div>
<div><span class="fk">FK</span> program_id</div>
<div>report_name</div>
<div>prompt_template (LLM)</div>
</div>
</div>
</div>
<div class="rel-summary">
<strong>Key relationships:</strong>
&nbsp;school_district <strong>1→N</strong> program &nbsp;·&nbsp;
program <strong>1→N</strong> student &nbsp;·&nbsp;
student <strong>1→N</strong> goal (recursive parent/child) &nbsp;·&nbsp;
goal <strong>1→N</strong> benchmark &nbsp;·&nbsp;
goal <strong>1→N</strong> progress_event &nbsp;·&nbsp;
progress_event <strong>M↔N</strong> benchmark (junction) &nbsp;·&nbsp;
user <strong>M↔N</strong> program (via user_program + role) &nbsp;·&nbsp;
user <strong>M↔N</strong> student (via user_student)
</div>
<!-- ── 4. Role Hierarchy ── -->
<div class="asec-title"><span>👥</span> Role Hierarchy &amp; Permissions</div>
<div class="role-and-matrix">
<div class="role-pane">
<div class="pane-label">Role Tiers (highest → lowest)</div>
<div class="role-pyramid">
<div class="role-row"><div class="role-chip" style="background:#0f172a;min-width:280px;">super_admin &nbsp;·&nbsp; <span style="font-weight:400;font-size:.7rem;">full platform access</span></div></div>
<div class="role-row"><div class="role-chip" style="background:#1d4ed8;min-width:240px;">district_admin &nbsp;·&nbsp; <span style="font-weight:400;font-size:.7rem;">manages programs</span></div></div>
<div class="role-row"><div class="role-chip" style="background:#2563eb;min-width:200px;">program_admin &nbsp;·&nbsp; <span style="font-weight:400;font-size:.7rem;">manages users</span></div></div>
<div class="role-row"><div class="role-chip" style="background:#16a34a;min-width:160px;">teacher &nbsp;·&nbsp; <span style="font-weight:400;font-size:.7rem;">full student CRUD</span></div></div>
<div class="role-row"><div class="role-chip" style="background:#d97706;min-width:120px;">paraeducator &nbsp;·&nbsp; <span style="font-weight:400;font-size:.7rem;">log events</span></div></div>
</div>
</div>
<div class="role-pane" style="overflow-x:auto;">
<div class="pane-label">Sample Permission Matrix</div>
<table class="matrix">
<thead>
<tr>
<th>Entity / Action</th>
<th>super_admin</th>
<th>district_admin</th>
<th>program_admin</th>
<th>teacher</th>
<th>paraeducator</th>
</tr>
</thead>
<tbody>
<tr><td>Student — Create</td><td><span class="allow">Allow</span></td><td><span class="allow">Allow</span></td><td><span class="allow">Allow</span></td><td><span class="allow">Allow</span></td><td><span class="deny">Deny</span></td></tr>
<tr><td>Student — Update</td><td><span class="allow">Allow</span></td><td><span class="allow">Allow</span></td><td><span class="allow">Allow</span></td><td><span class="mine">Mine</span></td><td><span class="deny">Deny</span></td></tr>
<tr><td>Goal — Create</td><td><span class="allow">Allow</span></td><td><span class="allow">Allow</span></td><td><span class="allow">Allow</span></td><td><span class="mine">Mine</span></td><td><span class="deny">Deny</span></td></tr>
<tr><td>Goal — Update</td><td><span class="allow">Allow</span></td><td><span class="allow">Allow</span></td><td><span class="allow">Allow</span></td><td><span class="mine">Mine</span></td><td><span class="deny">Deny</span></td></tr>
<tr><td>ProgressEvent — Create</td><td><span class="allow">Allow</span></td><td><span class="allow">Allow</span></td><td><span class="allow">Allow</span></td><td><span class="allow">Allow</span></td><td><span class="mine">Mine</span></td></tr>
<tr><td>ProgressEvent — Delete</td><td><span class="allow">Allow</span></td><td><span class="allow">Allow</span></td><td><span class="allow">Allow</span></td><td><span class="mine">Mine</span></td><td><span class="deny">Deny</span></td></tr>
<tr><td>Program — Create</td><td><span class="allow">Allow</span></td><td><span class="mine">Mine</span></td><td><span class="deny">Deny</span></td><td><span class="deny">Deny</span></td><td><span class="deny">Deny</span></td></tr>
<tr><td>User — Create</td><td><span class="allow">Allow</span></td><td><span class="mine">Mine</span></td><td><span class="mine">Mine</span></td><td><span class="deny">Deny</span></td><td><span class="deny">Deny</span></td></tr>
</tbody>
</table>
<div style="font-size:.63rem;color:#9ca3af;margin-top:.5rem;">
<span class="allow">Allow</span> = full access &nbsp;
<span class="mine">Mine</span> = own records only &nbsp;
<span class="deny">Deny</span> = not permitted
</div>
</div>
</div>
<!-- ── 5. Deployment ── -->
<div class="asec-title"><span>🐳</span> Deployment &amp; Infrastructure</div>
<div class="info-cards">
<div class="info-card">
<h3>Docker Containers</h3>
<ul>
<li>mysql:8 (port 3309)</li>
<li>.NET API (port 8005)</li>
<li>Angular / Nginx (port 8006)</li>
<li>Traefik reverse proxy</li>
</ul>
</div>
<div class="info-card">
<h3>Docker Networks</h3>
<ul>
<li>web — external HTTPS routing</li>
<li>backend — internal service mesh</li>
<li>API depends on MySQL healthcheck</li>
<li>UI depends on API (build time)</li>
</ul>
</div>
<div class="info-card">
<h3>Environment Config</h3>
<ul>
<li>MYSQL_ROOT_PASSWORD</li>
<li>MYSQL_DATABASE / USER / PASSWORD</li>
<li>JWT_KEY (signing secret)</li>
<li>MYSQL_HOST / PORT</li>
</ul>
</div>
<div class="info-card">
<h3>Frontend Environments</h3>
<ul>
<li>Dev: localhost:5000 (API)</li>
<li>Prod: winapi.opelly.me (API)</li>
<li>Served by Nginx in production</li>
<li>Angular CLI dev server locally</li>
</ul>
</div>
<div class="info-card">
<h3>DB Initialisation</h3>
<ul>
<li>Schemas from db/docker-init/</li>
<li>51 stored procedures</li>
<li>Health check gates API startup</li>
<li>No ORM migrations — raw SQL</li>
</ul>
</div>
<div class="info-card">
<h3>AI / External Services</h3>
<ul>
<li>Ollama LLM — llm.opelly.me</li>
<li>STT service — stt.opelly.me</li>
<li>Both called from API tier only</li>
<li>5-minute timeout on both</li>
</ul>
</div>
</div>
<!-- ── 6. Key Design Decisions ── -->
<div class="asec-title"><span>⚙️</span> Key Design Decisions</div>
<div class="info-cards">
<div class="info-card">
<h3>Two-Phase JWT Auth</h3>
<ul>
<li>Phase 1: credentials → session token</li>
<li>Phase 2: program select → scoped JWT</li>
<li>Prevents data leakage before program chosen</li>
<li>JWT scoped to one program at a time</li>
</ul>
</div>
<div class="info-card">
<h3>Refresh Token Rotation</h3>
<ul>
<li>New token issued on each refresh</li>
<li>Old token replaced (not deleted)</li>
<li>Genealogy tracked via replaced_by</li>
<li>Prevents replay attacks</li>
</ul>
</div>
<div class="info-card">
<h3>Program-Scoped Multi-Tenancy</h3>
<ul>
<li>JWT contains program_id claim</li>
<li>All queries filtered by program</li>
<li>district_admin scoped to district</li>
<li>super_admin bypasses scope</li>
</ul>
</div>
<div class="info-card">
<h3>Dapper + Stored Procedures</h3>
<ul>
<li>No ORM (Entity Framework)</li>
<li>Business logic close to data</li>
<li>Dapper maps rows to C# objects</li>
<li>51 procedures for full coverage</li>
</ul>
</div>
<div class="info-card">
<h3>Angular Signals (not RxJS)</h3>
<ul>
<li>Auth state as reactive signals</li>
<li>Computed signals derive user context</li>
<li>Simpler than Observable chains</li>
<li>Auto-update dependent UI</li>
</ul>
</div>
<div class="info-card">
<h3>Centralised Permission Matrix</h3>
<ul>
<li>Declarative rules per entity + action</li>
<li>Allow / MineOnly / Deny granularity</li>
<li>Evaluated at request time in API</li>
<li>Single source of truth for authz</li>
</ul>
</div>
</div>
</section>
</main>
</div>
<script>
// ─── ERD Schema Data ───
const schema = {
tables: {
user: {
columns: [
{ name: 'id_user', type: 'char(36)', pk: true },
{ name: 'email', type: 'varchar(255)' },
{ name: 'name', type: 'varchar(255)' },
{ name: 'password_hash', type: 'varchar(255)' },
{ name: 'password_salt', type: 'varchar(255)' },
{ name: 'password_updated_at', type: 'timestamp' },
{ name: 'failed_login_attempts', type: 'int' },
{ name: 'locked_until', type: 'timestamp' },
{ name: 'created_at', type: 'timestamp' }
],
color: '#6ea8fe'
},
student: {
columns: [
{ name: 'id_student', type: 'char(36)', pk: true },
{ name: 'id_program', type: 'char(36)', fk: 'program' },
{ name: 'identifier', type: 'varchar(50)' },
{ name: 'program_year', type: 'int' },
{ name: 'enrollment_date', type: 'date' },
{ name: 'next_iep_date', type: 'date' },
{ name: 'created_at', type: 'timestamp' }
],
color: '#66d9a0'
},
goal: {
columns: [
{ name: 'id_goal', type: 'char(36)', pk: true },
{ name: 'id_goal_parent', type: 'char(36)', fk: 'goal' },
{ name: 'id_student', type: 'char(36)', fk: 'student' },
{ name: 'id_user_created', type: 'char(36)', fk: 'user' },
{ name: 'description', type: 'text' },
{ name: 'category', type: 'varchar(255)' },
{ name: 'baseline', type: 'text' },
{ name: 'target_completion_date', type: 'date' },
{ name: 'close_date', type: 'date' },
{ name: 'achieved', type: 'tinyint(1)' },
{ name: 'close_notes', type: 'text' },
{ name: 'created_at', type: 'timestamp' },
{ name: 'updated_at', type: 'timestamp' }
],
color: '#f0b866'
},
benchmark: {
columns: [
{ name: 'id_benchmark', type: 'char(36)', pk: true },
{ name: 'id_goal', type: 'char(36)', fk: 'goal' },
{ name: 'id_user_created', type: 'char(36)', fk: 'user' },
{ name: 'benchmark', type: 'text' },
{ name: 'short_name', type: 'varchar(50)' },
{ name: 'created_at', type: 'datetime' },
{ name: 'updated_at', type: 'datetime' }
],
color: '#f0b866'
},
progress_event: {
columns: [
{ name: 'id_progress_event', type: 'char(36)', pk: true },
{ name: 'id_goal', type: 'char(36)', fk: 'goal' },
{ name: 'id_user_created', type: 'char(36)', fk: 'user' },
{ name: 'content', type: 'text' },
{ name: 'is_sensitive', type: 'tinyint(1)' },
{ name: 'created_at', type: 'timestamp' },
{ name: 'updated_at', type: 'timestamp' }
],
color: '#c792ea'
},
progress_event_benchmark: {
columns: [
{ name: 'id_progress_event_benchmark', type: 'char(36)', pk: true },
{ name: 'id_progress_event', type: 'char(36)', fk: 'progress_event' },
{ name: 'id_benchmark', type: 'char(36)', fk: 'benchmark' },
{ name: 'created_at', type: 'datetime' }
],
color: '#c792ea'
},
progress_report: {
columns: [
{ name: 'id_progress_report', type: 'char(36)', pk: true },
{ name: 'id_student', type: 'char(36)', fk: 'student' },
{ name: 'id_goal', type: 'char(36)', fk: 'goal' },
{ name: 'id_user_created', type: 'char(36)', fk: 'user' },
{ name: 'period', type: 'varchar(10)' },
{ name: 'year', type: 'int' },
{ name: 'summary', type: 'text' },
{ name: 'generated_at', type: 'timestamp' }
],
color: '#c792ea'
},
health_note: {
columns: [
{ name: 'id_health_note', type: 'char(36)', pk: true },
{ name: 'id_student', type: 'char(36)', fk: 'student' },
{ name: 'id_user_created', type: 'char(36)', fk: 'user' },
{ name: 'content', type: 'text' },
{ name: 'created_at', type: 'timestamp' }
],
color: '#66d9a0'
},
program: {
columns: [
{ name: 'id_program', type: 'char(36)', pk: true },
{ name: 'id_school_district', type: 'char(36)', fk: 'school_district' },
{ name: 'name', type: 'varchar(255)' },
{ name: 'description', type: 'text' },
{ name: 'created_at', type: 'timestamp' }
],
color: '#f07178'
},
school_district: {
columns: [
{ name: 'id_school_district', type: 'char(36)', pk: true },
{ name: 'name', type: 'varchar(255)' },
{ name: 'contact_email', type: 'varchar(255)' },
{ name: 'created_at', type: 'timestamp' }
],
color: '#f07178'
},
role: {
columns: [
{ name: 'id_role', type: 'char(36)', pk: true },
{ name: 'name', type: 'varchar(100)' },
{ name: 'internal_name', type: 'varchar(100)' },
{ name: 'description', type: 'text' },
{ name: 'created_at', type: 'timestamp' }
],
color: '#6ea8fe'
},
user_program: {
columns: [
{ name: 'id_user_program', type: 'char(36)', pk: true },
{ name: 'id_user', type: 'char(36)', fk: 'user' },
{ name: 'id_program', type: 'char(36)', fk: 'program' },
{ name: 'id_role', type: 'char(36)', fk: 'role' },
{ name: 'is_primary', type: 'tinyint(1)' },
{ name: 'status', type: 'varchar(20)' },
{ name: 'joined_at', type: 'timestamp' }
],
color: '#6ea8fe'
},
user_student: {
columns: [
{ name: 'id_user_student', type: 'char(36)', pk: true },
{ name: 'id_user', type: 'char(36)', fk: 'user' },
{ name: 'id_student', type: 'char(36)', fk: 'student' },
{ name: 'is_primary', type: 'tinyint(1)' }
],
color: '#6ea8fe'
},
password_history: {
columns: [
{ name: 'id_password_history', type: 'char(36)', pk: true },
{ name: 'id_user', type: 'char(36)', fk: 'user' },
{ name: 'password_hash', type: 'varchar(255)' },
{ name: 'created_at', type: 'timestamp' }
],
color: '#5a5f75'
},
password_reset_token: {
columns: [
{ name: 'id_password_reset_token', type: 'char(36)', pk: true },
{ name: 'id_user', type: 'char(36)', fk: 'user' },
{ name: 'token_hash', type: 'varchar(255)' },
{ name: 'expires_at', type: 'timestamp' },
{ name: 'created_at', type: 'timestamp' },
{ name: 'used_at', type: 'timestamp' },
{ name: 'invalidated_at', type: 'timestamp' },
{ name: 'request_ip', type: 'varchar(45)' }
],
color: '#5a5f75'
},
refresh_token: {
columns: [
{ name: 'id_refresh_token', type: 'char(36)', pk: true },
{ name: 'id_user', type: 'char(36)', fk: 'user' },
{ name: 'id_program', type: 'char(36)', fk: 'program' },
{ name: 'token_hash', type: 'varchar(512)' },
{ name: 'token_salt', type: 'varchar(512)' },
{ name: 'expires_at', type: 'timestamp' },
{ name: 'last_used_at', type: 'timestamp' },
{ name: 'revoked_at', type: 'timestamp' },
{ name: 'device_info', type: 'varchar(255)' },
{ name: 'user_agent', type: 'varchar(512)' },
{ name: 'replaced_by_token_id', type: 'char(36)', fk: 'refresh_token' },
{ name: 'created_at', type: 'timestamp' },
{ name: 'updated_at', type: 'timestamp' }
],
color: '#5a5f75'
},
ReportPrompt: {
columns: [
{ name: 'id_ReportPrompt', type: 'char(36)', pk: true },
{ name: 'prompt', type: 'text' },
{ name: 'reportname', type: 'char(100)' },
{ name: 'id_program', type: 'char(36)', fk: 'program' }
],
color: '#c792ea'
}
}
};
const relationships = [
{ from: 'goal', fromCol: 'id_goal_parent', to: 'goal', toCol: 'id_goal', label: 'parent' },
{ from: 'goal', fromCol: 'id_student', to: 'student', toCol: 'id_student' },
{ from: 'goal', fromCol: 'id_user_created', to: 'user', toCol: 'id_user' },
{ from: 'health_note', fromCol: 'id_student', to: 'student', toCol: 'id_student' },
{ from: 'health_note', fromCol: 'id_user_created', to: 'user', toCol: 'id_user' },
{ from: 'password_history', fromCol: 'id_user', to: 'user', toCol: 'id_user' },
{ from: 'password_reset_token', fromCol: 'id_user', to: 'user', toCol: 'id_user' },
{ from: 'program', fromCol: 'id_school_district', to: 'school_district', toCol: 'id_school_district' },
{ from: 'progress_event', fromCol: 'id_goal', to: 'goal', toCol: 'id_goal' },
{ from: 'progress_event', fromCol: 'id_user_created', to: 'user', toCol: 'id_user' },
{ from: 'progress_event_benchmark', fromCol: 'id_progress_event', to: 'progress_event', toCol: 'id_progress_event' },
{ from: 'progress_event_benchmark', fromCol: 'id_benchmark', to: 'benchmark', toCol: 'id_benchmark' },
{ from: 'progress_report', fromCol: 'id_student', to: 'student', toCol: 'id_student' },
{ from: 'progress_report', fromCol: 'id_goal', to: 'goal', toCol: 'id_goal' },
{ from: 'progress_report', fromCol: 'id_user_created', to: 'user', toCol: 'id_user' },
{ from: 'refresh_token', fromCol: 'id_user', to: 'user', toCol: 'id_user' },
{ from: 'refresh_token', fromCol: 'id_program', to: 'program', toCol: 'id_program' },
{ from: 'refresh_token', fromCol: 'replaced_by_token_id', to: 'refresh_token', toCol: 'id_refresh_token', label: 'replaces' },
{ from: 'benchmark', fromCol: 'id_goal', to: 'goal', toCol: 'id_goal' },
{ from: 'benchmark', fromCol: 'id_user_created', to: 'user', toCol: 'id_user' },
{ from: 'student', fromCol: 'id_program', to: 'program', toCol: 'id_program' },
{ from: 'user_program', fromCol: 'id_user', to: 'user', toCol: 'id_user' },
{ from: 'user_program', fromCol: 'id_program', to: 'program', toCol: 'id_program' },
{ from: 'user_program', fromCol: 'id_role', to: 'role', toCol: 'id_role' },
{ from: 'user_student', fromCol: 'id_user', to: 'user', toCol: 'id_user' },
{ from: 'user_student', fromCol: 'id_student', to: 'student', toCol: 'id_student' },
{ from: 'ReportPrompt', fromCol: 'id_program', to: 'program', toCol: 'id_program' }
];
document.getElementById('rel-count').textContent = relationships.length;
const positions = {
user: { x: 100, y: 300 },
role: { x: 100, y: 50 },
user_program: { x: 380, y: 80 },
user_student: { x: 380, y: 340 },
school_district: { x: 680, y: 50 },
program: { x: 680, y: 260 },
student: { x: 680, y: 500 },
goal: { x: 1050, y: 260 },
benchmark: { x: 1050, y: 50 },
progress_event: { x: 1380, y: 100 },
progress_event_benchmark: { x: 1380, y: 340 },
progress_report: { x: 1050, y: 570 },
health_note: { x: 680, y: 760 },
ReportPrompt: { x: 1380, y: 570 },
password_history: { x: 100, y: 620 },
password_reset_token: { x: 100, y: 800 },
refresh_token: { x: 380, y: 620 }
};
const canvas = document.getElementById('canvas');
const svgLines = document.getElementById('lines');
const container = document.getElementById('canvas-container');
const tableElements = {};
let scale = 0.85;
let panX = 50, panY = 20;
let showLabels = true;
function renderTables() {
for (const [tableName, table] of Object.entries(schema.tables)) {
const pos = positions[tableName] || { x: 0, y: 0 };
const el = document.createElement('div');
el.className = 'table-node';
el.id = `table-${tableName}`;
el.style.left = pos.x + 'px';
el.style.top = pos.y + 'px';
const headerColor = table.color || '#6ea8fe';
let html = `
<div class="table-header">
<div class="table-icon" style="background:${headerColor}22;color:${headerColor}">⬡</div>
<span class="table-name">${tableName}</span>
</div>
<div class="table-body">
`;
for (const col of table.columns) {
const isPk = col.pk;
const isFk = col.fk;
let badge = '<span class="col-badge empty"></span>';
if (isPk) badge = '<span class="col-badge pk">PK</span>';
else if (isFk) badge = '<span class="col-badge fk">FK</span>';
let nameClass = 'col-name';
if (isPk) nameClass += ' pk-name';
else if (isFk) nameClass += ' fk-name';
html += `
<div class="column-row" data-col="${col.name}">
${badge}
<span class="${nameClass}">${col.name}</span>
<span class="col-type">${col.type}</span>
</div>`;
}
html += '</div>';
el.innerHTML = html;
canvas.appendChild(el);
tableElements[tableName] = el;
makeDraggable(el, tableName);
}
}
function getColumnY(tableName, colName) {
const el = tableElements[tableName];
if (!el) return 0;
const row = el.querySelector(`[data-col="${colName}"]`);
if (!row) return el.offsetTop + 20;
return el.offsetTop + row.offsetTop + row.offsetHeight / 2;
}
function drawRelationships() {
svgLines.innerHTML = '';
const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
const marker = document.createElementNS('http://www.w3.org/2000/svg', 'marker');
marker.setAttribute('id', 'arrowhead');
marker.setAttribute('markerWidth', '8');
marker.setAttribute('markerHeight', '6');
marker.setAttribute('refX', '8');
marker.setAttribute('refY', '3');
marker.setAttribute('orient', 'auto');
const poly = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
poly.setAttribute('points', '0 0, 8 3, 0 6');
poly.setAttribute('class', 'rel-marker');
marker.appendChild(poly);
defs.appendChild(marker);
const markerH = document.createElementNS('http://www.w3.org/2000/svg', 'marker');
markerH.setAttribute('id', 'arrowhead-hl');
markerH.setAttribute('markerWidth', '8');
markerH.setAttribute('markerHeight', '6');
markerH.setAttribute('refX', '8');
markerH.setAttribute('refY', '3');
markerH.setAttribute('orient', 'auto');
const polyH = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
polyH.setAttribute('points', '0 0, 8 3, 0 6');
polyH.setAttribute('class', 'rel-marker highlighted');
markerH.appendChild(polyH);
defs.appendChild(markerH);
svgLines.appendChild(defs);
for (const rel of relationships) {
const fromEl = tableElements[rel.from];
const toEl = tableElements[rel.to];
if (!fromEl || !toEl) continue;
const fromY = getColumnY(rel.from, rel.fromCol);
const toY = getColumnY(rel.to, rel.toCol);
const fromLeft = fromEl.offsetLeft;
const fromRight = fromEl.offsetLeft + fromEl.offsetWidth;
const toLeft = toEl.offsetLeft;
const toRight = toEl.offsetLeft + toEl.offsetWidth;
let x1, x2;
if (rel.from === rel.to) {
x1 = fromRight;
x2 = fromRight;
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
const loopSize = 40;
const d = `M ${x1} ${fromY} C ${x1 + loopSize} ${fromY - loopSize}, ${x2 + loopSize} ${toY + loopSize}, ${x2} ${toY}`;
path.setAttribute('d', d);
path.setAttribute('class', 'rel-line');
path.setAttribute('marker-end', 'url(#arrowhead)');
path.dataset.from = rel.from;
path.dataset.to = rel.to;
svgLines.appendChild(path);
if (showLabels && rel.label) {
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
text.setAttribute('x', x1 + loopSize + 4);
text.setAttribute('y', (fromY + toY) / 2);
text.setAttribute('class', 'rel-label');
text.textContent = rel.label;
svgLines.appendChild(text);
}
continue;
}
const fromCX = (fromLeft + fromRight) / 2;
const toCX = (toLeft + toRight) / 2;
if (fromCX < toCX) { x1 = fromRight; x2 = toLeft; }
else { x1 = fromLeft; x2 = toRight; }
const dx = Math.abs(x2 - x1);
const cpOffset = Math.max(40, dx * 0.35);
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
const cp1x = x1 < x2 ? x1 + cpOffset : x1 - cpOffset;
const cp2x = x1 < x2 ? x2 - cpOffset : x2 + cpOffset;
const d = `M ${x1} ${fromY} C ${cp1x} ${fromY}, ${cp2x} ${toY}, ${x2} ${toY}`;
path.setAttribute('d', d);
path.setAttribute('class', 'rel-line');
path.setAttribute('marker-end', 'url(#arrowhead)');
path.dataset.from = rel.from;
path.dataset.to = rel.to;
svgLines.appendChild(path);
if (showLabels && rel.label) {
const mx = (x1 + x2) / 2;
const my = (fromY + toY) / 2 - 6;
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
text.setAttribute('x', mx);
text.setAttribute('y', my);
text.setAttribute('class', 'rel-label');
text.setAttribute('text-anchor', 'middle');
text.textContent = rel.label;
svgLines.appendChild(text);
}
}
}
function makeDraggable(el, tableName) {
let startX, startY, origX, origY;
el.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
e.stopPropagation();
el.classList.add('dragging');
startX = e.clientX;
startY = e.clientY;
origX = el.offsetLeft;
origY = el.offsetTop;
function onMove(e) {
const dx = (e.clientX - startX) / scale;
const dy = (e.clientY - startY) / scale;
el.style.left = (origX + dx) + 'px';
el.style.top = (origY + dy) + 'px';
positions[tableName].x = origX + dx;
positions[tableName].y = origY + dy;
drawRelationships();
updateMinimap();
}
function onUp() {
el.classList.remove('dragging');
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
el.addEventListener('mouseenter', () => highlightTable(tableName));
el.addEventListener('mouseleave', () => clearHighlights());
}
function highlightTable(tableName) {
const lines = svgLines.querySelectorAll('.rel-line');
lines.forEach(line => {
if (line.dataset.from === tableName || line.dataset.to === tableName) {
line.classList.add('highlighted');
line.setAttribute('marker-end', 'url(#arrowhead-hl)');
} else {
line.style.opacity = '0.15';
}
});
}
function clearHighlights() {
const lines = svgLines.querySelectorAll('.rel-line');
lines.forEach(line => {
line.classList.remove('highlighted');
line.setAttribute('marker-end', 'url(#arrowhead)');
line.style.opacity = '';
});
}
let isPanning = false, panStartX, panStartY;
container.addEventListener('mousedown', (e) => {
if (e.target !== container && e.target !== canvas && e.target.tagName !== 'svg') return;
isPanning = true;
panStartX = e.clientX - panX;
panStartY = e.clientY - panY;
container.style.cursor = 'grabbing';
});
document.addEventListener('mousemove', (e) => {
if (!isPanning) return;
panX = e.clientX - panStartX;
panY = e.clientY - panStartY;
applyTransform();
updateMinimap();
});
document.addEventListener('mouseup', () => {
isPanning = false;
container.style.cursor = 'grab';
});
container.addEventListener('wheel', (e) => {
e.preventDefault();
const delta = e.deltaY > 0 ? 0.92 : 1.08;
const newScale = Math.max(0.2, Math.min(2, scale * delta));
const rect = container.getBoundingClientRect();
const cx = e.clientX - rect.left;
const cy = e.clientY - rect.top;
panX = cx - (cx - panX) * (newScale / scale);
panY = cy - (cy - panY) * (newScale / scale);
scale = newScale;
applyTransform();
updateMinimap();
}, { passive: false });
function applyTransform() {
canvas.style.transform = `translate(${panX}px, ${panY}px) scale(${scale})`;
}
function zoomIn() { scale = Math.min(2, scale * 1.2); applyTransform(); updateMinimap(); }
function zoomOut() { scale = Math.max(0.2, scale * 0.8); applyTransform(); updateMinimap(); }
function resetView() {
scale = 0.85; panX = 50; panY = 20;
applyTransform(); updateMinimap();
}
function toggleLabels() {
showLabels = !showLabels;
drawRelationships();
}
const minimapCanvas = document.getElementById('minimap-canvas');
const mCtx = minimapCanvas.getContext('2d');
function updateMinimap() {
const mw = 180, mh = 120;
minimapCanvas.width = mw * 2;
minimapCanvas.height = mh * 2;
minimapCanvas.style.width = mw + 'px';
minimapCanvas.style.height = mh + 'px';
mCtx.scale(2, 2);
mCtx.clearRect(0, 0, mw, mh);
mCtx.fillStyle = '#f5f7fb';
mCtx.fillRect(0, 0, mw, mh);
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const [name, pos] of Object.entries(positions)) {
const el = tableElements[name];
if (!el) continue;
minX = Math.min(minX, pos.x); minY = Math.min(minY, pos.y);
maxX = Math.max(maxX, pos.x + el.offsetWidth); maxY = Math.max(maxY, pos.y + el.offsetHeight);
}
const padding = 40;
minX -= padding; minY -= padding; maxX += padding; maxY += padding;
const scaleX = mw / (maxX - minX);
const scaleY = mh / (maxY - minY);
const ms = Math.min(scaleX, scaleY);
for (const [name, pos] of Object.entries(positions)) {
const el = tableElements[name];
if (!el) continue;
const color = schema.tables[name]?.color || '#6ea8fe';
mCtx.fillStyle = color + '60';
mCtx.fillRect((pos.x - minX) * ms, (pos.y - minY) * ms, el.offsetWidth * ms, el.offsetHeight * ms);
}
const rect = container.getBoundingClientRect();
const vx = (-panX / scale - minX) * ms;
const vy = (-panY / scale - minY) * ms;
const vw = (rect.width / scale) * ms;
const vh = (rect.height / scale) * ms;
mCtx.strokeStyle = '#6ea8fe';
mCtx.lineWidth = 1;
mCtx.strokeRect(vx, vy, vw, vh);
}
renderTables();
drawRelationships();
applyTransform();
setTimeout(updateMinimap, 100);
window.addEventListener('resize', updateMinimap);
</script>
</body>
</html>