diff --git a/schema.sql b/schema.sql index 98d12f9..3efef96 100644 --- a/schema.sql +++ b/schema.sql @@ -1,6 +1,3 @@ -DROP TABLE IF EXISTS roommate_constraints; -DROP TYPE IF EXISTS constraint_kind; - DO $$ BEGIN CREATE TYPE constraint_kind AS ENUM ('must', 'prefer', 'prefer_not', 'must_not'); EXCEPTION WHEN duplicate_object THEN NULL; diff --git a/static/admin.html b/static/admin.html index 2c1fca9..6cedb15 100644 --- a/static/admin.html +++ b/static/admin.html @@ -33,6 +33,7 @@ .trip-name { font-weight: bold; font-size: 1rem; color: var(--wa-color-brand-60); } .tags { display: flex; flex-wrap: wrap; gap: 0.25rem; } wa-tag { transition: opacity var(--wa-transition-normal); } + wa-tag::part(base) { padding: 0.15rem 0.4rem; } wa-input.email { max-width: 20rem; } .add-form { display: flex; flex-direction: column; gap: 0.3rem; max-width: 20rem; } .add-form wa-button { align-self: flex-start; } diff --git a/static/trip.html b/static/trip.html index 97d9045..f63e504 100644 --- a/static/trip.html +++ b/static/trip.html @@ -32,6 +32,7 @@ .student-name { font-weight: bold; display: block; margin-bottom: 0.3rem; } .tags { display: flex; flex-wrap: wrap; gap: 0.25rem; margin-bottom: 0.3rem; } wa-tag { transition: opacity var(--wa-transition-normal); } + wa-tag::part(base) { padding: 0.15rem 0.4rem; } wa-input.email { max-width: 20rem; } .add-form { display: flex; flex-direction: column; gap: 0.3rem; max-width: 20rem; } .add-form wa-button { align-self: flex-start; } @@ -60,6 +61,9 @@ .constraint-level { font-size: 0.7rem; font-weight: bold; opacity: 0.6; } .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; } + .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; } @@ -76,6 +80,7 @@ +
diff --git a/static/trip.js b/static/trip.js index dff8a8b..0c11d46 100644 --- a/static/trip.js +++ b/static/trip.js @@ -27,6 +27,70 @@ async function loadStudents() { api('GET', '/api/trips/' + tripID + '/students'), api('GET', '/api/trips/' + tripID + '/constraints') ]); + const kindLabels = { must: 'Must', prefer: 'Prefer', prefer_not: 'Prefer Not', must_not: 'Must Not' }; + const kindVariant = { must: 'success', prefer: 'brand', prefer_not: 'warning', must_not: 'danger' }; + const kindColor = { must: 'var(--wa-color-success-50)', prefer: 'var(--wa-color-brand-50)', prefer_not: 'var(--wa-color-warning-50)', must_not: 'var(--wa-color-danger-50)' }; + const kindOrder = { must: 0, prefer: 1, prefer_not: 2, must_not: 3 }; + const isPositive = kind => kind === 'must' || kind === 'prefer'; + const capitalize = s => s.charAt(0).toUpperCase() + s.slice(1); + + const pairs = {}; + for (const c of constraints) { + const k = c.student_a_id + '-' + c.student_b_id; + if (!pairs[k]) pairs[k] = []; + pairs[k].push(c); + } + const conflictList = []; + const conflictMap = {}; + for (const group of Object.values(pairs)) { + const pos = group.filter(c => isPositive(c.kind)); + const neg = group.filter(c => !isPositive(c.kind)); + if (pos.length === 0 || neg.length === 0) continue; + conflictList.push({ + names: group[0].student_a_name + ' \u2192 ' + group[0].student_b_name, + positives: pos.map(c => ({ level: c.level, kind: c.kind })), + negatives: neg.map(c => ({ level: c.level, kind: c.kind })) + }); + for (const c of group) { + const opposing = isPositive(c.kind) ? neg : pos; + conflictMap[c.id] = opposing.map(o => capitalize(o.level) + ' says ' + kindLabels[o.kind]).join(', '); + } + } + + const conflictsEl = document.getElementById('conflicts'); + const conflictsWasOpen = conflictsEl.querySelector('wa-details')?.open; + conflictsEl.innerHTML = ''; + if (conflictList.length > 0) { + 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'; + div.appendChild(document.createTextNode(conflict.names + ': ')); + conflict.positives.forEach((p, i) => { + if (i > 0) div.appendChild(document.createTextNode(', ')); + div.appendChild(document.createTextNode(capitalize(p.level) + ' ')); + div.appendChild(kindSpan(p.kind)); + }); + div.appendChild(document.createTextNode(' vs ')); + conflict.negatives.forEach((n, i) => { + if (i > 0) div.appendChild(document.createTextNode(', ')); + div.appendChild(document.createTextNode(capitalize(n.level) + ' ')); + div.appendChild(kindSpan(n.kind)); + }); + det.appendChild(div); + } + conflictsEl.appendChild(det); + } + const container = document.getElementById('students'); const openStates = {}; for (const card of container.children) { @@ -103,9 +167,6 @@ async function loadStudents() { const cDetails = document.createElement('wa-details'); cDetails.summary = 'Constraints'; - const kindVariant = { must: 'success', prefer: 'brand', prefer_not: 'warning', must_not: 'danger' }; - const kindLabels = { must: 'Must', prefer: 'Prefer', prefer_not: 'Prefer Not', must_not: 'Must Not' }; - const kindOrder = { must: 0, prefer: 1, prefer_not: 2, must_not: 3 }; const myConstraints = constraints.filter(c => c.student_a_id === student.id || c.student_b_id === student.id); for (const level of ['admin', 'parent', 'student']) { @@ -130,11 +191,20 @@ async function loadStudents() { tag.size = 'small'; tag.variant = kindVariant[c.kind]; tag.setAttribute('with-remove', ''); - tag.textContent = kindLabels[c.kind] + ': ' + otherName; tag.addEventListener('wa-remove', async () => { await api('DELETE', '/api/trips/' + tripID + '/constraints/' + c.id); loadStudents(); }); + if (conflictMap[c.id]) { + const icon = document.createElement('span'); + icon.className = 'conflict-icon'; + icon.textContent = '\u26a0 '; + tag.appendChild(icon); + tag.appendChild(document.createTextNode(kindLabels[c.kind] + ': ' + otherName)); + tag.title = 'Overrides: ' + conflictMap[c.id]; + } else { + tag.textContent = kindLabels[c.kind] + ': ' + otherName; + } group.appendChild(tag); } cDetails.appendChild(group);