Slopify This Thang
This commit is contained in:
parent
3e592f9a0d
commit
8c7b5fbd26
101
README.md
101
README.md
@ -1,44 +1,102 @@
|
||||
# sdp-request-watcher
|
||||
|
||||
A local Node.js dashboard for ManageEngine ServiceDesk Plus (SDP) Cloud. It shows each technician's open tickets by status, plays a sound when a ticket is closed, tracks the unassigned queue, and shows local weather.
|
||||
|
||||
## Env and Server Setup
|
||||
env.example contains the setup for the env requires a Non-Browser Application key from https://api-console.zoho.com/add
|
||||
Place the .env file inside the src directory
|
||||

|
||||
|
||||
Technicians and Statuses are checked in the .env file
|
||||
## Setup
|
||||
|
||||
I resolved the issue with it only finding closed tickets so it should now find resolved and closed I havent tested with others but will
|
||||
### 1. Zoho API client
|
||||
|
||||
The technician field can have spaces after the comma however the statuses can not. There can be no spaces before the status or it wont work properly.
|
||||
Create a **Non-Browser Application** at <https://api-console.zoho.com/add>. That gives you the client ID and secret. The app only asks for the `SDPOnDemand.requests.READ` scope, so it can read requests but never change them.
|
||||
|
||||
### 2. `.env` file
|
||||
|
||||
Copy `src/env.example` to a file named `.env` and fill it in. dotenv reads `.env` from the folder you start the server in, so keep `.env` in the project root and start the server from there (see step 3).
|
||||
|
||||
## Site Setup
|
||||
Open the site after the server is running, then connect the Zoho account from the board.
|
||||
| Setting | Required | What it does |
|
||||
| --- | --- | --- |
|
||||
| `CLIENT_ID`, `CLIENT_SECRET` | Yes | The Zoho Non-Browser Application credentials. |
|
||||
| `TECHNICIAN_NAMES` | No | Comma-separated technician names, spelled as they appear in SDP. One table row per name. Leave it empty to include every technician. |
|
||||
| `STATUS_NAMES` | No | Statuses that count as "closed" for the ding and the Closed today count. Everything else counts as open. Defaults to `Closed,Resolved,Canceled`. |
|
||||
| `SITE_NAMES` | No | Only include requests from these sites (the region). Leave it empty for all sites. |
|
||||
| `GROUP_NAMES` | No | Only include requests in these support groups, e.g. `Customer Support`. Leave it empty for all groups. |
|
||||
| `UNASSIGNED_EXCLUDED_STATUSES` | No | Statuses the Unassigned requests counter leaves out. Defaults to `STATUS_NAMES`. |
|
||||
| `WORKLOAD_MINUTES` | No | How often the status table and unassigned count refresh, in minutes. Defaults to 5. |
|
||||
| `OWM_API_KEY`, `OWM_ZIP_CODE` | No | OpenWeatherMap key and zip code for the weather card. Leave either one empty to hide the card. A free key from <https://openweathermap.org/api> is enough. New keys can take a couple of hours to start working. |
|
||||
|
||||
1. Start the server from the `src` folder with node ./server.js
|
||||
2. Open the local dashboard in your browser. Should be localhost:3000
|
||||
3. Click **Connect** to begin the Zoho authorization flow.
|
||||
4. Approve the device code on the Zoho page, then return to the dashboard.
|
||||
Spaces around commas are fine in every list.
|
||||
|
||||
What you should see:
|
||||
The central region settings currently in use:
|
||||
|
||||

|
||||
```text
|
||||
SITE_NAMES=Base Site,ASSIGN,CMCC,CMCC-Damariscotta,EMCC,KVCC,KVCC-Alfond,MCCS
|
||||
GROUP_NAMES=Customer Support
|
||||
UNASSIGNED_EXCLUDED_STATUSES=Canceled,Closed,Need Information,On Hold,Resolved
|
||||
```
|
||||
|
||||

|
||||
These match the "Central Region Unassigned" custom view in ManageEngine. The Unassigned requests count was checked against that view and matched.
|
||||
|
||||

|
||||
### 3. Run it
|
||||
|
||||

|
||||
```text
|
||||
npm install
|
||||
node src/server.js
|
||||
```
|
||||
|
||||
Once connected, the board will begin checking for tickets automatically and refreshing on its own based on the time set in the server.js file.
|
||||
Then open <http://localhost:3000>. Set `PORT` in `.env` to use a different port.
|
||||
|
||||
## Connecting to Zoho
|
||||
|
||||
The token is kept in memory only, so you connect each time the server starts.
|
||||
|
||||
1. Click **Connect**.
|
||||
|
||||

|
||||
|
||||
2. Once Zoho sends back a device code, the code and an **Open authorization page** button appear. The Zoho page opens in a new tab.
|
||||
|
||||

|
||||
|
||||
3. Check that the code matches, tick the box and click **Accept**, then close that tab.
|
||||
|
||||

