Move weather to a top strip and give the closed feed full height

The weather card becomes a full-width strip under the header. Below it
the stat tiles sit over the technician table, and the recently closed
feed runs the height of both and scrolls inside its own panel. The
"watching" text moves from under the header to the footer, which now
comes before the page script so render() can find it.

Tiles share subgrid rows so a wrapped label doesn't push its number out
of line, and the feed's age column is wider so "1h 13m" no longer runs
into the ticket number.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Skylar Grant 2026-10-19 12:24:13 -04:00
parent 0d0bf2b033
commit 340f1f3b68
3 changed files with 92 additions and 41 deletions

View File

@ -119,9 +119,11 @@ The full security model, its limits and the open items are in [docs/SECURITY.md]
## The dashboard ## The dashboard
From the top: a weather strip across the full width, then the stat tiles over the technician table, with the recently closed feed down the right side. The feed scrolls inside its own panel and runs the height of the tiles and table. On narrow screens everything stacks. The footer lists what the dashboard is watching (sites, groups, technicians, check intervals and the last check).
- **Stat tiles**: unassigned requests, total open requests, closed today, closed in the last hour, and the technician with the most open tickets. - **Stat tiles**: unassigned requests, total open requests, closed today, closed in the last hour, and the technician with the most open tickets.
- **Tickets by technician**: each technician's open tickets by status. **Total open** and **Closed today** come first, so they stay visible on narrow screens while the status columns scroll sideways. Darker cells mean more tickets. Click a column header to sort by it, and click it again to reverse. Rows start A to Z by name; the arrow points toward the larger end (↓ is A to Z or lowest first, ↑ is highest first). Each browser remembers its own sort. A row flashes when that technician closes a ticket. - **Tickets by technician**: each technician's open tickets by status. **Total open** and **Closed today** come first, so they stay visible on narrow screens while the status columns scroll sideways. Darker cells mean more tickets. Click a column header to sort by it, and click it again to reverse. Rows start A to Z by name; the arrow points toward the larger end (↓ is A to Z or lowest first, ↑ is highest first). Each browser remembers its own sort. A row flashes when that technician closes a ticket.
- **Weather**: current conditions and a 5-day forecast for `OWM_ZIP_CODE`, refreshed every 10 minutes. - **Weather**: current conditions and a 5-day forecast for the chosen location (`OWM_ZIP_CODE` by default), refreshed every 10 minutes.
- **Recently closed**: the live closed-ticket feed, checked every minute. A ding plays for each new close. The sound is on by default, and the **Sound on close** toggle mutes it in that browser. - **Recently closed**: the live closed-ticket feed, checked every minute. A ding plays for each new close. The sound is on by default, and the **Sound on close** toggle mutes it in that browser.
- **Refresh now** reruns every check immediately. - **Refresh now** reruns every check immediately.

View File

@ -38,5 +38,6 @@ A hosted dashboard for MCCS help desk staff. Everyone signs in with their own Zo
- Live test on 2026-10-15: per-user sign-in, data, Sign out and the IP access list all work behind NPM. - Live test on 2026-10-15: per-user sign-in, data, Sign out and the IP access list all work behind NPM.
- Per-user settings (2026-10-15, `v2.1.0`): ⚙ Settings and a first-visit setup prompt; sites, groups, technicians with suggestions from recent SDP tickets; table refresh and feed length; weather location from a campus list; hidden panels and status columns. Saved server side by Zoho email, so they follow the user between browsers. - Per-user settings (2026-10-15, `v2.1.0`): ⚙ Settings and a first-visit setup prompt; sites, groups, technicians with suggestions from recent SDP tickets; table refresh and feed length; weather location from a campus list; hidden panels and status columns. Saved server side by Zoho email, so they follow the user between browsers.
- Per-user closed statuses for the feed and ding (2026-10-19, `v2.1.1`), so Resolved can ding without waiting days for SDP's automatic close. Tested with stubs only; check against real SDP that resolved tickets carry `resolved_time` and that the later automatic close doesn't ding again. - Per-user closed statuses for the feed and ding (2026-10-19, `v2.1.1`), so Resolved can ding without waiting days for SDP's automatic close. Tested with stubs only; check against real SDP that resolved tickets carry `resolved_time` and that the later automatic close doesn't ding again.
- New layout (2026-10-19, `v2.3.0`): weather strip across the top, stat tiles over the table, the closed feed full height beside them, and the "watching" text in the footer.
- Contrast pass and MCCS colors (2026-10-19, `v2.2.0`): WCAG AA text, stronger heat scale, 12px minimum text, MCCS green accent and navy heat, all colors as tokens. - Contrast pass and MCCS colors (2026-10-19, `v2.2.0`): WCAG AA text, stronger heat scale, 12px minimum text, MCCS green accent and navy heat, all colors as tokens.
- Sortable technician table (2026-10-19, `v2.1.1`): click any header to sort, again to reverse. Defaults to name A to Z; remembered per browser. - Sortable technician table (2026-10-19, `v2.1.1`): click any header to sort, again to reverse. Defaults to name A to Z; remembered per browser.

