From d74892e895757e9b5b9023ecd7405c5ddf16f1f3 Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Sun, 15 Feb 2026 19:26:38 -0800 Subject: [PATCH] Make constraints directional and add mismatch detection --- drop.sql | 7 ++++ main.go | 6 +--- schema.sql | 2 +- static/trip.html | 2 ++ static/trip.js | 92 +++++++++++++++++++++++++++++++++--------------- 5 files changed, 75 insertions(+), 34 deletions(-) create mode 100644 drop.sql diff --git a/drop.sql b/drop.sql new file mode 100644 index 0000000..07edf98 --- /dev/null +++ b/drop.sql @@ -0,0 +1,7 @@ +DROP TABLE IF EXISTS roommate_constraints; +DROP TABLE IF EXISTS parents; +DROP TABLE IF EXISTS students; +DROP TABLE IF EXISTS trip_admins; +DROP TABLE IF EXISTS trips; +DROP TYPE IF EXISTS constraint_level; +DROP TYPE IF EXISTS constraint_kind; diff --git a/main.go b/main.go index ea89289..d236e1a 100644 --- a/main.go +++ b/main.go @@ -654,10 +654,6 @@ func handleCreateConstraint(db *sql.DB) http.HandlerFunc { http.Error(w, "invalid level", http.StatusBadRequest) return } - a, b := body.StudentAID, body.StudentBID - if a > b { - a, b = b, a - } var id int64 err := db.QueryRow(` INSERT INTO roommate_constraints (student_a_id, student_b_id, kind, level) @@ -666,7 +662,7 @@ func handleCreateConstraint(db *sql.DB) http.HandlerFunc { JOIN students sb ON sb.id = $2 AND sb.trip_id = $5 WHERE sa.id = $1 AND sa.trip_id = $5 ON CONFLICT (student_a_id, student_b_id, level) DO UPDATE SET kind = EXCLUDED.kind - RETURNING id`, a, b, body.Kind, body.Level, tripID).Scan(&id) + RETURNING id`, body.StudentAID, body.StudentBID, body.Kind, body.Level, tripID).Scan(&id) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return diff --git a/schema.sql b/schema.sql index 3efef96..0fde698 100644 --- a/schema.sql +++ b/schema.sql @@ -43,6 +43,6 @@ CREATE TABLE IF NOT EXISTS roommate_constraints ( student_b_id BIGINT NOT NULL REFERENCES students(id) ON DELETE CASCADE, kind constraint_kind NOT NULL, level constraint_level NOT NULL, - CHECK(student_a_id < student_b_id), + CHECK(student_a_id != student_b_id), UNIQUE(student_a_id, student_b_id, level) ); diff --git a/static/trip.html b/static/trip.html index edbe3cd..ba46957 100644 --- a/static/trip.html +++ b/static/trip.html @@ -62,6 +62,7 @@ .constraint-add { display: flex; gap: 0.5rem; align-items: center; margin-top: 0.3rem; } .constraint-add select { font-size: 0.75rem; padding: 0.15rem; border: 1px solid var(--wa-color-neutral-300, #ccc); border-radius: 0.25rem; } #conflicts { margin-bottom: 0.5rem; } + #mismatches { margin-bottom: 0.5rem; } .conflict-row { margin-bottom: 0.2rem; } .conflict-icon { background: var(--wa-color-danger-50, #dc3545); color: white; border-radius: 0.15rem; padding: 0 0.15rem; font-size: 0.6rem; line-height: 1.2; vertical-align: middle; margin-right: 0.1rem; display: inline-block; } @@ -81,6 +82,7 @@
+
diff --git a/static/trip.js b/static/trip.js index f0c3ee0..4cb1a34 100644 --- a/static/trip.js +++ b/static/trip.js @@ -57,6 +57,45 @@ async function loadStudents() { } } + const kindSpan = (kind) => { + const span = document.createElement('span'); + span.textContent = kindLabels[kind]; + span.style.color = kindColor[kind]; + span.style.fontWeight = 'bold'; + return span; + }; + + const allOveralls = {}; + for (const s of students) { + const myC = constraints.filter(c => c.student_a_id === s.id); + const byPeer = {}; + for (const c of myC) { + if (!byPeer[c.student_b_id]) byPeer[c.student_b_id] = {}; + byPeer[c.student_b_id][c.level] = c; + } + allOveralls[s.id] = {}; + for (const [peerId, levels] of Object.entries(byPeer)) { + const eff = levels.admin || levels.parent || levels.student; + if (eff) allOveralls[s.id][peerId] = eff; + } + } + + const mismatchList = []; + for (const s of students) { + for (const [bId, effA] of Object.entries(allOveralls[s.id])) { + if (!allOveralls[bId] || !allOveralls[bId][s.id]) continue; + const effB = allOveralls[bId][s.id]; + if (isPositive(effA.kind) && !isPositive(effB.kind)) { + mismatchList.push({ + nameA: s.name, + nameB: students.find(x => x.id === parseInt(bId)).name, + kindA: effA.kind, + kindB: effB.kind, + }); + } + } + } + const conflictsEl = document.getElementById('conflicts'); const conflictsWasOpen = conflictsEl.querySelector('wa-details')?.open; conflictsEl.innerHTML = ''; @@ -64,13 +103,6 @@ async function loadStudents() { const det = document.createElement('wa-details'); det.summary = '\u26a0 Overrides (' + conflictList.length + ')'; if (conflictsWasOpen) det.open = true; - const kindSpan = (kind) => { - const span = document.createElement('span'); - span.textContent = kindLabels[kind]; - span.style.color = kindColor[kind]; - span.style.fontWeight = 'bold'; - return span; - }; for (const conflict of conflictList) { const div = document.createElement('div'); div.className = 'conflict-row'; @@ -91,6 +123,25 @@ async function loadStudents() { conflictsEl.appendChild(det); } + const mismatchesEl = document.getElementById('mismatches'); + const mismatchesWasOpen = mismatchesEl.querySelector('wa-details')?.open; + mismatchesEl.innerHTML = ''; + if (mismatchList.length > 0) { + const det = document.createElement('wa-details'); + det.summary = '\u26a0 Mismatches (' + mismatchList.length + ')'; + if (mismatchesWasOpen) det.open = true; + for (const m of mismatchList) { + const div = document.createElement('div'); + div.className = 'conflict-row'; + div.appendChild(document.createTextNode(m.nameA + ' \u2192 ' + m.nameB + ': ')); + div.appendChild(kindSpan(m.kindA)); + div.appendChild(document.createTextNode(' but ' + m.nameB + ' \u2192 ' + m.nameA + ': ')); + div.appendChild(kindSpan(m.kindB)); + det.appendChild(div); + } + mismatchesEl.appendChild(det); + } + const container = document.getElementById('students'); const openStates = {}; for (const card of container.children) { @@ -167,26 +218,14 @@ async function loadStudents() { const cDetails = document.createElement('wa-details'); cDetails.summary = 'Constraints'; - const myConstraints = constraints.filter(c => c.student_a_id === student.id || c.student_b_id === student.id); + const myConstraints = constraints.filter(c => c.student_a_id === student.id); - const byPeer = {}; - for (const c of myConstraints) { - const otherId = c.student_a_id === student.id ? c.student_b_id : c.student_a_id; - if (!byPeer[otherId]) byPeer[otherId] = {}; - byPeer[otherId][c.level] = c; - } - const overall = []; - for (const levels of Object.values(byPeer)) { - const eff = levels.admin || levels.parent || levels.student; - if (eff) overall.push(eff); - } + const overall = Object.values(allOveralls[student.id] || {}); if (overall.length > 0) { overall.sort((a, b) => { const kd = kindOrder[a.kind] - kindOrder[b.kind]; if (kd !== 0) return kd; - const na = a.student_a_id === student.id ? a.student_b_name : a.student_a_name; - const nb = b.student_a_id === student.id ? b.student_b_name : b.student_a_name; - return na.localeCompare(nb); + return a.student_b_name.localeCompare(b.student_b_name); }); const group = document.createElement('div'); group.className = 'constraint-group'; @@ -195,11 +234,10 @@ async function loadStudents() { levelLabel.textContent = 'Overall'; group.appendChild(levelLabel); for (const c of overall) { - const otherName = c.student_a_id === student.id ? c.student_b_name : c.student_a_name; const tag = document.createElement('wa-tag'); tag.size = 'small'; tag.variant = kindVariant[c.kind]; - tag.textContent = kindLabels[c.kind] + ': ' + otherName; + tag.textContent = kindLabels[c.kind] + ': ' + c.student_b_name; tag.title = 'From ' + capitalize(c.level); group.appendChild(tag); } @@ -212,9 +250,7 @@ async function loadStudents() { lc.sort((a, b) => { const kd = kindOrder[a.kind] - kindOrder[b.kind]; if (kd !== 0) return kd; - const na = a.student_a_id === student.id ? a.student_b_name : a.student_a_name; - const nb = b.student_a_id === student.id ? b.student_b_name : b.student_a_name; - return na.localeCompare(nb); + return a.student_b_name.localeCompare(b.student_b_name); }); const group = document.createElement('div'); group.className = 'constraint-group'; @@ -223,7 +259,7 @@ async function loadStudents() { levelLabel.textContent = level.charAt(0).toUpperCase() + level.slice(1); group.appendChild(levelLabel); for (const c of lc) { - const otherName = c.student_a_id === student.id ? c.student_b_name : c.student_a_name; + const otherName = c.student_b_name; const tag = document.createElement('wa-tag'); tag.size = 'small'; tag.variant = kindVariant[c.kind];