|
||||
|
||||
4. Return to the dashboard tab. Zoho can't redirect back to a local app, but the dashboard checks the server every few seconds and goes live by itself once you approve.
|
||||
|
||||
## The dashboard
|
||||
|
||||
- **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. 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.
|
||||
- **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.
|
||||
|
||||
If a query fails, a red error box shows what SDP returned, and the other sections keep working. If the table ever shows **partial**, a query matched more tickets than it can page through (2,000). The server console then logs the details, including SDP's total count. A very large total usually means SDP ignored the search criteria.
|
||||
|
||||
### Unassigned verification list
|
||||
|
||||
`src/public/index.html` still has a hidden table listing every unassigned request by ID and subject. It was used to confirm the count matches the ManageEngine view. To show it again, set `SHOW_UNASSIGNED_LIST = true`.
|
||||
|
||||
## How it works
|
||||
|
||||
| File | Role |
|
||||
| --- | --- |
|
||||
| `src/server.js` | Reads `.env`, then starts Express and the services. |
|
||||
| `src/auth.js` | Zoho device-code sign-in. |
|
||||
| `src/boardService.js` | Holds the token and state, and runs the refresh timers. |
|
||||
| `src/tickets.js` | SDP API queries for the closed feed, the status table, closed today and unassigned requests. |
|
||||
| `src/weather.js` | OpenWeatherMap current weather and forecast, cached for 10 minutes. |
|
||||
| `src/routes.js` | `/api/state`, `/api/connect`, `/api/check` and `/api/weather`. |
|
||||
| `src/public/index.html` | The dashboard page. |
|
||||
|
||||
Every SDP query is limited to the configured sites, groups and technicians. Results are also re-checked locally, so a ticket from outside the region is never counted even if the API returns one.
|
||||
|
||||
## Requirements
|
||||
axios
|
||||
dotenv
|
||||
|
||||
Node.js 18 or newer. Dependencies are `axios`, `dotenv` and `express`, all installed by `npm install`.
|
||||
|
||||
## Reference Materials/ Provided by Skylar
|
||||
## Reference materials (provided by Skylar)
|
||||
|
||||
- [Non-Browser Apps - Overview](https://www.zoho.com/accounts/protocol/oauth/limited-input-devices.html)
|
||||
- [Non-Browser Apps - Initiation Request](https://www.zoho.com/accounts/protocol/oauth/devices/initiation-request.html)
|
||||
@ -46,3 +104,4 @@ dotenv
|
||||
- [Get List Request](https://www.manageengine.com/products/service-desk/sdpod-v3-api/requests/request.html#get-list-request)
|
||||
- [Input Data](https://www.manageengine.com/products/service-desk/sdpod-v3-api/getting-started/input-data.html)
|
||||
- [Search Criteria](https://www.manageengine.com/products/service-desk/sdpod-v3-api/getting-started/search-criteria.html)
|
||||
- [OpenWeatherMap Current Weather](https://openweathermap.org/current) and [5 Day Forecast](https://openweathermap.org/forecast5)
|
||||
|
||||
BIN
images/1.png
BIN
images/1.png
Binary file not shown.
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 28 KiB |
BIN
images/2.png
BIN
images/2.png
Binary file not shown.
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 32 KiB |
BIN
images/4.png
BIN
images/4.png
Binary file not shown.
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 73 KiB |
24
package-lock.json
generated
24
package-lock.json
generated
@ -1,14 +1,19 @@
|
||||
{
|
||||
"name": "Dashboard",
|
||||
"name": "sdp-request-watcher",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sdp-request-watcher",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"axios": "^1.13.2",
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^5.2.1"
|
||||
}
|
||||
"axios": "1.x",
|
||||
"dotenv": "18.x",
|
||||
"express": "5.x"
|
||||
},
|
||||
"devDependencies": {}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "2.0.0",
|
||||
@ -228,10 +233,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "17.4.2",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
|
||||
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
|
||||
"version": "18.0.8",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-18.0.8.tgz",
|
||||
"integrity": "sha512-2KDeoFoK/zRtcnbunCDlLG5VWVMcyqeQc6iK65o231nfNAcuGrKoqUQfJFUtBI+QlShk5HKIHxwebBtUl3rEfA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"bin": {
|
||||
"dotenv": "dist/index.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
||||
@ -11,12 +11,13 @@
|
||||
"type": "commonjs",
|
||||
"main": "src/server.js",
|
||||
"scripts": {
|
||||
"app": "node src/server.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.13.2",
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^5.2.1"
|
||||
"axios": "1.x",
|
||||
"dotenv": "18.x",
|
||||
"express": "5.x"
|
||||
},
|
||||
"devDependencies": {}
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
const { startAuthentication } = require('./auth');
|
||||
const { fetchClosedTickets } = require('./tickets');
|
||||
const { fetchClosedTickets, fetchWorkload, fetchUnassigned } = require('./tickets');
|
||||
|
||||
function createBoardService({ clientId, clientSecret, technicians, statusSet, windowMinutes, checkMinutes }) {
|
||||
function createBoardService({ clientId, clientSecret, technicians, statusSet, closedStatuses, unassignedExcludedStatuses, scope, windowMinutes, checkMinutes, workloadMinutes }) {
|
||||
const state = {
|
||||
auth: 'disconnected',
|
||||
verificationUrl: null,
|
||||
@ -10,9 +10,27 @@ function createBoardService({ clientId, clientSecret, technicians, statusSet, wi
|
||||
lastError: null
|
||||
};
|
||||
|
||||
const workload = {
|
||||
updatedAt: null,
|
||||
error: null,
|
||||
truncated: false,
|
||||
statuses: [],
|
||||
technicians: []
|
||||
};
|
||||
|
||||
const unassigned = {
|
||||
updatedAt: null,
|
||||
error: null,
|
||||
truncated: false,
|
||||
count: 0,
|
||||
discarded: 0,
|
||||
tickets: []
|
||||
};
|
||||
|
||||
const tickets = new Map();
|
||||
let accessToken = null;
|
||||
let pollingTimer = null;
|
||||
let workloadTimer = null;
|
||||
|
||||
async function refreshTickets() {
|
||||
if (!accessToken) return;
|
||||
@ -23,10 +41,30 @@ function createBoardService({ clientId, clientSecret, technicians, statusSet, wi
|
||||
tickets,
|
||||
technicians,
|
||||
statusSet,
|
||||
scope,
|
||||
windowMinutes
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshWorkload() {
|
||||
if (!accessToken) return;
|
||||
|
||||
await fetchWorkload({
|
||||
accessToken,
|
||||
workload,
|
||||
technicians,
|
||||
closedStatuses,
|
||||
scope
|
||||
});
|
||||
|
||||
await fetchUnassigned({
|
||||
accessToken,
|
||||
unassigned,
|
||||
excludedStatuses: unassignedExcludedStatuses,
|
||||
scope
|
||||
});
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
if (pollingTimer) return;
|
||||
|
||||
@ -36,6 +74,13 @@ function createBoardService({ clientId, clientSecret, technicians, statusSet, wi
|
||||
state.lastCheck = Date.now();
|
||||
});
|
||||
}, checkMinutes * 60 * 1000);
|
||||
|
||||
workloadTimer = setInterval(() => {
|
||||
refreshWorkload().catch(err => {
|
||||
workload.error = err.message;
|
||||
workload.updatedAt = Date.now();
|
||||
});
|
||||
}, workloadMinutes * 60 * 1000);
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
@ -52,6 +97,7 @@ function createBoardService({ clientId, clientSecret, technicians, statusSet, wi
|
||||
|
||||
await refreshTickets();
|
||||
startPolling();
|
||||
await refreshWorkload();
|
||||
}
|
||||
|
||||
function failConnection(err) {
|
||||
@ -66,14 +112,18 @@ function createBoardService({ clientId, clientSecret, technicians, statusSet, wi
|
||||
...state,
|
||||
windowMinutes,
|
||||
checkMinutes,
|
||||
workloadMinutes,
|
||||
watching: technicians,
|
||||
scope,
|
||||
serverTime: Date.now(),
|
||||
tickets: [...tickets.values()].sort((a, b) => b.closedAt - a.closedAt)
|
||||
tickets: [...tickets.values()].sort((a, b) => b.closedAt - a.closedAt),
|
||||
workload,
|
||||
unassigned
|
||||
};
|
||||
}
|
||||
|
||||
async function checkNow() {
|
||||
await refreshTickets();
|
||||
await Promise.all([refreshTickets(), refreshWorkload()]);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@ -2,3 +2,14 @@ CLIENT_ID=
|
||||
CLIENT_SECRET=
|
||||
TECHNICIAN_NAMES=First Last, First Last
|
||||
STATUS_NAMES=Closed,Resolved
|
||||
# only requests from these sites are shown (central region: KVCC, EMCC, CMCC, MCCS and their satellite sites)
|
||||
SITE_NAMES=Base Site,ASSIGN,CMCC,CMCC-Damariscotta,EMCC,KVCC,KVCC-Alfond,MCCS
|
||||
# optional: also limit to these support groups, e.g. Customer Support (leave empty for all groups)
|
||||
GROUP_NAMES=
|
||||
# statuses the Unassigned Requests counter leaves out (defaults to STATUS_NAMES)
|
||||
UNASSIGNED_EXCLUDED_STATUSES=Canceled,Closed,Need Information,On Hold,Resolved
|
||||
# optional: weather widget, free key from https://openweathermap.org/api (current weather + 5 day forecast)
|
||||
OWM_API_KEY=
|
||||
OWM_ZIP_CODE=04937
|
||||
# optional: how often the technician/status table is recounted, in minutes (default 5)
|
||||
WORKLOAD_MINUTES=5
|
||||
@ -3,19 +3,19 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Closed ticket board</title>
|
||||
<title>Help desk dashboard</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&display=swap');
|
||||
|
||||
:root {
|
||||
|
||||
--ground: hsl(210, 18%, 8%);
|
||||
--card: hsl(210, 16%, 13%);
|
||||
--card-raised: hsl(210, 15%, 16%);
|
||||
--ink: hsl(30, 30%, 96%);
|
||||
--muted: hsl(215, 12%, 68%);
|
||||
--faint: hsl(215, 10%, 42%);
|
||||
--rule: hsl(210, 12%, 24%);
|
||||
--signal: hsl(28, 95%, 58%);
|
||||
--signal-soft: hsl(28, 70%, 22%);
|
||||
@ -27,6 +27,7 @@
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
@ -42,10 +43,11 @@ body {
|
||||
}
|
||||
|
||||
.wrap {
|
||||
max-width: 860px;
|
||||
width: 100%;
|
||||
max-width: 1320px;
|
||||
flex: 1;
|
||||
margin: 0 auto;
|
||||
padding: 32px 20px 80px;
|
||||
padding: 28px 24px 48px;
|
||||
}
|
||||
|
||||
/* ---- header ---- */
|
||||
@ -53,7 +55,7 @@ body {
|
||||
header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
align-items: center;
|
||||
gap: 12px 20px;
|
||||
padding-bottom: 14px;
|
||||
border-bottom: 2px solid var(--ink);
|
||||
@ -71,7 +73,6 @@ h1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
margin-left: auto;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
@ -96,6 +97,13 @@ h1 {
|
||||
50% { opacity: 0.25; }
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@ -124,7 +132,7 @@ h1 {
|
||||
.panel p {
|
||||
margin: 0 0 16px;
|
||||
color: var(--muted);
|
||||
max-width: 52ch;
|
||||
max-width: 80ch;
|
||||
}
|
||||
|
||||
.code {
|
||||
@ -165,6 +173,40 @@ button.quiet {
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
button.quiet:hover { background: none; color: var(--ink); }
|
||||
button.quiet:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
background: none;
|
||||
color: var(--muted);
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
.toggle:hover { background: none; color: var(--ink); }
|
||||
.toggle .track {
|
||||
position: relative;
|
||||
width: 26px;
|
||||
height: 14px;
|
||||
border-radius: 7px;
|
||||
background: var(--rule);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.toggle .track::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--muted);
|
||||
transition: transform 0.15s, background 0.15s;
|
||||
}
|
||||
.toggle[aria-pressed="true"] .track { background: var(--signal-soft); }
|
||||
.toggle[aria-pressed="true"] .track::after { transform: translateX(12px); background: var(--signal); }
|
||||
|
||||
.error {
|
||||
margin-top: 20px;
|
||||
@ -175,55 +217,188 @@ button.quiet:hover { background: none; color: var(--ink); }
|
||||
}
|
||||
.error strong { display: block; font-family: var(--mono); font-size: 12px; color: var(--alert); }
|
||||
|
||||
/* ---- tallies ---- */
|
||||
/* ---- stat tiles ---- */
|
||||
|
||||
.tallies {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.tally {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
padding: 5px 12px;
|
||||
.stat {
|
||||
padding: 16px 18px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--rule);
|
||||
font-size: 13px;
|
||||
}
|
||||
.tally b {
|
||||
.stat .label {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
.stat .value {
|
||||
margin-top: 4px;
|
||||
font-family: var(--mono);
|
||||
font-size: 30px;
|
||||
font-weight: 600;
|
||||
color: var(--signal);
|
||||
line-height: 1.1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.stat.accent .value { color: var(--signal); }
|
||||
.stat .sub {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ---- board layout ---- */
|
||||
|
||||
.board {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 380px;
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.section {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--rule);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 4px 12px;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
.section-head h2 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.section-head .note {
|
||||
margin-left: auto;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* ---- workload table ---- */
|
||||
|
||||
.table-scroll { overflow-x: auto; }
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 9px 14px;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
th {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--muted);
|
||||
border-bottom: 1px solid var(--rule);
|
||||
vertical-align: bottom;
|
||||
}
|
||||
th .swatch {
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
margin-right: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--status-color, var(--muted));
|
||||
vertical-align: 1px;
|
||||
}
|
||||
|
||||
th:first-child, td:first-child {
|
||||
text-align: left;
|
||||
position: sticky;
|
||||
left: 0;
|
||||
background: var(--card);
|
||||
z-index: 1;
|
||||
}
|
||||
td:first-child { font-weight: 500; }
|
||||
|
||||
tbody tr + tr td { border-top: 1px solid color-mix(in oklab, var(--rule), transparent 50%); }
|
||||
tbody tr:hover td { background: var(--card-raised); }
|
||||
|
||||
td.num {
|
||||
--heat: 0;
|
||||
font-family: var(--mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
background: color-mix(in oklab, var(--signal-soft) calc(var(--heat) * 100%), transparent);
|
||||
}
|
||||
tbody tr:hover td.num { background: color-mix(in oklab, var(--signal-soft) calc(var(--heat) * 100%), var(--card-raised)); }
|
||||
td.zero { color: var(--faint); }
|
||||
|
||||
/* totals and closed-today sit apart from the per-status columns */
|
||||
td.total { font-weight: 600; background: none; }
|
||||
th.closed, td.closed { border-right: 1px solid var(--rule); }
|
||||
td.closed { color: var(--signal); background: none; }
|
||||
td.closed.zero { color: var(--faint); }
|
||||
tfoot td.closed { color: var(--ink); }
|
||||
|
||||
tfoot td {
|
||||
border-top: 1px solid var(--rule);
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
background: none;
|
||||
}
|
||||
tfoot td:first-child { font-family: var(--font-ui); color: var(--muted); font-weight: 500; }
|
||||
|
||||
tr.flash td { animation: flash 2.4s ease-out; }
|
||||
@keyframes flash {
|
||||
from { background: var(--signal-soft); }
|
||||
}
|
||||
|
||||
.table-empty {
|
||||
padding: 36px 18px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* ---- feed ---- */
|
||||
|
||||
.feed { margin-top: 20px; }
|
||||
.feed {
|
||||
max-height: 640px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ticket {
|
||||
--age: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 74px 1fr;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--rule);
|
||||
border-bottom: none;
|
||||
grid-template-columns: 66px 1fr;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
.feed .ticket:last-child { border-bottom: 1px solid var(--rule); }
|
||||
.feed .ticket:last-child { border-bottom: none; }
|
||||
|
||||
/* The rail is the point of the whole page: how long ago this closed, drained
|
||||
of color as it ages out of the window. */
|
||||
/* how long ago this closed, drained of color as it ages out of the hour */
|
||||
.rail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 14px 0 14px 14px;
|
||||
padding: 12px 0 12px 12px;
|
||||
border-left: 3px solid color-mix(in oklab, var(--signal), var(--rule) calc(var(--age) * 100%));
|
||||
font-family: var(--mono);
|
||||
font-size: 15px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: color-mix(in oklab, var(--signal), var(--muted) calc(var(--age) * 100%));
|
||||
}
|
||||
@ -235,11 +410,11 @@ button.quiet:hover { background: none; color: var(--ink); }
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.body { padding: 14px 16px 14px 4px; min-width: 0; }
|
||||
.body { padding: 12px 14px 12px 4px; min-width: 0; }
|
||||
|
||||
.subject {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@ -251,8 +426,8 @@ button.quiet:hover { background: none; color: var(--ink); }
|
||||
}
|
||||
|
||||
.byline {
|
||||
margin: 3px 0 0;
|
||||
font-size: 13px;
|
||||
margin: 2px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.byline b { color: var(--ink); font-weight: 500; }
|
||||
@ -264,32 +439,102 @@ button.quiet:hover { background: none; color: var(--ink); }
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin-top: 20px;
|
||||
padding: 40px 24px;
|
||||
background: var(--card);
|
||||
border: 1px dashed var(--rule);
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
.empty p { margin: 0; }
|
||||
|
||||
footer {
|
||||
margin-top: 28px;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 11px;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
body > footer {
|
||||
margin-top: auto;
|
||||
max-width: 860px;
|
||||
width: 100%;
|
||||
padding: 0 20px 28px;
|
||||
max-width: 1320px;
|
||||
margin: auto auto 0;
|
||||
padding: 0 24px 28px;
|
||||
font-size: 11px;
|
||||
}
|
||||
body > footer p { margin: 0; }
|
||||
|
||||
/* ---- weather ---- */
|
||||
|
||||
.side { display: grid; gap: 20px; min-width: 0; }
|
||||
|
||||
.weather-now {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
gap: 4px 12px;
|
||||
padding: 12px 18px 14px 8px;
|
||||
}
|
||||
.weather-now img { width: 84px; height: 84px; margin: -6px 0; }
|
||||
.weather-temp {
|
||||
font-family: var(--mono);
|
||||
font-size: 38px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
}
|
||||
.weather-desc { margin-top: 4px; font-size: 14px; text-transform: capitalize; }
|
||||
.weather-details {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px 14px;
|
||||
padding-left: 10px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.weather-details b { color: var(--ink); font-weight: 500; }
|
||||
|
||||
.forecast {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
border-top: 1px solid var(--rule);
|
||||
}
|
||||
.forecast-day {
|
||||
padding: 10px 4px 12px;
|
||||
text-align: center;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
.forecast-day + .forecast-day { border-left: 1px solid color-mix(in oklab, var(--rule), transparent 50%); }
|
||||
.forecast-day .name { color: var(--muted); font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; }
|
||||
.forecast-day img { display: block; width: 44px; height: 44px; margin: 0 auto; }
|
||||
.forecast-day .lo { color: var(--muted); }
|
||||
.forecast-day .pop { margin-top: 2px; font-size: 11px; color: hsl(205, 70%, 66%); }
|
||||
|
||||
/* ---- temporary: unassigned verification list (not for prod) ---- */
|
||||
|
||||
.verify {
|
||||
margin-top: 20px;
|
||||
background: var(--card);
|
||||
border: 1px dashed var(--rule);
|
||||
}
|
||||
.verify summary {
|
||||
padding: 12px 18px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.verify summary:hover { color: var(--ink); }
|
||||
.verify[open] summary { border-bottom: 1px dashed var(--rule); }
|
||||
.verify .warn { padding: 10px 18px 0; font-size: 13px; color: var(--alert); }
|
||||
.verify td, .verify th { text-align: left; }
|
||||
.verify td.subject-cell { white-space: normal; min-width: 260px; }
|
||||
|
||||
@media (max-width: 1040px) {
|
||||
.board { grid-template-columns: minmax(0, 1fr); }
|
||||
.stats { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.feed { max-height: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.ticket { grid-template-columns: 62px 1fr; }
|
||||
.rail { font-size: 13px; padding-left: 10px; }
|
||||
@media (max-width: 720px) {
|
||||
.wrap { padding: 20px 16px 40px; }
|
||||
body > footer { padding: 0 16px 24px; }
|
||||
.stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.stat .value { font-size: 24px; }
|
||||
.actions { margin-left: 0; width: 100%; }
|
||||
th, td { padding: 8px 10px; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@ -301,17 +546,21 @@ body > footer {
|
||||
<div class="wrap">
|
||||
|
||||
<header>
|
||||
<h1>Closed ticket board</h1>
|
||||
<h1>Help desk dashboard</h1>
|
||||
<div class="status" id="status" data-auth="disconnected">
|
||||
<span class="dot"></span><span id="statusText">Not connected</span>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="toggle" id="soundToggle" aria-pressed="true" onclick="toggleSound()">
|
||||
<span class="track"></span>Sound on close
|
||||
</button>
|
||||
<button class="quiet" id="checkBtn" onclick="checkNow(this)" hidden>Refresh now</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="meta" id="meta"></div>
|
||||
|
||||
<div id="main"></div>
|
||||
|
||||
<footer id="footer"></footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
@ -327,11 +576,22 @@ function beep() {
|
||||
});
|
||||
}
|
||||
|
||||
// the ding is on by default; muting is remembered per browser
|
||||
let soundOn = true;
|
||||
try { soundOn = localStorage.getItem('sdp-sound') !== 'off'; } catch (err) {}
|
||||
|
||||
function toggleSound() {
|
||||
soundOn = !soundOn;
|
||||
try { localStorage.setItem('sdp-sound', soundOn ? 'on' : 'off'); } catch (err) {}
|
||||
$('soundToggle').setAttribute('aria-pressed', String(soundOn));
|
||||
if (soundOn) beep();
|
||||
}
|
||||
$('soundToggle').setAttribute('aria-pressed', String(soundOn));
|
||||
|
||||
let data = null;
|
||||
let seen = new Set();
|
||||
let firstLoad = true;
|
||||
let flashTechs = new Set();
|
||||
|
||||
const STATUS_TEXT = {
|
||||
disconnected: 'Not connected',
|
||||
@ -369,16 +629,22 @@ async function load() {
|
||||
function render() {
|
||||
if (!data) return;
|
||||
|
||||
if (!firstLoad && data.tickets.some(t => !seen.has(t.id))) beep(); //Calls Beep
|
||||
|
||||
// the watcher: anything we haven't seen before is a fresh close
|
||||
const fresh = data.tickets.filter(t => !seen.has(t.id));
|
||||
flashTechs = new Set(firstLoad ? [] : fresh.map(t => t.technician));
|
||||
if (!firstLoad && fresh.length && soundOn) beep();
|
||||
|
||||
$('status').dataset.auth = data.auth;
|
||||
$('statusText').textContent = STATUS_TEXT[data.auth] || data.auth;
|
||||
$('checkBtn').hidden = data.auth !== 'connected';
|
||||
|
||||
const wl = data.workload || {};
|
||||
$('meta').innerHTML = [
|
||||
'Checks every ' + data.checkMinutes + ' min',
|
||||
'Window ' + data.windowMinutes + ' min',
|
||||
'Closed tickets checked every ' + data.checkMinutes + ' min',
|
||||
'Status counts every ' + data.workloadMinutes + ' min',
|
||||
data.watching.length ? 'Watching ' + data.watching.length + ' technicians' : 'Watching all technicians',
|
||||
data.scope && data.scope.sites.length ? 'Sites: ' + data.scope.sites.join(', ') : '',
|
||||
data.scope && data.scope.groups.length ? 'Groups: ' + data.scope.groups.join(', ') : '',
|
||||
data.lastCheck ? 'Last check ' + clockTime(data.lastCheck) : ''
|
||||
].filter(Boolean).map(t => '<span>' + esc(t) + '</span>').join('');
|
||||
|
||||
@ -393,45 +659,214 @@ function render() {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (data.auth === 'pending') {
|
||||
if (data.auth === 'pending' && !(data.userCode && data.verificationUrl)) {
|
||||
// Zoho hasn't handed back the device code yet; nothing to open until it does
|
||||
html += `<div class="panel">
|
||||
<h2>Approve the board</h2>
|
||||
<p>Open the authorization page and confirm this code matches the one shown there.</p>
|
||||
<div class="code">${esc(data.userCode || '...')}</div>
|
||||
<a class="button" href="${esc(data.verificationUrl || '#')}">Open authorization page</a>
|
||||
<p>Requesting a device code from Zoho…</p>
|
||||
</div>`;
|
||||
} else if (data.auth === 'pending') {
|
||||
html += `<div class="panel">
|
||||
<h2>Approve the board</h2>
|
||||
<p>The authorization page opens in a new tab. Confirm this code matches the one shown there,
|
||||
approve it, then close that tab. This page picks up the connection on its own.</p>
|
||||
<div class="code">${esc(data.userCode)}</div>
|
||||
<a class="button" href="${esc(data.verificationUrl)}" target="_blank" rel="noopener noreferrer">Open authorization page ↗</a>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (data.lastError) {
|
||||
html += `<div class="error"><strong>Last check failed</strong>${esc(data.lastError)}</div>`;
|
||||
}
|
||||
|
||||
if (data.auth === 'connected') {
|
||||
const tallies = {};
|
||||
for (const t of data.tickets) tallies[t.technician] = (tallies[t.technician] || 0) + 1;
|
||||
const names = Object.keys(tallies).sort((a, b) => tallies[b] - tallies[a]);
|
||||
|
||||
if (names.length) {
|
||||
html += '<div class="tallies">' + names.map(n =>
|
||||
`<span class="tally">${esc(n)} <b>${tallies[n]}</b></span>`).join('') + '</div>';
|
||||
}
|
||||
|
||||
if (!data.tickets.length) {
|
||||
html += `<div class="empty"><p>Nothing closed yet.</p></div>`;
|
||||
} else {
|
||||
html += '<div class="feed">' + data.tickets.map(renderTicket).join('') + '</div>';
|
||||
}
|
||||
|
||||
html += '<p style="margin-top:16px"><button class="quiet" onclick="checkNow(this)">Check now</button></p>';
|
||||
if (wl.error) {
|
||||
html += `<div class="error"><strong>Status counts failed</strong>${esc(wl.error)}</div>`;
|
||||
}
|
||||
if (data.unassigned && data.unassigned.error) {
|
||||
html += `<div class="error"><strong>Unassigned count failed</strong>${esc(data.unassigned.error)}</div>`;
|
||||
}
|
||||
|
||||
$('main').innerHTML = html;
|
||||
if (data.auth === 'connected') {
|
||||
html += renderStats(wl);
|
||||
html += `<div class="board">
|
||||
${renderWorkload(wl)}
|
||||
<div class="side">
|
||||
${renderWeather()}
|
||||
${renderFeed()}
|
||||
</div>
|
||||
</div>`;
|
||||
html += renderUnassignedList(data.unassigned || {});
|
||||
}
|
||||
|
||||
$('footer').textContent = data.tickets && data.tickets.length
|
||||
? data.tickets.length + ' tickets on the board'
|
||||
// keep the verification list open across the 5s re-renders
|
||||
const verifyOpen = !!document.querySelector('.verify[open]');
|
||||
$('main').innerHTML = html;
|
||||
if (verifyOpen) document.querySelector('.verify').open = true;
|
||||
|
||||
for (const t of data.tickets) seen.add(t.id);
|
||||
firstLoad = false;
|
||||
}
|
||||
|
||||
function renderStats(wl) {
|
||||
const techs = wl.technicians || [];
|
||||
const loaded = !!wl.updatedAt && !wl.error;
|
||||
const open = techs.reduce((n, t) => n + t.open, 0);
|
||||
const closedToday = techs.reduce((n, t) => n + t.closedToday, 0);
|
||||
const hourAgo = Date.now() - 60 * 60 * 1000;
|
||||
const lastHour = data.tickets.filter(t => t.closedAt >= hourAgo).length;
|
||||
const busiest = techs.slice().sort((a, b) => b.open - a.open)[0];
|
||||
const top = (wl.statuses || [])[0];
|
||||
|
||||
const tile = (label, value, sub, accent) => `<div class="stat${accent ? ' accent' : ''}">
|
||||
<div class="label">${esc(label)}</div>
|
||||
<div class="value">${esc(value)}</div>
|
||||
<div class="sub">${esc(sub || ' ')}</div>
|
||||
</div>`;
|
||||
|
||||
const un = data.unassigned || {};
|
||||
const unLoaded = !!un.updatedAt && !un.error;
|
||||
|
||||
return '<div class="stats">'
|
||||
+ tile('Unassigned requests', unLoaded ? un.count : '–', unLoaded ? 'checked ' + clockTime(un.updatedAt) + (un.truncated ? ', partial' : '') : '')
|
||||
+ tile('Open requests', loaded ? open : '–', top ? top.total + ' ' + top.name : '')
|
||||
+ tile('Closed today', loaded ? closedToday : '–', 'since midnight', true)
|
||||
+ tile('Closed last hour', lastHour, data.tickets[0] ? 'latest #' + data.tickets[0].id : 'none yet', true)
|
||||
+ tile('Most open', busiest && busiest.open ? busiest.open : '–', busiest && busiest.open ? busiest.name : '')
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
function renderWorkload(wl) {
|
||||
const statuses = wl.statuses || [];
|
||||
const techs = wl.technicians || [];
|
||||
|
||||
const note = wl.updatedAt
|
||||
? 'Updated ' + clockTime(wl.updatedAt) + (wl.truncated ? ' · partial: ' + wl.truncated : '')
|
||||
: 'Loading…';
|
||||
|
||||
let body;
|
||||
if (!wl.updatedAt) {
|
||||
body = '<div class="table-empty">Counting tickets…</div>';
|
||||
} else if (!techs.length) {
|
||||
body = '<div class="table-empty">No open tickets for the watched technicians.</div>';
|
||||
} else {
|
||||
const max = Math.max(1, ...techs.flatMap(t => statuses.map(s => t.counts[s.name] || 0)));
|
||||
const cell = (n, cls) => n
|
||||
? `<td class="num ${cls || ''}" style="--heat:${cls ? 0 : (n / max * 0.9 + 0.1).toFixed(3)}">${n}</td>`
|
||||
: `<td class="num zero ${cls || ''}">·</td>`;
|
||||
|
||||
// totals come right after the name so they stay visible when the status columns scroll off
|
||||
const head = '<tr><th scope="col">Technician</th>'
|
||||
+ '<th scope="col" class="total">Total open</th><th scope="col" class="closed">Closed today</th>'
|
||||
+ statuses.map(s => `<th scope="col"${s.color ? ` style="--status-color:${s.color}"` : ''}><span class="swatch"></span>${esc(s.name)}</th>`).join('')
|
||||
+ '</tr>';
|
||||
|
||||
const rows = techs.map(t => `<tr${flashTechs.has(t.name) ? ' class="flash"' : ''}>
|
||||
<td>${esc(t.name)}</td>
|
||||
${cell(t.open, 'total')}
|
||||
${cell(t.closedToday, 'closed')}
|
||||
${statuses.map(s => cell(t.counts[s.name] || 0)).join('')}
|
||||
</tr>`).join('');
|
||||
|
||||
const sum = key => techs.reduce((n, t) => n + t[key], 0);
|
||||
const foot = '<tr><td>All technicians</td>'
|
||||
+ `<td class="total">${sum('open')}</td><td class="closed">${sum('closedToday')}</td>`
|
||||
+ statuses.map(s => `<td>${s.total}</td>`).join('')
|
||||
+ '</tr>';
|
||||
|
||||
body = `<div class="table-scroll"><table>
|
||||
<thead>${head}</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
<tfoot>${foot}</tfoot>
|
||||
</table></div>`;
|
||||
}
|
||||
|
||||
return `<section class="section">
|
||||
<div class="section-head"><h2>Tickets by technician</h2><span class="note">${esc(note)}</span></div>
|
||||
${body}
|
||||
</section>`;
|
||||
}
|
||||
|
||||
const iconUrl = (code, size) => `https://openweathermap.org/img/wn/${code}@${size}x.png`;
|
||||
|
||||
function renderWeather() {
|
||||
if (!weather || !weather.enabled) return '';
|
||||
|
||||
if (weather.temp === undefined) {
|
||||
return `<section class="section">
|
||||
<div class="section-head"><h2>Weather</h2></div>
|
||||
<div class="table-empty">${weather.error ? 'Weather unavailable: ' + esc(weather.error) : 'Loading…'}</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
const today = new Date().toLocaleDateString('en-CA'); // YYYY-MM-DD in local time
|
||||
const days = (weather.forecast || []).map(d => `<div class="forecast-day">
|
||||
<div class="name">${d.date === today ? 'Today' : new Date(d.date + 'T12:00').toLocaleDateString([], { weekday: 'short' })}</div>
|
||||
${d.icon ? `<img src="${iconUrl(d.icon, 2)}" alt="${esc(d.condition || '')}">` : ''}
|
||||
<div>${d.high}° <span class="lo">${d.low}°</span></div>
|
||||
<div class="pop">${d.pop >= 20 ? d.pop + '%' : ' '}</div>
|
||||
</div>`).join('');
|
||||
|
||||
const note = (weather.error ? 'stale · ' : '') + 'as of ' + clockTime(weather.updatedAt);
|
||||
|
||||
return `<section class="section">
|
||||
<div class="section-head"><h2>${esc(weather.place || 'Weather')}</h2><span class="note">${esc(note)}</span></div>
|
||||
<div class="weather-now">
|
||||
${weather.icon ? `<img src="${iconUrl(weather.icon, 4)}" alt="">` : '<span></span>'}
|
||||
<div>
|
||||
<div class="weather-temp">${weather.temp}°F</div>
|
||||
<div class="weather-desc">${esc(weather.description || weather.condition || '')}</div>
|
||||
</div>
|
||||
<div class="weather-details">
|
||||
<span>Feels <b>${weather.feelsLike}°</b></span>
|
||||
<span>Wind <b>${weather.wind} mph</b></span>
|
||||
<span>Humidity <b>${weather.humidity}%</b></span>
|
||||
${weather.sunset ? `<span>Sunset <b>${clockTime(weather.sunset)}</b></span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
${days ? `<div class="forecast">${days}</div>` : ''}
|
||||
</section>`;
|
||||
}
|
||||
|
||||
// Debug list for comparing against the ManageEngine "Central Region Unassigned" view.
|
||||
// Verified matching on 2026-10-15; flip to true to bring it back.
|
||||
const SHOW_UNASSIGNED_LIST = false;
|
||||
|
||||
function renderUnassignedList(un) {
|
||||
if (!SHOW_UNASSIGNED_LIST || !un.updatedAt || un.error) return '';
|
||||
|
||||
const rows = (un.tickets || []).map(t => `<tr>
|
||||
<td class="num">#${esc(t.id)}</td>
|
||||
<td class="subject-cell">${esc(t.subject || 'No subject')}</td>
|
||||
<td>${esc(t.status || '')}</td>
|
||||
<td>${esc(t.site || '')}</td>
|
||||
<td>${esc(t.group || '')}</td>
|
||||
<td>${esc(t.created || '')}</td>
|
||||
</tr>`).join('');
|
||||
|
||||
const warn = un.discarded
|
||||
? `<div class="warn">The API returned ${un.discarded} extra request(s) that failed the local check, so the search criteria aren't matching the view exactly.</div>`
|
||||
: '';
|
||||
|
||||
firstLoad = false;
|
||||
return `<details class="verify">
|
||||
<summary>Verify: ${un.count} unassigned request${un.count === 1 ? '' : 's'} (temporary, click to expand)</summary>
|
||||
${warn}
|
||||
${rows
|
||||
? `<div class="table-scroll"><table>
|
||||
<thead><tr><th>ID</th><th>Subject</th><th>Status</th><th>Site</th><th>Group</th><th>Created</th></tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table></div>`
|
||||
: '<div class="table-empty">No unassigned requests.</div>'}
|
||||
</details>`;
|
||||
}
|
||||
|
||||
function renderFeed() {
|
||||
const list = data.tickets.length
|
||||
? '<div class="feed">' + data.tickets.map(renderTicket).join('') + '</div>'
|
||||
: '<div class="empty"><p>Nothing closed yet.</p></div>';
|
||||
|
||||
return `<section class="section">
|
||||
<div class="section-head"><h2>Recently closed</h2><span class="note">last ${data.windowMinutes} min, kept 12h</span></div>
|
||||
${list}
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function renderTicket(ticket) {
|
||||
@ -440,7 +875,6 @@ function renderTicket(ticket) {
|
||||
// 0 = just closed, 1 = fully aged out (one hour on)
|
||||
const fade = Math.min(age / (60 * 60 * 1000), 1).toFixed(3);
|
||||
const isNew = !firstLoad && !seen.has(ticket.id);
|
||||
seen.add(ticket.id);
|
||||
|
||||
return `<article class="ticket${isNew ? ' new' : ''}" style="--age:${fade}">
|
||||
<div class="rail">
|
||||
@ -458,18 +892,40 @@ async function connect(btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Connecting...';
|
||||
await fetch('/api/connect', { method: 'POST' });
|
||||
load();
|
||||
// check quickly until the device code arrives instead of waiting on the 5s poll
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await load();
|
||||
if (!data || data.auth !== 'pending' || data.userCode) break;
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
}
|
||||
|
||||
async function checkNow(btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Checking...';
|
||||
await fetch('/api/check', { method: 'POST' });
|
||||
btn.textContent = 'Refreshing...';
|
||||
try {
|
||||
await fetch('/api/check', { method: 'POST' });
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Refresh now';
|
||||
}
|
||||
load();
|
||||
}
|
||||
|
||||
let weather = null;
|
||||
|
||||
async function loadWeather() {
|
||||
try {
|
||||
const res = await fetch('/api/weather');
|
||||
weather = await res.json();
|
||||
render();
|
||||
} catch (err) {}
|
||||
}
|
||||
|
||||
load();
|
||||
loadWeather();
|
||||
setInterval(load, 5000); // pull fresh state from the server
|
||||
setInterval(loadWeather, 10 * 60 * 1000); // server caches for 10 min, no point asking sooner
|
||||
setInterval(render, 20000); // re-age the rails between pulls
|
||||
</script>
|
||||
<footer>
|
||||
@ -477,4 +933,3 @@ setInterval(render, 20000); // re-age the rails between pulls
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
@ -1,8 +1,12 @@
|
||||
const express = require('express');
|
||||
|
||||
function createRoutes(boardService) {
|
||||
function createRoutes(boardService, weatherService) {
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/weather', async (req, res) => {
|
||||
res.json(await weatherService.getWeather());
|
||||
});
|
||||
|
||||
router.get('/state', (req, res) => {
|
||||
res.json(boardService.getState());
|
||||
});
|
||||
|
||||
@ -5,8 +5,9 @@ const path = require('path');
|
||||
const express = require('express');
|
||||
const { createBoardService } = require('./boardService');
|
||||
const { createRoutes } = require('./routes');
|
||||
const { createWeatherService } = require('./weather');
|
||||
|
||||
const { CLIENT_ID, CLIENT_SECRET, TECHNICIAN_NAMES, STATUS_NAMES } = process.env;
|
||||
const { CLIENT_ID, CLIENT_SECRET, TECHNICIAN_NAMES, STATUS_NAMES, SITE_NAMES, GROUP_NAMES, OWM_API_KEY, OWM_ZIP_CODE } = process.env;
|
||||
const PORT = process.env.PORT || 3000; // default 3000
|
||||
|
||||
if (!CLIENT_ID || !CLIENT_SECRET) {
|
||||
@ -14,12 +15,23 @@ if (!CLIENT_ID || !CLIENT_SECRET) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const technicians = (TECHNICIAN_NAMES ? TECHNICIAN_NAMES.split(',') : []).map(t => t.trim()).filter(Boolean);
|
||||
const statuses = (STATUS_NAMES ? STATUS_NAMES.split(',') : []).map(s => s.trim()).filter(Boolean);
|
||||
const toList = value => (value ? value.split(',') : []).map(v => v.trim()).filter(Boolean);
|
||||
|
||||
const technicians = toList(TECHNICIAN_NAMES);
|
||||
const statuses = toList(STATUS_NAMES);
|
||||
const statusSet = new Set(statuses.map(s => s.toLowerCase()));
|
||||
// statuses that count as done; everything else shows up in the workload table
|
||||
const closedStatuses = statuses.length ? statuses : ['Closed', 'Resolved', 'Canceled'];
|
||||
// statuses the unassigned counter ignores, to match the ManageEngine view
|
||||
const unassignedExcludedStatuses = process.env.UNASSIGNED_EXCLUDED_STATUSES !== undefined
|
||||
? toList(process.env.UNASSIGNED_EXCLUDED_STATUSES)
|
||||
: closedStatuses;
|
||||
// restricts every query to these sites/groups (the region); empty means no restriction
|
||||
const scope = { sites: toList(SITE_NAMES), groups: toList(GROUP_NAMES) };
|
||||
|
||||
const WINDOW_MINUTES = 10; // how far back the server looks in minutes
|
||||
const CHECK_MINUTES = 1; // how often the server checks for new tickets in minutes
|
||||
const WORKLOAD_MINUTES = Number(process.env.WORKLOAD_MINUTES) || 5; // how often the status table is recounted in minutes
|
||||
|
||||
const app = express();
|
||||
|
||||
@ -29,13 +41,20 @@ const boardService = createBoardService({
|
||||
clientSecret: CLIENT_SECRET,
|
||||
technicians,
|
||||
statusSet,
|
||||
closedStatuses,
|
||||
unassignedExcludedStatuses,
|
||||
scope,
|
||||
windowMinutes: WINDOW_MINUTES,
|
||||
checkMinutes: CHECK_MINUTES
|
||||
checkMinutes: CHECK_MINUTES,
|
||||
workloadMinutes: WORKLOAD_MINUTES
|
||||
});
|
||||
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
app.use('/api', createRoutes(boardService));
|
||||
// weather widget is optional; it stays hidden unless both values are set
|
||||
const weatherService = createWeatherService({ apiKey: OWM_API_KEY, zipCode: OWM_ZIP_CODE });
|
||||
|
||||
app.use('/api', createRoutes(boardService, weatherService));
|
||||
|
||||
//starts listening on port specified
|
||||
app.listen(PORT, () => {
|
||||
|
||||
288
src/tickets.js
288
src/tickets.js
@ -1,5 +1,9 @@
|
||||
const axios = require('axios');
|
||||
|
||||
const API_URL = 'https://ithelp.mainecc.edu/api/v3/requests';
|
||||
const PAGE_SIZE = 100; // SDP caps row_count at 100 per page
|
||||
const MAX_PAGES = 20; // safety net so a bad filter can't page forever
|
||||
|
||||
function toMillis(completedTime) {
|
||||
if (!completedTime) return null;
|
||||
const raw = typeof completedTime === 'object' ? completedTime.value : completedTime;
|
||||
@ -14,47 +18,121 @@ function getRelevantTicketTime(ticket) {
|
||||
|| null;
|
||||
}
|
||||
|
||||
async function fetchClosedTickets({ accessToken, state, tickets, technicians, statusSet, windowMinutes }) {
|
||||
function describeError(err) {
|
||||
const detail = err.response?.data;
|
||||
console.error('Error: ', detail || err.message);
|
||||
return detail?.response_status?.messages?.[0]?.message
|
||||
|| (typeof detail === 'string' ? detail : null)
|
||||
|| err.message;
|
||||
}
|
||||
|
||||
// joins search criteria with AND, skipping any that are null
|
||||
function allOf(...criteria) {
|
||||
return criteria.flat().filter(Boolean).map((c, i) => (i ? { ...c, logical_operator: 'AND' } : c));
|
||||
}
|
||||
|
||||
// limits a query to the configured sites/groups (the region)
|
||||
function scopeCriteria(scope) {
|
||||
return [
|
||||
scope.sites.length ? { field: 'site.name', condition: 'is', values: scope.sites } : null,
|
||||
scope.groups.length ? { field: 'group.name', condition: 'is', values: scope.groups } : null
|
||||
];
|
||||
}
|
||||
|
||||
// same check done locally, in case the API ever returns something outside the region
|
||||
function inScope(ticket, scope) {
|
||||
const matches = (list, name) => !list.length
|
||||
|| (!!name && list.some(item => item.toLowerCase() === name.toLowerCase()));
|
||||
return matches(scope.sites, ticket.site?.name) && matches(scope.groups, ticket.group?.name);
|
||||
}
|
||||
|
||||
async function getRequests(accessToken, listInfo) {
|
||||
const response = await axios.get(API_URL, {
|
||||
params: { input_data: JSON.stringify({ list_info: listInfo }) },
|
||||
headers: {
|
||||
Authorization: `Zoho-oauthtoken ${accessToken}`,
|
||||
Accept: 'application/vnd.manageengine.sdp.v3+json'
|
||||
}
|
||||
});
|
||||
return response.data || {};
|
||||
}
|
||||
|
||||
async function getAllRequests(accessToken, listInfo, label) {
|
||||
const byId = new Map();
|
||||
let totalCount = null;
|
||||
let pages = 0;
|
||||
let duplicates = 0;
|
||||
let truncated = true;
|
||||
|
||||
for (let page = 0; page < MAX_PAGES; page++) {
|
||||
const data = await getRequests(accessToken, {
|
||||
...listInfo,
|
||||
row_count: PAGE_SIZE,
|
||||
start_index: page * PAGE_SIZE + 1,
|
||||
get_total_count: page === 0
|
||||
});
|
||||
pages++;
|
||||
if (page === 0) totalCount = data.list_info?.total_count ?? null;
|
||||
|
||||
// dedupe by id so a page that repeats (or shifts as tickets change) can't double count
|
||||
let added = 0;
|
||||
for (const ticket of data.requests || []) {
|
||||
const id = String(ticket.id ?? ticket.display_id);
|
||||
if (byId.has(id)) duplicates++;
|
||||
else { byId.set(id, ticket); added++; }
|
||||
}
|
||||
|
||||
if (!data.list_info?.has_more_rows) { truncated = false; break; }
|
||||
// more matches than we could ever page through almost always means the API ignored our
|
||||
// search criteria; don't spend 19 more calls downloading the wrong data
|
||||
if (totalCount > PAGE_SIZE * MAX_PAGES) break;
|
||||
// a full page of repeats means start_index isn't moving us forward; stop rather than loop
|
||||
if (!added) break;
|
||||
}
|
||||
|
||||
const requests = [...byId.values()];
|
||||
if (truncated || duplicates) {
|
||||
console.warn(`[${label}] pages=${pages} unique=${requests.length} duplicates=${duplicates} total_count=${totalCount} truncated=${truncated}`);
|
||||
}
|
||||
return { requests, truncated, totalCount };
|
||||
}
|
||||
|
||||
async function fetchClosedTickets({ accessToken, state, tickets, technicians, statusSet, scope, windowMinutes }) {
|
||||
if (!accessToken) return;
|
||||
|
||||
try {
|
||||
const windowStart = Date.now() - windowMinutes * 60 * 1000;
|
||||
|
||||
const listInfo = {
|
||||
list_info: {
|
||||
row_count: 200,
|
||||
sort_field: 'last_updated_time',
|
||||
sort_order: 'desc',
|
||||
fields_required: [
|
||||
'display_id',
|
||||
'subject',
|
||||
'status',
|
||||
'completed_time',
|
||||
'resolved_time',
|
||||
'last_updated_time',
|
||||
'technician'
|
||||
],
|
||||
search_criteria: [
|
||||
{
|
||||
field: 'last_updated_time',
|
||||
condition: 'greater_than',
|
||||
value: windowStart.toString()
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
const response = await axios.get('https://ithelp.mainecc.edu/api/v3/requests', {
|
||||
params: { input_data: JSON.stringify(listInfo) },
|
||||
headers: {
|
||||
Authorization: `Zoho-oauthtoken ${accessToken}`,
|
||||
Accept: 'application/vnd.manageengine.sdp.v3+json'
|
||||
}
|
||||
const data = await getRequests(accessToken, {
|
||||
row_count: 200,
|
||||
sort_field: 'last_updated_time',
|
||||
sort_order: 'desc',
|
||||
fields_required: [
|
||||
'display_id',
|
||||
'subject',
|
||||
'status',
|
||||
'completed_time',
|
||||
'resolved_time',
|
||||
'last_updated_time',
|
||||
'technician',
|
||||
'site',
|
||||
'group'
|
||||
],
|
||||
search_criteria: allOf(
|
||||
{
|
||||
field: 'last_updated_time',
|
||||
condition: 'greater than',
|
||||
value: windowStart.toString()
|
||||
},
|
||||
scopeCriteria(scope)
|
||||
)
|
||||
});
|
||||
|
||||
const found = response.data?.requests || [];
|
||||
const found = data.requests || [];
|
||||
|
||||
for (const ticket of found) {
|
||||
if (!inScope(ticket, scope)) continue;
|
||||
|
||||
const statusName = ticket.status?.name || '';
|
||||
const statusInternalName = ticket.status?.internal_name || '';
|
||||
const statusMatches = !statusSet.size
|
||||
@ -90,15 +168,149 @@ async function fetchClosedTickets({ accessToken, state, tickets, technicians, st
|
||||
state.lastCheck = Date.now();
|
||||
state.lastError = null;
|
||||
} catch (err) {
|
||||
const detail = err.response?.data;
|
||||
state.lastError = detail?.response_status?.messages?.[0]?.message
|
||||
|| (typeof detail === 'string' ? detail : null)
|
||||
|| err.message;
|
||||
state.lastError = describeError(err);
|
||||
state.lastCheck = Date.now();
|
||||
console.error('Error: ', detail || err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Counts every open ticket by technician and status, plus how many each
|
||||
// technician has closed since midnight.
|
||||
async function fetchWorkload({ accessToken, workload, technicians, closedStatuses, scope }) {
|
||||
if (!accessToken) return;
|
||||
|
||||
const startOfDay = new Date();
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
|
||||
const closedLower = new Set(closedStatuses.map(s => s.toLowerCase()));
|
||||
const byLowerName = new Map(technicians.map(t => [t.toLowerCase(), t]));
|
||||
const techCriterion = technicians.length
|
||||
? { field: 'technician.name', condition: 'is', values: technicians }
|
||||
: null;
|
||||
|
||||
// maps a ticket to the row it's counted under, or null to skip it
|
||||
function rowFor(ticket) {
|
||||
if (!inScope(ticket, scope)) return null;
|
||||
const name = ticket.technician?.name;
|
||||
if (!technicians.length) return name || 'Unassigned';
|
||||
return name ? byLowerName.get(name.toLowerCase()) || null : null;
|
||||
}
|
||||
|
||||
try {
|
||||
const open = await getAllRequests(accessToken, {
|
||||
sort_field: 'created_time',
|
||||
sort_order: 'desc',
|
||||
fields_required: ['display_id', 'status', 'technician', 'site', 'group'],
|
||||
search_criteria: allOf(
|
||||
{ field: 'status.name', condition: 'is not', values: closedStatuses },
|
||||
techCriterion,
|
||||
scopeCriteria(scope)
|
||||
)
|
||||
}, 'open tickets');
|
||||
|
||||
const closed = await getAllRequests(accessToken, {
|
||||
sort_field: 'last_updated_time',
|
||||
sort_order: 'desc',
|
||||
fields_required: ['display_id', 'status', 'technician', 'site', 'group', 'completed_time', 'resolved_time', 'last_updated_time'],
|
||||
search_criteria: allOf(
|
||||
{ field: 'last_updated_time', condition: 'greater than', value: startOfDay.getTime().toString() },
|
||||
{ field: 'status.name', condition: 'is', values: closedStatuses },
|
||||
techCriterion,
|
||||
scopeCriteria(scope)
|
||||
)
|
||||
}, 'closed today');
|
||||
|
||||
const rows = new Map(technicians.map(t => [t, { name: t, counts: {}, open: 0, closedToday: 0 }]));
|
||||
const row = name => {
|
||||
if (!rows.has(name)) rows.set(name, { name, counts: {}, open: 0, closedToday: 0 });
|
||||
return rows.get(name);
|
||||
};
|
||||
const statuses = new Map();
|
||||
|
||||
for (const ticket of open.requests) {
|
||||
const status = ticket.status?.name;
|
||||
if (!status || closedLower.has(status.toLowerCase())) continue;
|
||||
const tech = rowFor(ticket);
|
||||
if (!tech) continue;
|
||||
|
||||
const r = row(tech);
|
||||
r.counts[status] = (r.counts[status] || 0) + 1;
|
||||
r.open++;
|
||||
|
||||
if (!statuses.has(status)) {
|
||||
const color = /^#[0-9a-f]{3,8}$/i.test(ticket.status.color || '') ? ticket.status.color : null;
|
||||
statuses.set(status, { name: status, color, total: 0 });
|
||||
}
|
||||
statuses.get(status).total++;
|
||||
}
|
||||
|
||||
for (const ticket of closed.requests) {
|
||||
const closedAt = getRelevantTicketTime(ticket);
|
||||
if (!closedAt || closedAt < startOfDay.getTime()) continue;
|
||||
const tech = rowFor(ticket);
|
||||
if (tech) row(tech).closedToday++;
|
||||
}
|
||||
|
||||
// Open first, then busiest statuses
|
||||
workload.statuses = [...statuses.values()].sort((a, b) =>
|
||||
(b.name === 'Open') - (a.name === 'Open') || b.total - a.total || a.name.localeCompare(b.name));
|
||||
workload.technicians = [...rows.values()];
|
||||
if (!technicians.length) workload.technicians.sort((a, b) => a.name.localeCompare(b.name));
|
||||
// which query hit the page cap, and how many tickets SDP says matched, so the page can explain it
|
||||
const cutOff = (label, result) => result.truncated
|
||||
&& `${label} (${result.requests.length} of ${result.totalCount ?? '?'})`;
|
||||
workload.truncated = [cutOff('open tickets', open), cutOff('closed today', closed)]
|
||||
.filter(Boolean).join(', ') || false;
|
||||
workload.updatedAt = Date.now();
|
||||
workload.error = null;
|
||||
} catch (err) {
|
||||
workload.error = describeError(err);
|
||||
workload.updatedAt = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
// Mirrors the "Central Region Unassigned" view: in-scope, not excluded, no technician.
|
||||
async function fetchUnassigned({ accessToken, unassigned, excludedStatuses, scope }) {
|
||||
if (!accessToken) return;
|
||||
|
||||
const excludedLower = new Set(excludedStatuses.map(s => s.toLowerCase()));
|
||||
|
||||
try {
|
||||
const result = await getAllRequests(accessToken, {
|
||||
sort_field: 'created_time',
|
||||
sort_order: 'desc',
|
||||
fields_required: ['display_id', 'subject', 'status', 'technician', 'site', 'group', 'created_time'],
|
||||
search_criteria: allOf(
|
||||
scopeCriteria(scope),
|
||||
excludedStatuses.length ? { field: 'status.name', condition: 'is not', values: excludedStatuses } : null,
|
||||
// "is empty": the API documents "technician is not empty" as condition 'is not' with no value
|
||||
{ field: 'technician', condition: 'is' }
|
||||
)
|
||||
}, 'unassigned');
|
||||
|
||||
unassigned.tickets = result.requests
|
||||
.filter(t => !t.technician && inScope(t, scope) && !excludedLower.has((t.status?.name || '').toLowerCase()))
|
||||
.map(t => ({
|
||||
id: t.display_id,
|
||||
subject: t.subject,
|
||||
status: t.status?.name || null,
|
||||
site: t.site?.name || null,
|
||||
group: t.group?.name || null,
|
||||
created: t.created_time?.display_value || null
|
||||
}));
|
||||
unassigned.count = unassigned.tickets.length;
|
||||
// how many the API returned that we threw out locally; non-zero means the criteria are off
|
||||
unassigned.discarded = result.requests.length - unassigned.count;
|
||||
unassigned.truncated = result.truncated;
|
||||
unassigned.updatedAt = Date.now();
|
||||
unassigned.error = null;
|
||||
} catch (err) {
|
||||
unassigned.error = describeError(err);
|
||||
unassigned.updatedAt = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchClosedTickets
|
||||
fetchClosedTickets,
|
||||
fetchWorkload,
|
||||
fetchUnassigned
|
||||
};
|
||||
107
src/weather.js
Normal file
107
src/weather.js
Normal file
@ -0,0 +1,107 @@
|
||||
const axios = require('axios');
|
||||
|
||||
const API_URL = 'https://api.openweathermap.org/data/2.5';
|
||||
const CACHE_MINUTES = 10; // OpenWeatherMap only updates about every 10 minutes anyway
|
||||
const FORECAST_DAYS = 5;
|
||||
|
||||
// only pass through icon codes like "10d" so the page can build the image URL safely
|
||||
function iconCode(icon) {
|
||||
return /^\d\d[dn]$/.test(icon || '') ? icon : null;
|
||||
}
|
||||
|
||||
// the 5 day forecast comes in 3 hour slots; roll them up into one entry per local day
|
||||
function dailyForecast(forecast) {
|
||||
const offset = (forecast.city?.timezone || 0) * 1000;
|
||||
const days = new Map();
|
||||
|
||||
for (const slot of forecast.list || []) {
|
||||
const local = new Date(slot.dt * 1000 + offset);
|
||||
const key = local.toISOString().slice(0, 10);
|
||||
if (!days.has(key)) days.set(key, { date: key, low: Infinity, high: -Infinity, pop: 0, noon: null });
|
||||
|
||||
const day = days.get(key);
|
||||
day.low = Math.min(day.low, slot.main.temp_min);
|
||||
day.high = Math.max(day.high, slot.main.temp_max);
|
||||
day.pop = Math.max(day.pop, slot.pop || 0);
|
||||
|
||||
// the slot nearest midday best represents the day's conditions
|
||||
const distance = Math.abs(local.getUTCHours() - 12);
|
||||
if (!day.noon || distance < day.noon.distance) day.noon = { distance, weather: slot.weather?.[0] };
|
||||
}
|
||||
|
||||
return [...days.values()].slice(0, FORECAST_DAYS).map(day => ({
|
||||
date: day.date,
|
||||
low: Math.round(day.low),
|
||||
high: Math.round(day.high),
|
||||
pop: Math.round(day.pop * 100),
|
||||
condition: day.noon?.weather?.main || null,
|
||||
icon: iconCode(day.noon?.weather?.icon)
|
||||
}));
|
||||
}
|
||||
|
||||
function createWeatherService({ apiKey, zipCode }) {
|
||||
const enabled = !!(apiKey && zipCode);
|
||||
// US zip by default; "12345,ca" style values pass through untouched
|
||||
const zip = zipCode && zipCode.includes(',') ? zipCode : `${zipCode},us`;
|
||||
|
||||
let cache = null;
|
||||
let fetchedAt = 0;
|
||||
let pending = null;
|
||||
let lastError = null;
|
||||
|
||||
async function fetchWeather() {
|
||||
const params = { zip, units: 'imperial', appid: apiKey };
|
||||
const [current, forecast] = await Promise.all([
|
||||
axios.get(`${API_URL}/weather`, { params }),
|
||||
axios.get(`${API_URL}/forecast`, { params })
|
||||
]);
|
||||
|
||||
const c = current.data;
|
||||
return {
|
||||
place: c.name,
|
||||
updatedAt: c.dt * 1000,
|
||||
temp: Math.round(c.main.temp),
|
||||
feelsLike: Math.round(c.main.feels_like),
|
||||
high: Math.round(c.main.temp_max),
|
||||
low: Math.round(c.main.temp_min),
|
||||
humidity: c.main.humidity,
|
||||
wind: Math.round(c.wind?.speed || 0),
|
||||
condition: c.weather?.[0]?.main || null,
|
||||
description: c.weather?.[0]?.description || null,
|
||||
icon: iconCode(c.weather?.[0]?.icon),
|
||||
sunrise: c.sys?.sunrise ? c.sys.sunrise * 1000 : null,
|
||||
sunset: c.sys?.sunset ? c.sys.sunset * 1000 : null,
|
||||
forecast: dailyForecast(forecast.data)
|
||||
};
|
||||
}
|
||||
|
||||
async function getWeather() {
|
||||
if (!enabled) return { enabled: false };
|
||||
|
||||
if (Date.now() - fetchedAt > CACHE_MINUTES * 60 * 1000) {
|
||||
// share one request between callers that arrive while it's in flight
|
||||
pending = pending || fetchWeather()
|
||||
.then(data => {
|
||||
cache = data;
|
||||
lastError = null;
|
||||
})
|
||||
.catch(err => {
|
||||
lastError = err.response?.data?.message || err.message;
|
||||
console.error('Weather error: ', lastError);
|
||||
})
|
||||
.finally(() => {
|
||||
fetchedAt = Date.now();
|
||||
pending = null;
|
||||
});
|
||||
await pending;
|
||||
}
|
||||
|
||||
return { enabled: true, error: lastError, ...cache };
|
||||
}
|
||||
|
||||
return { getWeather };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createWeatherService
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user