View File

@ -234,8 +234,16 @@ button.quiet:disabled { opacity: 0.5; cursor: default; }
display: grid; display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr)); grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 12px; gap: 12px;
margin-top: 24px;
} }
/* tiles share rows, so a label that wraps beside the table keeps every number on one line */
.stats .stat {
display: grid;
grid-row: span 3;
grid-template-rows: subgrid;
row-gap: 0;
align-content: start;
}
.stats .stat .label { align-self: end; }
.stat { .stat {
padding: 16px 18px; padding: 16px 18px;
@ -274,8 +282,9 @@ button.quiet:disabled { opacity: 0.5; cursor: default; }
grid-template-columns: minmax(0, 1fr) 380px; grid-template-columns: minmax(0, 1fr) 380px;
gap: 20px; gap: 20px;
margin-top: 20px; margin-top: 20px;
align-items: start;
} }
/* stat tiles over the technician table, as wide as the table */
.main-col { display: grid; gap: 20px; align-content: start; min-width: 0; }
.section { .section {
background: var(--card); background: var(--card);
@ -403,15 +412,24 @@ tr.flash td { animation: flash 2.4s ease-out; }
/* ---- feed ---- */ /* ---- feed ---- */
/* the feed runs the full height of the tiles and table beside it and scrolls inside.
contain: size keeps its own length from stretching the row; the left column sets the height */
.feed-panel {
display: flex;
flex-direction: column;
contain: size;
min-height: 420px;
}
.feed { .feed {
max-height: 640px; flex: 1;
min-height: 0;
overflow-y: auto; overflow-y: auto;
} }
.ticket { .ticket {
--age: 0; --age: 0;
display: grid; display: grid;
grid-template-columns: 66px 1fr; grid-template-columns: 80px 1fr;
border-bottom: 1px solid var(--rule); border-bottom: 1px solid var(--rule);
} }
.feed .ticket:last-child { border-bottom: none; } .feed .ticket:last-child { border-bottom: none; }
@ -475,19 +493,39 @@ body > footer {
body > footer p { margin: 0; } body > footer p { margin: 0; }
body > footer p + p { margin-top: 4px; } body > footer p + p { margin-top: 4px; }
body > footer .credit { color: var(--signal); } body > footer .credit { color: var(--signal); }
body > footer .meta { margin: 0 0 12px; }
body > footer .meta:empty { display: none; }
/* ---- weather ---- */ /* ---- weather ---- */
.side { display: grid; gap: 20px; min-width: 0; } /* one strip across the top: now, details, then the forecast days filling the rest */
.weather-strip {
display: grid;
grid-template-columns: auto auto minmax(0, 1fr);
align-items: stretch;
margin-top: 20px;
}
.weather-empty { grid-column: 1 / -1; padding: 18px; color: var(--muted); }
.weather-now { .weather-now {
display: grid; display: flex;
grid-template-columns: auto 1fr;
align-items: center; align-items: center;
gap: 4px 12px; gap: 8px;
padding: 12px 18px 14px 8px; padding: 10px 22px 10px 8px;
} }
.weather-now img { width: 84px; height: 84px; margin: -6px 0; } .weather-now img { width: 84px; height: 84px; margin: -6px 0; }
.weather-place {
margin: 0 0 4px;
font-size: 15px;
font-weight: 600;
}
.weather-place .note {
margin-left: 8px;
font-family: var(--mono);
font-size: 12px;
font-weight: 400;
color: var(--muted);
}
.weather-temp { .weather-temp {
font-family: var(--mono); font-family: var(--mono);
font-size: 38px; font-size: 38px;
@ -496,21 +534,23 @@ body > footer .credit { color: var(--signal); }
} }
.weather-desc { margin-top: 4px; font-size: 14px; text-transform: capitalize; } .weather-desc { margin-top: 4px; font-size: 14px; text-transform: capitalize; }
.weather-details { .weather-details {
grid-column: 1 / -1;
display: flex; display: flex;
flex-wrap: wrap; flex-direction: column;
gap: 2px 14px; justify-content: center;
padding-left: 10px; gap: 2px;
padding: 10px 22px;
border-left: 1px solid var(--rule);
font-family: var(--mono); font-family: var(--mono);
font-size: 12px; font-size: 13px;
color: var(--muted); color: var(--muted);
white-space: nowrap;
} }
.weather-details b { color: var(--ink); font-weight: 500; } .weather-details b { color: var(--ink); font-weight: 500; }
.forecast { .forecast {
display: grid; display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr)); grid-template-columns: repeat(5, minmax(0, 1fr));
border-top: 1px solid var(--rule); border-left: 1px solid var(--rule);
} }
.forecast-day { .forecast-day {
padding: 10px 4px 12px; padding: 10px 4px 12px;
@ -662,7 +702,11 @@ button:disabled { opacity: 0.5; cursor: default; }
@media (max-width: 1040px) { @media (max-width: 1040px) {
.board { grid-template-columns: minmax(0, 1fr); } .board { grid-template-columns: minmax(0, 1fr); }
.stats { grid-template-columns: repeat(3, minmax(0, 1fr)); } .stats { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.feed { max-height: none; } /* stacked under the table, the feed sizes to its tickets again */
.feed-panel { contain: none; min-height: 0; }
/* the forecast drops under the current conditions */
.weather-strip { grid-template-columns: auto minmax(0, 1fr); }
.forecast { grid-column: 1 / -1; border-left: none; border-top: 1px solid var(--rule); }
} }
@media (max-width: 720px) { @media (max-width: 720px) {
@ -698,13 +742,18 @@ button:disabled { opacity: 0.5; cursor: default; }
</div> </div>
</header> </header>
<div class="meta" id="meta"></div>
<div id="main"></div> <div id="main"></div>
</div> </div>
<dialog id="settings" aria-labelledby="settingsTitle"></dialog> <dialog id="settings" aria-labelledby="settingsTitle"></dialog>
<footer>
<!-- what the dashboard is watching and how often; down here so the board gets the space -->
<div class="meta" id="meta"></div>
<p>Design and Credits: <span class="credit">Isaac Godin</span></p>
<p>Dashboard by <span class="credit">Skylar Grant</span> with <span class="credit">Claude</span> (Anthropic)</p>
</footer>
<script> <script>
const $ = id => document.getElementById(id); const $ = id => document.getElementById(id);
@ -933,12 +982,16 @@ function render() {
if (data.auth === 'connected') { if (data.auth === 'connected') {
const shown = panel => !s.hiddenPanels.includes(panel); const shown = panel => !s.hiddenPanels.includes(panel);
if (shown('stats')) html += renderStats(wl); // weather is a strip across the top; under it the tiles sit over the table, beside the feed
if (shown('weather')) html += renderWeather();
// the watcher and its ding keep running with the feed hidden; only the list goes // the watcher and its ding keep running with the feed hidden; only the list goes
const side = (shown('weather') ? renderWeather() : '') + (shown('feed') ? renderFeed() : ''); const feedPanel = shown('feed') ? renderFeed() : '';
html += `<div class="board${side ? '' : ' solo'}"> html += `<div class="board${feedPanel ? '' : ' solo'}">
<div class="main-col">
${shown('stats') ? renderStats(wl) : ''}
${renderWorkload(wl)} ${renderWorkload(wl)}
${side ? `<div class="side">${side}</div>` : ''} </div>
${feedPanel}
</div>`; </div>`;
html += renderUnassignedList(data.unassigned || {}); html += renderUnassignedList(data.unassigned || {});
} }
@ -1078,9 +1131,8 @@ function renderWeather() {
if (!weather || !weather.enabled) return ''; if (!weather || !weather.enabled) return '';
if (weather.temp === undefined) { if (weather.temp === undefined) {
return `<section class="section"> return `<section class="section weather-strip">
<div class="section-head"><h2>Weather</h2></div> <div class="weather-empty">${weather.error ? 'Weather unavailable: ' + esc(weather.error) : 'Loading weather…'}</div>
<div class="table-empty">${weather.error ? 'Weather unavailable: ' + esc(weather.error) : 'Loading…'}</div>
</section>`; </section>`;
} }
@ -1094,21 +1146,21 @@ function renderWeather() {
const note = (weather.error ? 'stale · ' : '') + 'as of ' + clockTime(weather.updatedAt); const note = (weather.error ? 'stale · ' : '') + 'as of ' + clockTime(weather.updatedAt);
return `<section class="section"> return `<section class="section weather-strip">
<div class="section-head"><h2>${esc(weather.place || 'Weather')}</h2><span class="note">${esc(note)}</span></div>
<div class="weather-now"> <div class="weather-now">
${weather.icon ? `<img src="${iconUrl(weather.icon, 4)}" alt="">` : '<span></span>'} ${weather.icon ? `<img src="${iconUrl(weather.icon, 4)}" alt="">` : '<span></span>'}
<div> <div>
<h2 class="weather-place">${esc(weather.place || 'Weather')} <span class="note">${esc(note)}</span></h2>
<div class="weather-temp">${weather.temp}°F</div> <div class="weather-temp">${weather.temp}°F</div>
<div class="weather-desc">${esc(weather.description || weather.condition || '')}</div> <div class="weather-desc">${esc(weather.description || weather.condition || '')}</div>
</div> </div>
</div>
<div class="weather-details"> <div class="weather-details">
<span>Feels <b>${weather.feelsLike}°</b></span> <span>Feels <b>${weather.feelsLike}°</b></span>
<span>Wind <b>${weather.wind} mph</b></span> <span>Wind <b>${weather.wind} mph</b></span>
<span>Humidity <b>${weather.humidity}%</b></span> <span>Humidity <b>${weather.humidity}%</b></span>
${weather.sunset ? `<span>Sunset <b>${clockTime(weather.sunset)}</b></span>` : ''} ${weather.sunset ? `<span>Sunset <b>${clockTime(weather.sunset)}</b></span>` : ''}
</div> </div>
</div>
${days ?`<div class="forecast">${days}</div>` : ''} ${days ?`<div class="forecast">${days}</div>` : ''}
</section>`; </section>`;
} }
@ -1149,7 +1201,7 @@ function renderFeed() {
? '<div class="feed">' + data.tickets.map(renderTicket).join('') + '</div>' ? '<div class="feed">' + data.tickets.map(renderTicket).join('') + '</div>'
: '<div class="empty"><p>Nothing closed yet.</p></div>'; : '<div class="empty"><p>Nothing closed yet.</p></div>';
return `<section class="section"> return `<section class="section feed-panel">
<div class="section-head"><h2>Recently closed</h2><span class="note">last ${data.settings.feedHours}h</span></div> <div class="section-head"><h2>Recently closed</h2><span class="note">last ${data.settings.feedHours}h</span></div>
${list} ${list}
</section>`; </section>`;
@ -1460,9 +1512,5 @@ function applySettings(reply) {
start(); start();
setInterval(render, 20000); // re-age the rails between checks setInterval(render, 20000); // re-age the rails between checks
</script> </script>
<footer>
<p>Design and Credits: <span class="credit">Isaac Godin</span></p>
<p>Dashboard by <span class="credit">Skylar Grant</span> with <span class="credit">Claude</span> (Anthropic)</p>
</footer>
</body> </body>
</html> </html>