diff --git a/README.md b/README.md index f44e51b..f1bfa0c 100644 --- a/README.md +++ b/README.md @@ -36,9 +36,19 @@ screenly edge-app instance create ## Configuration -| Setting | Description | Required | Default | -| --------- | ------------------------------- | -------- | ------------------ | -| `message` | The message displayed on screen | No | `Hello, Screenly!` | +Settings are defined in `screenly.yml` and mirrored in `screenly_qc.yml` (used for the QC/staging build). + +| Setting | Description | Required | Default | +| ------------------- | ----------------------------------------------------------------------------------------------------------- | -------- | ------- | +| `access_token` | For testing only. Production support for Puzzel ID OAuth is not implemented yet. | No | — | +| `customer_key` | Your Puzzel customer number. | Yes | — | +| `display_errors` | For debugging purposes to display errors on the screen. | No | `false` | +| `override_locale` | Override the default locale with a supported language code (e.g., en_US, fr_FR, de_DE). | No | `en` | +| `override_timezone` | Override the default timezone with a supported timezone identifier (e.g., Europe/London, America/New_York). | No | — | +| `refresh_interval` | How often to refresh dashboard data, in seconds. | No | `15` | +| `sentry_dsn` | Sentry Client Key from Sentry SDK for error capturing. | No | — | +| `user_group_name` | Filter agent statuses to a specific Puzzel user group. Leave blank to show all agents. | No | — | +| `user_id` | The Puzzel user ID associated with your OIDC client's Product Access. Required for API requests to succeed. | Yes | — | ## Screenshots diff --git a/bun.lock b/bun.lock index a95ef53..dfddb19 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,10 @@ "workspaces": { "": { "name": "puzzel-dashboard-app", + "dependencies": { + "chart.js": "^4.5.1", + "lit-html": "^3.3.3", + }, "devDependencies": { "@playwright/test": "^1.58.0", "@screenly/edge-apps": "^1.4.0", @@ -185,6 +189,8 @@ "@jsheaven/status-message": ["@jsheaven/status-message@1.1.2", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-a9ye8kre8pBBtL7zKxDVoaVO+PJjnPLcim71IX0fVpV8OkBk6rvL97c2E8outOZgs3sKBqFfY44kx5wal3DRpA=="], + "@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="], + "@lezer/common": ["@lezer/common@1.5.2", "", {}, "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ=="], "@lezer/lr": ["@lezer/lr@1.4.10", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A=="], @@ -503,6 +509,8 @@ "@types/tough-cookie": ["@types/tough-cookie@4.0.5", "", {}, "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="], + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.67.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.67.0", "@typescript-eslint/type-utils": "8.67.0", "@typescript-eslint/utils": "8.67.0", "@typescript-eslint/visitor-keys": "8.67.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.67.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.67.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.67.0", "@typescript-eslint/types": "8.67.0", "@typescript-eslint/typescript-estree": "8.67.0", "@typescript-eslint/visitor-keys": "8.67.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w=="], @@ -563,6 +571,8 @@ "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "chart.js": ["chart.js@4.5.1", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw=="], + "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], "chrome-trace-event": ["chrome-trace-event@1.0.4", "", {}, "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ=="], @@ -751,6 +761,8 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], + "lit-html": ["lit-html@3.3.3", "", { "dependencies": { "@types/trusted-types": "^2.0.2" } }, "sha512-el8M6jK2o3RXBnrSHX3ZKrsN8zEV63pSExTO1wYJz7QndGYZ8353e2a5PPX+qHe2aGayfnchQmkAojaWAREOIA=="], + "lmdb": ["lmdb@2.8.5", "", { "dependencies": { "msgpackr": "^1.9.5", "node-addon-api": "^6.1.0", "node-gyp-build-optional-packages": "5.1.1", "ordered-binary": "^1.4.1", "weak-lru-cache": "^1.2.2" }, "optionalDependencies": { "@lmdb/lmdb-darwin-arm64": "2.8.5", "@lmdb/lmdb-darwin-x64": "2.8.5", "@lmdb/lmdb-linux-arm": "2.8.5", "@lmdb/lmdb-linux-arm64": "2.8.5", "@lmdb/lmdb-linux-x64": "2.8.5", "@lmdb/lmdb-win32-x64": "2.8.5" }, "bin": { "download-lmdb-prebuilds": "bin/download-prebuilds.js" } }, "sha512-9bMdFfc80S+vSldBmG3HOuLVHnxRdNTlpzR6QDnzqCQtCzGUEAGTzBKYMeIM+I/sU4oZfgbcbS7X7F65/z/oxQ=="], "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], diff --git a/e2e/screenshots.lib.ts b/e2e/screenshots.lib.ts new file mode 100644 index 0000000..0f85694 --- /dev/null +++ b/e2e/screenshots.lib.ts @@ -0,0 +1,204 @@ +import type { Browser } from '@playwright/test' +import { + createMockScreenlyForScreenshots, + FIXED_SCREENSHOT_DATE, + getScreenshotsDir, + setupClockMock, + setupScreenlyJsMock, +} from '@screenly/edge-apps/test/screenshots' +import path from 'path' + +const MOCK_CUSTOMER_KEY = '12345' +const MOCK_USER_ID = '67890' + +export const MOCK_CREDENTIALS = { + token: 'mock-access-token', + metadata: {}, +} + +export const MOCK_QUEUES = [ + { + id: 1, + description: 'Support', + queueSize: 3, + agentsLoggedOn: 4, + agentsInPause: 1, + agentsReady: 3, + waitTimeMaxSeconds: 95, + waitTimeAverageSeconds: 32, + callsOfferedToday: 58, + callsAnsweredToday: 54, + ciqsOfferedToday: 6, + ciqsAnsweredToday: 5, + sla: 92, + }, + { + id: 2, + description: 'Sales', + queueSize: 1, + agentsLoggedOn: 3, + agentsInPause: 0, + agentsReady: 3, + waitTimeMaxSeconds: 40, + waitTimeAverageSeconds: 18, + callsOfferedToday: 21, + callsAnsweredToday: 20, + ciqsOfferedToday: 3, + ciqsAnsweredToday: 3, + sla: 98, + }, + { + id: 3, + description: 'Billing', + queueSize: 0, + agentsLoggedOn: 2, + agentsInPause: 1, + agentsReady: 1, + waitTimeMaxSeconds: 0, + waitTimeAverageSeconds: 0, + callsOfferedToday: 9, + callsAnsweredToday: 9, + ciqsOfferedToday: 0, + ciqsAnsweredToday: 0, + sla: 100, + }, +] + +export const MOCK_AGENTS = [ + { + userId: 1, + firstName: 'Ada', + lastName: 'Nilsen', + contactCentreStatus: 'LoggedOn', + userStatus: 'Available', + userGroupName: 'Support', + callsOffered: 12, + callsAnswered: 11, + }, + { + userId: 2, + firstName: 'Bo', + lastName: 'Eriksen', + contactCentreStatus: 'Pause', + userStatus: 'Busy', + userGroupName: 'Support', + callsOffered: 9, + callsAnswered: 9, + }, + { + userId: 3, + firstName: 'Cleo', + lastName: 'Vik', + contactCentreStatus: 'LoggedOn', + userStatus: 'Available', + userGroupName: 'Sales', + callsOffered: 15, + callsAnswered: 14, + }, + { + userId: 4, + firstName: 'Dan', + lastName: 'Aasen', + contactCentreStatus: 'LoggedOff', + userStatus: 'System', + userGroupName: 'Billing', + callsOffered: 4, + callsAnswered: 4, + }, +] + +export const { screenlyJsContent: dashboardScreenlyJsContent } = + createMockScreenlyForScreenshots( + { coordinates: [37.3861, -122.0839], location: 'Silicon Valley, USA' }, + { + customer_key: MOCK_CUSTOMER_KEY, + user_id: MOCK_USER_ID, + refresh_interval: '15', + display_errors: 'false', + screenly_oauth_tokens_url: 'http://localhost:3000/', + screenly_app_auth_token: 'mock-token', + }, + ) + +export type BrowserContext = Awaited> + +export async function takeScreenshot( + browser: Browser, + width: number, + height: number, + filename: string, + screenlyJsContent: string, + setup: (context: BrowserContext) => Promise, +): Promise { + const screenshotsDir = getScreenshotsDir() + const context = await browser.newContext({ viewport: { width, height } }) + const page = await context.newPage() + + await setupClockMock(page, FIXED_SCREENSHOT_DATE) + await setupScreenlyJsMock(page, screenlyJsContent) + await setup(context) + + await page.goto('/') + await page.waitForLoadState('networkidle') + + await page.screenshot({ + path: path.join(screenshotsDir, filename), + fullPage: false, + }) + + await context.close() +} + +function mockCredentials(context: BrowserContext): Promise { + return context.route(/access_token\//, (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(MOCK_CREDENTIALS), + }), + ) +} + +export async function setupDashboardRoutes( + context: BrowserContext, +): Promise { + await mockCredentials(context) + + await context.route(/visualqueues\/stateinformation/, (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ result: MOCK_QUEUES }), + }), + ) + + await context.route(/users\/stateinformation/, (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ result: MOCK_AGENTS }), + }), + ) +} + +export async function setupEmptyQueuesRoutes( + context: BrowserContext, +): Promise { + await mockCredentials(context) + + await context.route(/visualqueues\/stateinformation/, (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ result: [] }), + }), + ) + + await context.route(/users\/stateinformation/, (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ result: [] }), + }), + ) +} diff --git a/e2e/screenshots.spec.ts b/e2e/screenshots.spec.ts index 9e556d4..65cd6e3 100644 --- a/e2e/screenshots.spec.ts +++ b/e2e/screenshots.spec.ts @@ -1,33 +1,37 @@ import { test } from '@playwright/test' +import { RESOLUTIONS } from '@screenly/edge-apps/test/screenshots' import { - createMockScreenlyForScreenshots, - getScreenshotsDir, - RESOLUTIONS, - setupClockMock, - setupScreenlyJsMock, -} from '@screenly/edge-apps/test/screenshots' -import path from 'path' - -const { screenlyJsContent } = createMockScreenlyForScreenshots() + dashboardScreenlyJsContent, + setupDashboardRoutes, + setupEmptyQueuesRoutes, + takeScreenshot, +} from './screenshots.lib' for (const { width, height } of RESOLUTIONS) { - test(`screenshot ${width}x${height}`, async ({ browser }) => { - const screenshotsDir = getScreenshotsDir() - - const context = await browser.newContext({ viewport: { width, height } }) - const page = await context.newPage() - - await setupClockMock(page) - await setupScreenlyJsMock(page, screenlyJsContent) - - await page.goto('/') - await page.waitForLoadState('networkidle') - - await page.screenshot({ - path: path.join(screenshotsDir, `${width}x${height}.png`), - fullPage: false, - }) + test(`screenshot dashboard ${width}x${height}`, async ({ browser }) => { + await takeScreenshot( + browser, + width, + height, + `dashboard-${width}x${height}.png`, + dashboardScreenlyJsContent, + (context) => setupDashboardRoutes(context), + ) + }) +} - await context.close() +for (const [width, height] of [ + [1920, 1080], + [1080, 1920], +]) { + test(`screenshot empty-queues ${width}x${height}`, async ({ browser }) => { + await takeScreenshot( + browser, + width, + height, + `empty-queues-${width}x${height}.png`, + dashboardScreenlyJsContent, + (context) => setupEmptyQueuesRoutes(context), + ) }) } diff --git a/index.html b/index.html index 008f9d1..ac2a4d5 100644 --- a/index.html +++ b/index.html @@ -12,11 +12,14 @@ reference-height="1080" orientation="auto" > -
- +
+
-
diff --git a/mock-server/.env.example b/mock-server/.env.example new file mode 100644 index 0000000..eff9ada --- /dev/null +++ b/mock-server/.env.example @@ -0,0 +1,4 @@ +PUZZEL_CLIENT_KEY=your_puzzel_client_key_here +PUZZEL_CLIENT_SECRET=your_puzzel_client_secret_here +PUZZEL_TENANT_ID=your_puzzel_tenant_id_here +PUZZEL_USER_ID=your_puzzel_user_id_here diff --git a/mock-server/.gitignore b/mock-server/.gitignore index b947077..4a3b904 100644 --- a/mock-server/.gitignore +++ b/mock-server/.gitignore @@ -1,2 +1,4 @@ node_modules/ dist/ +.env +auth.db diff --git a/mock-server/README.md b/mock-server/README.md index d814aac..5b2cbe1 100644 --- a/mock-server/README.md +++ b/mock-server/README.md @@ -1,8 +1,11 @@ # Mock Server -Fakes the Puzzel Contact Centre REST API real-time endpoints so the Edge App -can be developed and demoed without a paid Puzzel account. Response shapes -are based on Puzzel's published OpenAPI schema for the `Real-time` tag. +Acts as a Screenly OAuth broker stand-in for Puzzel, under `/screenly`, and +an authenticated pass-through to the real Puzzel Contact Centre REST API, +under `/puzzel`. Response shapes are based on Puzzel's published OpenAPI +schema for the `Real-time` tag, from +[developer.puzzel.com](https://developer.puzzel.com/). Both require a real +Puzzel account — there is no synthetic/offline mode. ## Getting Started @@ -11,32 +14,60 @@ bun install bun run dev ``` -Open `http://localhost:3000` to see the current mock queues and agents, and -to cycle an agent's status (Ready → Paused → Logged Off) by clicking its -tile. Numbers on the queues also jitter automatically every 5 seconds so a -running dashboard shows movement. +Open `http://localhost:3000` to connect. + +## Connecting to real Puzzel data + +Copy `.env.example` to `.env` and fill in: + +``` +PUZZEL_CLIENT_KEY=... +PUZZEL_CLIENT_SECRET=... +PUZZEL_TENANT_ID=... +PUZZEL_USER_ID=... +``` + +Then click **Connect** on `http://localhost:3000`. This exchanges those +credentials for a real Puzzel ID access token (client-credentials grant, +`scope=contact-centre:{tenantId}:{userId}`), stores it in a local `auth.db` +SQLite file, and refreshes it automatically before it expires. Click +**Disconnect** to clear it. + +`.env` and `auth.db` are gitignored — never commit real credentials or +tokens. ## Endpoints -| Endpoint | Mirrors | -| ----------------------------------------------------------------| --------------------------------- | -| `GET /` | Admin UI for inspecting mock data | -| `GET /:customerKey/visualqueues/stateinformation/:result` | `VisualQueueStateAndTickerList` | -| `GET /:customerKey/users/stateinformation/:tickerPeriodWindow` | `AgentStateAndTickerList` | -| `GET /:customerKey/visualqueues` | `VisualQueueList` | -| `POST /admin/agents/:userId/cycle-status` | Admin-only: cycles an agent's status | +| Endpoint | Mirrors | +| --------------------------------------------------------------------- | ------------------------------------------------------------------------------ | +| `GET /` | Admin UI: Connect/Disconnect | +| `POST /connect` | Admin-only: exchanges env credentials for a real token | +| `POST /disconnect` | Admin-only: clears the stored real token | +| `GET /puzzel/:customerKey/visualqueues/stateinformation/:result` | `VisualQueueStateAndTickerList` | +| `GET /puzzel/:customerKey/users/stateinformation/:tickerPeriodWindow` | `AgentStateAndTickerList` | +| `GET /puzzel/:customerKey/visualqueues` | `VisualQueueList` | +| `GET /screenly/access_token/` | The not-yet-built Screenly OAuth broker for Puzzel (real token when connected) | -All `Real-time` endpoints require an `Authorization: Bearer ` +All `/puzzel` endpoints require an `Authorization: Bearer ` header — the mock does not validate the token itself, it only checks that -one was sent. +one was sent — and require an active Connect session, returning 404 +otherwise. The `:customerKey` and `:tickerPeriodWindow` path segments are +passed through to the real API as-is, but the tenant and `userId` used in +the upstream request always come from the connected credentials, not from +the incoming request. ## Connecting to the Edge App -In `mock-data.yml` (at the repository root), set: +The Edge App's Puzzel API base URL is hardcoded to the real Puzzel API and is +no longer configurable via settings, so `/puzzel/...` is not wired into the +running dashboard — use it for manual `curl`/browser checks instead. The +`/screenly` broker can still be pointed at from `mock-data.yml` (at the +repository root): ```yaml settings: access_token: 'mock-token' - api_base_url: 'http://localhost:3000' customer_key: '12345' + screenly_oauth_tokens_url: 'http://localhost:3000/screenly/' + screenly_app_auth_token: 'mock-token' ``` diff --git a/mock-server/src/app.ts b/mock-server/src/app.ts new file mode 100644 index 0000000..9982936 --- /dev/null +++ b/mock-server/src/app.ts @@ -0,0 +1,60 @@ +import cors from 'cors' +import express from 'express' +import path from 'path' +import { fileURLToPath } from 'url' +import { cycleAgentStatus } from './data' +import { connect, disconnect, getConnection } from './oauth' +import { startRefreshLoop, stopRefreshLoop } from './refresh' +import { puzzelRouter } from './routes/puzzel' +import { screenlyRouter } from './routes/screenly' + +const sourceDirectory = path.dirname(fileURLToPath(import.meta.url)) + +export function createApp(): express.Express { + const app = express() + app.use(cors()) + app.set('view engine', 'ejs') + app.set('views', path.join(sourceDirectory, 'views')) + app.use(express.static(path.join(sourceDirectory, '..', 'dist'))) + app.use( + '/vendor/alpine', + express.static( + path.join(sourceDirectory, '..', 'node_modules', 'alpinejs', 'dist') + ) + ) + + app.get('/', (_req, res) => { + res.render('index', { connection: getConnection(), error: null }) + }) + + app.post('/connect', async (_req, res) => { + const result = await connect() + if ('error' in result) { + res.status(400).render('index', { connection: null, error: result.error }) + return + } + startRefreshLoop() + res.redirect('/') + }) + + app.post('/disconnect', (_req, res) => { + stopRefreshLoop() + disconnect() + res.redirect('/') + }) + + app.post('/admin/agents/:userId/cycle-status', (req, res) => { + const userId = Number(req.params.userId) + const agent = cycleAgentStatus(userId) + if (!agent) { + res.status(404).json({ message: 'Agent not found' }) + return + } + res.json(agent) + }) + + app.use('/puzzel', puzzelRouter) + app.use('/screenly', screenlyRouter) + + return app +} diff --git a/mock-server/src/constants.ts b/mock-server/src/constants.ts new file mode 100644 index 0000000..2bc8a33 --- /dev/null +++ b/mock-server/src/constants.ts @@ -0,0 +1,2 @@ +export const PUZZEL_TOKEN_URL = 'https://app.puzzel.com/id/connect/token' +export const PUZZEL_API_BASE_URL = 'https://api.puzzel.com/ContactCentre5' diff --git a/mock-server/src/data.ts b/mock-server/src/data.ts index 0f043ac..80b33f6 100644 --- a/mock-server/src/data.ts +++ b/mock-server/src/data.ts @@ -11,6 +11,8 @@ export interface QueueStats { waitTimeAverageSeconds: number callsOfferedToday: number callsAnsweredToday: number + ciqsOfferedToday: number + ciqsAnsweredToday: number sla: number } @@ -37,6 +39,8 @@ export const queues: QueueStats[] = [ waitTimeAverageSeconds: 32, callsOfferedToday: 58, callsAnsweredToday: 54, + ciqsOfferedToday: 6, + ciqsAnsweredToday: 5, sla: 92, }, { @@ -50,6 +54,8 @@ export const queues: QueueStats[] = [ waitTimeAverageSeconds: 18, callsOfferedToday: 21, callsAnsweredToday: 20, + ciqsOfferedToday: 3, + ciqsAnsweredToday: 3, sla: 98, }, { @@ -63,6 +69,8 @@ export const queues: QueueStats[] = [ waitTimeAverageSeconds: 0, callsOfferedToday: 9, callsAnsweredToday: 9, + ciqsOfferedToday: 0, + ciqsAnsweredToday: 0, sla: 100, }, ] @@ -121,12 +129,17 @@ export function jitterData(): void { queue.waitTimeAverageSeconds = randomWalk(queue.waitTimeAverageSeconds, 5) queue.waitTimeMaxSeconds = Math.max( queue.waitTimeMaxSeconds, - queue.waitTimeAverageSeconds, + queue.waitTimeAverageSeconds ) queue.callsOfferedToday = randomWalk(queue.callsOfferedToday, 1) queue.callsAnsweredToday = Math.min( queue.callsOfferedToday, - randomWalk(queue.callsAnsweredToday, 1), + randomWalk(queue.callsAnsweredToday, 1) + ) + queue.ciqsOfferedToday = randomWalk(queue.ciqsOfferedToday, 1) + queue.ciqsAnsweredToday = Math.min( + queue.ciqsOfferedToday, + randomWalk(queue.ciqsAnsweredToday, 1) ) }) } @@ -136,7 +149,8 @@ export function cycleAgentStatus(userId: number): AgentStatus | undefined { const agent = agents.find((a) => a.userId === userId) if (!agent) return undefined - const nextIndex = (order.indexOf(agent.contactCentreStatus) + 1) % order.length + const nextIndex = + (order.indexOf(agent.contactCentreStatus) + 1) % order.length agent.contactCentreStatus = order[nextIndex] return agent } diff --git a/mock-server/src/db.ts b/mock-server/src/db.ts new file mode 100644 index 0000000..34c7e15 --- /dev/null +++ b/mock-server/src/db.ts @@ -0,0 +1,43 @@ +import { Database } from 'bun:sqlite' + +const db = new Database('auth.db') + +db.run(` + CREATE TABLE IF NOT EXISTS tokens ( + id INTEGER PRIMARY KEY, + access_token TEXT NOT NULL, + expires_at INTEGER NOT NULL, + tenant_id TEXT NOT NULL, + user_id TEXT NOT NULL, + created_at INTEGER NOT NULL DEFAULT (unixepoch()) + ) +`) + +export interface StoredToken { + access_token: string + expires_at: number + tenant_id: string + user_id: string +} + +export function saveToken(token: StoredToken): void { + db.run('DELETE FROM tokens') + db.run( + 'INSERT INTO tokens (access_token, expires_at, tenant_id, user_id) VALUES (?, ?, ?, ?)', + [token.access_token, token.expires_at, token.tenant_id, token.user_id] + ) +} + +export function loadToken(): StoredToken | null { + return ( + db + .query( + 'SELECT access_token, expires_at, tenant_id, user_id FROM tokens LIMIT 1' + ) + .get() ?? null + ) +} + +export function clearToken(): void { + db.run('DELETE FROM tokens') +} diff --git a/mock-server/src/index.ts b/mock-server/src/index.ts index 39686df..fa4a13f 100644 --- a/mock-server/src/index.ts +++ b/mock-server/src/index.ts @@ -1,81 +1,12 @@ -import cors from 'cors' -import express from 'express' -import path from 'path' -import { fileURLToPath } from 'url' -import { agents, cycleAgentStatus, jitterData, queues } from './data' +import { createApp } from './app' +import { jitterData } from './data' +import { startRefreshLoop } from './refresh' -const __dirname = path.dirname(fileURLToPath(import.meta.url)) const PORT = 3000 const JITTER_INTERVAL_MS = 5000 -const app = express() -app.use(cors()) -app.set('view engine', 'ejs') -app.set('views', path.join(__dirname, 'views')) -app.use(express.static(path.join(__dirname, '..', 'dist'))) -app.use( - '/vendor/alpine', - express.static(path.join(__dirname, '..', 'node_modules', 'alpinejs', 'dist')), -) - -function requireBearerToken( - req: express.Request, - res: express.Response, - next: express.NextFunction, -): void { - const header = req.header('Authorization') ?? '' - if (!header.startsWith('Bearer ') || header.length <= 'Bearer '.length) { - res.status(401).json({ code: 401, message: 'Missing or invalid token' }) - return - } - next() -} - -app.get('/', (_req, res) => { - res.render('index', { queues, agents }) -}) - -app.post('/admin/agents/:userId/cycle-status', (req, res) => { - const userId = Number(req.params.userId) - const agent = cycleAgentStatus(userId) - if (!agent) { - res.status(404).json({ message: 'Agent not found' }) - return - } - res.json(agent) -}) - -// Mirrors VisualQueueStateAndTickerList from the Puzzel Contact Centre REST -// API: GET /{customerKey}/visualqueues/stateinformation/{result} -app.get( - '/:customerKey/visualqueues/stateinformation/:result', - requireBearerToken, - (_req, res) => { - res.json({ result: queues, code: 0, id: 'mock', message: 'OK' }) - }, -) - -// Mirrors AgentStateAndTickerList: GET /{customerKey}/users/stateinformation/{tickerPeriodWindow} -app.get( - '/:customerKey/users/stateinformation/:tickerPeriodWindow', - requireBearerToken, - (req, res) => { - const { userGroupName } = req.query - const filtered = - typeof userGroupName === 'string' && userGroupName - ? agents.filter((agent) => agent.userGroupName === userGroupName) - : agents - res.json({ result: filtered, code: 0, id: 'mock', message: 'OK' }) - }, -) - -// Mirrors VisualQueueList: GET /{customerKey}/visualqueues -app.get('/:customerKey/visualqueues', requireBearerToken, (_req, res) => { - const result = queues.map(({ id, description }) => ({ id, description })) - res.json({ result, code: 0, id: 'mock', message: 'OK' }) -}) - -app.listen(PORT, () => { +createApp().listen(PORT, () => { console.log(`Mock Puzzel API running at http://localhost:${PORT}`) setInterval(jitterData, JITTER_INTERVAL_MS) + startRefreshLoop() }) diff --git a/mock-server/src/oauth.ts b/mock-server/src/oauth.ts new file mode 100644 index 0000000..e0e2d54 --- /dev/null +++ b/mock-server/src/oauth.ts @@ -0,0 +1,71 @@ +import { PUZZEL_TOKEN_URL } from './constants' +import { clearToken, loadToken, saveToken } from './db' + +interface PuzzelTokenResponse { + access_token?: string + expires_in?: number + error?: string + error_description?: string +} + +export interface Connection { + token: string + tenantId: string + userId: string +} + +export async function connect(): Promise<{ error: string } | { ok: true }> { + const clientId = process.env.PUZZEL_CLIENT_KEY + const clientSecret = process.env.PUZZEL_CLIENT_SECRET + const tenantId = process.env.PUZZEL_TENANT_ID + const userId = process.env.PUZZEL_USER_ID + + if (!clientId || !clientSecret || !tenantId || !userId) { + return { + error: + 'PUZZEL_CLIENT_KEY, PUZZEL_CLIENT_SECRET, PUZZEL_TENANT_ID, and PUZZEL_USER_ID must all be set (see .env.example).', + } + } + + const res = await fetch(PUZZEL_TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + scope: `contact-centre:${tenantId}:${userId}`, + grant_type: 'client_credentials', + }).toString(), + }) + + const data = (await res.json()) as PuzzelTokenResponse + if (!res.ok || !data.access_token) { + return { + error: data.error_description ?? data.error ?? `HTTP ${res.status}`, + } + } + + saveToken({ + access_token: data.access_token, + expires_at: Math.floor(Date.now() / 1000) + (data.expires_in ?? 900), + tenant_id: tenantId, + user_id: userId, + }) + + return { ok: true } +} + +export function disconnect(): void { + clearToken() +} + +export function getConnection(): Connection | null { + const token = loadToken() + if (!token) return null + if (token.expires_at * 1000 <= Date.now()) return null + return { + token: token.access_token, + tenantId: token.tenant_id, + userId: token.user_id, + } +} diff --git a/mock-server/src/refresh.ts b/mock-server/src/refresh.ts new file mode 100644 index 0000000..784595d --- /dev/null +++ b/mock-server/src/refresh.ts @@ -0,0 +1,40 @@ +import { loadToken } from './db' +import { connect } from './oauth' + +const REFRESH_SAFETY_MARGIN_MS = 2 * 60 * 1000 +const MIN_REFRESH_DELAY_MS = 60 * 1000 + +let refreshTimer: ReturnType | undefined + +function scheduleNextRefresh(): void { + if (refreshTimer) clearTimeout(refreshTimer) + + const token = loadToken() + if (!token) return + + const delayMs = Math.max( + token.expires_at * 1000 - Date.now() - REFRESH_SAFETY_MARGIN_MS, + MIN_REFRESH_DELAY_MS + ) + + refreshTimer = setTimeout(() => { + connect() + .then((result) => { + if ('error' in result) { + console.error(`Puzzel token refresh failed: ${result.error}`) + return + } + console.log(`Puzzel token refreshed at ${new Date().toISOString()}`) + scheduleNextRefresh() + }) + .catch((err) => console.error('Puzzel token refresh error:', err)) + }, delayMs) +} + +export function startRefreshLoop(): void { + scheduleNextRefresh() +} + +export function stopRefreshLoop(): void { + if (refreshTimer) clearTimeout(refreshTimer) +} diff --git a/mock-server/src/routes/puzzel.ts b/mock-server/src/routes/puzzel.ts new file mode 100644 index 0000000..82e0548 --- /dev/null +++ b/mock-server/src/routes/puzzel.ts @@ -0,0 +1,80 @@ +import { Router, type NextFunction, type Request, type Response } from 'express' +import { PUZZEL_API_BASE_URL } from '../constants' +import { getConnection } from '../oauth' + +function requireBearerToken( + req: Request, + res: Response, + next: NextFunction +): void { + const header = req.header('Authorization') ?? '' + if (!header.startsWith('Bearer ') || header.length <= 'Bearer '.length) { + res.status(401).json({ code: 401, message: 'Missing or invalid token' }) + return + } + next() +} + +async function callPuzzel( + path: string, + query: Record, + res: Response +): Promise { + const connection = getConnection() + if (!connection) { + res.status(404).json({ error: 'Not connected. Visit / to connect.' }) + return + } + + const params = new URLSearchParams(query) + params.set('userId', connection.userId) + const url = `${PUZZEL_API_BASE_URL}/${connection.tenantId}${path}?${params}` + + try { + const upstream = await fetch(url, { + headers: { Authorization: `Bearer ${connection.token}` }, + }) + const body = await upstream.text() + res.status(upstream.status).type('application/json').send(body) + } catch (err) { + res.status(502).json({ + error: 'Failed to reach Puzzel.', + message: err instanceof Error ? err.message : String(err), + }) + } +} + +export const puzzelRouter = Router() + +puzzelRouter.use(requireBearerToken) + +// Mirrors VisualQueueStateAndTickerList: GET /{customerKey}/visualqueues/stateinformation/{result} +puzzelRouter.get( + '/:customerKey/visualqueues/stateinformation/:result', + async (req, res) => { + await callPuzzel( + `/visualqueues/stateinformation/${req.params.result}`, + {}, + res + ) + } +) + +// Mirrors AgentStateAndTickerList: GET /{customerKey}/users/stateinformation/{tickerPeriodWindow} +puzzelRouter.get( + '/:customerKey/users/stateinformation/:tickerPeriodWindow', + async (req, res) => { + const { userGroupName } = req.query + + await callPuzzel( + `/users/stateinformation/${req.params.tickerPeriodWindow}`, + typeof userGroupName === 'string' ? { userGroupName } : {}, + res + ) + } +) + +// Mirrors VisualQueueList: GET /{customerKey}/visualqueues +puzzelRouter.get('/:customerKey/visualqueues', async (_req, res) => { + await callPuzzel('/visualqueues', {}, res) +}) diff --git a/mock-server/src/routes/screenly.ts b/mock-server/src/routes/screenly.ts new file mode 100644 index 0000000..1e3a54d --- /dev/null +++ b/mock-server/src/routes/screenly.ts @@ -0,0 +1,14 @@ +import { Router } from 'express' +import { getConnection } from '../oauth' + +export const screenlyRouter = Router() + +screenlyRouter.get('/access_token/', (_req, res) => { + const connection = getConnection() + if (!connection) { + res.status(404).json({ error: 'Not connected. Visit / to connect.' }) + return + } + + res.json({ token: connection.token }) +}) diff --git a/mock-server/src/views/index.ejs b/mock-server/src/views/index.ejs index ac95546..3aa6acc 100644 --- a/mock-server/src/views/index.ejs +++ b/mock-server/src/views/index.ejs @@ -2,84 +2,127 @@ + Puzzel Mock API - -

Puzzel Mock API

-

- Fakes the Puzzel Contact Centre real-time endpoints. Point the Edge - App's api_base_url setting at - http://localhost:3000 and set any non-empty - access_token. -

+ + <% if (connection) { %> +
+
+

+ Connected to
Puzzel +

+

+ Real queue and agent data is now proxied for the Edge App. +

+ ✓ Authenticated +
+ +
+
+
+ Tenant ID +
+
+ <%= connection.tenantId %> +
+
+ +
+
+ User ID +
+
+ <%= connection.userId %> +
+
-
-

Queues

- - - - - - - - - - - - - - <% queues.forEach((queue) => { %> - - - - - - - - - - <% }) %> - -
NameReadyLogged OnIn QueueOfferedAnsweredSLA
<%= queue.description %><%= queue.agentsReady %><%= queue.agentsLoggedOn %><%= queue.queueSize %><%= queue.callsOfferedToday %><%= queue.callsAnsweredToday %><%= queue.sla %>%
-

- Numbers jitter automatically every 5 seconds. Reload this page to see - the latest values. -

-
+
+
+ Access Token +
+
+ <%= connection.token %> +
+ + Copied! +
+ +
+ +
+ +
+
+
+ <% } else { %> +
+
+

+ Connect to
Puzzel +

+

+ Exchange real credentials for a Puzzel ID access token, so the Edge + App can be developed against real queue and agent data. +

+
-
-

Agents

-
- +
-

- Click a tile to cycle its status: Ready → Paused → Logged Off. -

-
+
+ <% } %> diff --git a/package.json b/package.json index 9187417..353652a 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,8 @@ "build": "edge-apps-scripts build", "build:dev": "edge-apps-scripts build:dev", "build:prod": "edge-apps-scripts build", - "test": "bun test --pass-with-no-tests src/", - "test:unit": "bun test --pass-with-no-tests src/", + "test": "bun test --isolate --pass-with-no-tests src/", + "test:unit": "bun test --isolate --pass-with-no-tests src/", "lint": "edge-apps-scripts lint --fix", "format": "prettier --write src/ README.md index.html", "format:check": "prettier --check src/ README.md index.html", @@ -34,5 +34,9 @@ "bun-types": "^1.3.13", "jsdom": "^28.1.0", "@types/jsdom": "^28.0.0" + }, + "dependencies": { + "chart.js": "^4.5.1", + "lit-html": "^3.3.3" } } diff --git a/screenly.yml b/screenly.yml index c879317..a041dd5 100644 --- a/screenly.yml +++ b/screenly.yml @@ -10,17 +10,6 @@ settings: title: Access Token optional: true help_text: For testing only. Production support for Puzzel ID OAuth is not implemented yet. - api_base_url: - type: string - default_value: 'https://api.puzzel.com/ContactCentre5' - title: API Base URL - optional: true - help_text: - schema_version: 1 - properties: - advanced: true - help_text: Base URL of the Puzzel Contact Centre REST API. Override to point at a local mock server during development. - type: url customer_key: type: string title: Customer Key @@ -32,29 +21,48 @@ settings: title: Display Errors optional: true help_text: - schema_version: 1 properties: advanced: true help_text: For debugging purposes to display errors on the screen. type: boolean + schema_version: 1 + override_locale: + type: string + default_value: en + title: Override Locale + optional: true + help_text: | + Override the default locale with a supported language code (e.g., en_US, fr_FR, de_DE). + override_timezone: + type: string + default_value: '' + title: Override Timezone + optional: true + help_text: | + Override the default timezone with a supported timezone identifier (e.g., Europe/London, America/New_York). refresh_interval: type: string default_value: '15' title: Refresh Interval optional: true help_text: - schema_version: 1 properties: help_text: How often to refresh dashboard data, in seconds. type: number + schema_version: 1 sentry_dsn: type: secret title: Sentry Client Key optional: true - is_global: true help_text: Sentry Client Key from Sentry SDK for error capturing. + is_global: true user_group_name: type: string title: User Group Name optional: true help_text: Filter agent statuses to a specific Puzzel user group. Leave blank to show all agents. + user_id: + type: string + title: User ID + optional: false + help_text: The Puzzel user ID associated with your OIDC client's Product Access. Required for API requests to succeed. diff --git a/screenly_qc.yml b/screenly_qc.yml new file mode 100644 index 0000000..a041dd5 --- /dev/null +++ b/screenly_qc.yml @@ -0,0 +1,68 @@ +--- +syntax: manifest_v1 +description: Display real-time Puzzel Contact Centre queue and agent stats on your Screenly digital signage screens +categories: + - Dashboards +ready_signal: true +settings: + access_token: + type: secret + title: Access Token + optional: true + help_text: For testing only. Production support for Puzzel ID OAuth is not implemented yet. + customer_key: + type: string + title: Customer Key + optional: false + help_text: Your Puzzel customer number. + display_errors: + type: string + default_value: 'false' + title: Display Errors + optional: true + help_text: + properties: + advanced: true + help_text: For debugging purposes to display errors on the screen. + type: boolean + schema_version: 1 + override_locale: + type: string + default_value: en + title: Override Locale + optional: true + help_text: | + Override the default locale with a supported language code (e.g., en_US, fr_FR, de_DE). + override_timezone: + type: string + default_value: '' + title: Override Timezone + optional: true + help_text: | + Override the default timezone with a supported timezone identifier (e.g., Europe/London, America/New_York). + refresh_interval: + type: string + default_value: '15' + title: Refresh Interval + optional: true + help_text: + properties: + help_text: How often to refresh dashboard data, in seconds. + type: number + schema_version: 1 + sentry_dsn: + type: secret + title: Sentry Client Key + optional: true + help_text: Sentry Client Key from Sentry SDK for error capturing. + is_global: true + user_group_name: + type: string + title: User Group Name + optional: true + help_text: Filter agent statuses to a specific Puzzel user group. Leave blank to show all agents. + user_id: + type: string + title: User ID + optional: false + help_text: The Puzzel user ID associated with your OIDC client's Product Access. Required for API requests to succeed. diff --git a/screenshots/dashboard-1080x1920.webp b/screenshots/dashboard-1080x1920.webp new file mode 100644 index 0000000..74ce073 Binary files /dev/null and b/screenshots/dashboard-1080x1920.webp differ diff --git a/screenshots/dashboard-1280x720.webp b/screenshots/dashboard-1280x720.webp new file mode 100644 index 0000000..cdf0672 Binary files /dev/null and b/screenshots/dashboard-1280x720.webp differ diff --git a/screenshots/dashboard-1920x1080.webp b/screenshots/dashboard-1920x1080.webp new file mode 100644 index 0000000..6f4f866 Binary files /dev/null and b/screenshots/dashboard-1920x1080.webp differ diff --git a/screenshots/dashboard-2160x3840.webp b/screenshots/dashboard-2160x3840.webp new file mode 100644 index 0000000..1676864 Binary files /dev/null and b/screenshots/dashboard-2160x3840.webp differ diff --git a/screenshots/dashboard-2160x4096.webp b/screenshots/dashboard-2160x4096.webp new file mode 100644 index 0000000..bc264b1 Binary files /dev/null and b/screenshots/dashboard-2160x4096.webp differ diff --git a/screenshots/dashboard-3840x2160.webp b/screenshots/dashboard-3840x2160.webp new file mode 100644 index 0000000..b34f341 Binary files /dev/null and b/screenshots/dashboard-3840x2160.webp differ diff --git a/screenshots/dashboard-4096x2160.webp b/screenshots/dashboard-4096x2160.webp new file mode 100644 index 0000000..40d9929 Binary files /dev/null and b/screenshots/dashboard-4096x2160.webp differ diff --git a/screenshots/dashboard-480x800.webp b/screenshots/dashboard-480x800.webp new file mode 100644 index 0000000..a4df200 Binary files /dev/null and b/screenshots/dashboard-480x800.webp differ diff --git a/screenshots/dashboard-720x1280.webp b/screenshots/dashboard-720x1280.webp new file mode 100644 index 0000000..9840435 Binary files /dev/null and b/screenshots/dashboard-720x1280.webp differ diff --git a/screenshots/dashboard-800x480.webp b/screenshots/dashboard-800x480.webp new file mode 100644 index 0000000..bcea96c Binary files /dev/null and b/screenshots/dashboard-800x480.webp differ diff --git a/screenshots/empty-queues-1080x1920.webp b/screenshots/empty-queues-1080x1920.webp new file mode 100644 index 0000000..a5081d3 Binary files /dev/null and b/screenshots/empty-queues-1080x1920.webp differ diff --git a/screenshots/empty-queues-1920x1080.webp b/screenshots/empty-queues-1920x1080.webp new file mode 100644 index 0000000..21acaa4 Binary files /dev/null and b/screenshots/empty-queues-1920x1080.webp differ diff --git a/src/api.test.ts b/src/api.test.ts new file mode 100644 index 0000000..94db070 --- /dev/null +++ b/src/api.test.ts @@ -0,0 +1,147 @@ +import '@screenly/edge-apps/test' +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import { resetScreenlyMock, setupScreenlyMock } from '@screenly/edge-apps/test' +import { AuthError, fetchAgentStatuses, fetchQueueStats } from './api' + +const BASE_SETTINGS = { + customer_key: '12345', + user_id: '67890', +} + +const originalFetch = globalThis.fetch + +function stubFetch(impl: () => Promise) { + const fetchMock = mock(impl) + globalThis.fetch = fetchMock as unknown as typeof fetch + return fetchMock +} + +function fakeResponse(status: number, body: unknown) { + return stubFetch(async () => ({ + status, + ok: status >= 200 && status < 300, + statusText: 'Error', + json: async () => body, + })) +} + +describe('fetchQueueStats', () => { + beforeEach(() => { + setupScreenlyMock({}, BASE_SETTINGS) + }) + + afterEach(() => { + globalThis.fetch = originalFetch + resetScreenlyMock() + }) + + test('parses `result` on success', async () => { + const body = { result: [{ id: 1, description: 'Support' }] } + fakeResponse(200, body) + + const result = await fetchQueueStats('token') + expect(result).toEqual(body.result) + }) + + test('throws AuthError on a 401 response', async () => { + fakeResponse(401, {}) + + await expect(fetchQueueStats('token')).rejects.toBeInstanceOf(AuthError) + }) + + test('throws AuthError on a 403 response', async () => { + fakeResponse(403, {}) + + await expect(fetchQueueStats('token')).rejects.toBeInstanceOf(AuthError) + }) + + test('throws a generic error on other non-ok statuses', async () => { + fakeResponse(500, {}) + + await expect(fetchQueueStats('token')).rejects.toThrow( + /Failed to fetch queue stats: 500/, + ) + }) + + test('builds the request URL from customer_key and user_id settings', async () => { + const fetchMock = fakeResponse(200, { result: [] }) + + await fetchQueueStats('token') + + const [requestUrl] = fetchMock.mock.calls[0] as [URL] + expect(requestUrl.toString()).toBe( + 'https://api.puzzel.com/ContactCentre5/12345/visualqueues/stateinformation/All?userId=67890', + ) + }) + + test('sends the token as a bearer auth header', async () => { + const fetchMock = fakeResponse(200, { result: [] }) + + await fetchQueueStats('my-token') + + const [, init] = fetchMock.mock.calls[0] as [URL, RequestInit] + expect(init.headers).toEqual({ Authorization: 'Bearer my-token' }) + }) +}) + +describe('fetchAgentStatuses', () => { + beforeEach(() => { + setupScreenlyMock({}, BASE_SETTINGS) + }) + + afterEach(() => { + globalThis.fetch = originalFetch + resetScreenlyMock() + }) + + test('parses `result` on success', async () => { + const body = { result: [{ userId: 1, firstName: 'Ada' }] } + fakeResponse(200, body) + + const result = await fetchAgentStatuses('token') + expect(result).toEqual(body.result) + }) + + test('throws AuthError on a 401 response', async () => { + fakeResponse(401, {}) + + await expect(fetchAgentStatuses('token')).rejects.toBeInstanceOf(AuthError) + }) + + test('throws AuthError on a 403 response', async () => { + fakeResponse(403, {}) + + await expect(fetchAgentStatuses('token')).rejects.toBeInstanceOf(AuthError) + }) + + test('throws a generic error on other non-ok statuses', async () => { + fakeResponse(500, {}) + + await expect(fetchAgentStatuses('token')).rejects.toThrow( + /Failed to fetch agent statuses: 500/, + ) + }) + + test('omits userGroupName from the query when the setting is blank', async () => { + const fetchMock = fakeResponse(200, { result: [] }) + + await fetchAgentStatuses('token') + + const [requestUrl] = fetchMock.mock.calls[0] as [URL] + expect(requestUrl.toString()).toBe( + 'https://api.puzzel.com/ContactCentre5/12345/users/stateinformation/Today?userId=67890', + ) + }) + + test('includes userGroupName in the query when the setting is set', async () => { + setupScreenlyMock({}, { ...BASE_SETTINGS, user_group_name: 'Support' }) + const fetchMock = fakeResponse(200, { result: [] }) + + await fetchAgentStatuses('token') + + const [requestUrl] = fetchMock.mock.calls[0] as [URL] + expect(requestUrl.toString()).toBe( + 'https://api.puzzel.com/ContactCentre5/12345/users/stateinformation/Today?userId=67890&userGroupName=Support', + ) + }) +}) diff --git a/src/api.ts b/src/api.ts index 1c477e6..649556a 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,4 +1,9 @@ -import { TICKER_PERIOD_WINDOW, VISUAL_QUEUE_RESULT } from './constants' +import { getSettingWithDefault } from '@screenly/edge-apps' +import { + API_BASE_URL, + TICKER_PERIOD_WINDOW, + VISUAL_QUEUE_RESULT, +} from './constants' export class AuthError extends Error { constructor(message = 'Puzzel authentication failed') { @@ -18,6 +23,8 @@ export interface QueueStats { waitTimeAverageSeconds: number callsOfferedToday: number callsAnsweredToday: number + ciqsOfferedToday: number + ciqsAnsweredToday: number sla: number } @@ -38,39 +45,50 @@ function throwIfAuthError(res: Response): void { } } -export async function fetchQueueStats( - baseUrl: string, - token: string, - customerKey: string, -): Promise { - const res = await fetch( - `${baseUrl}/${customerKey}/visualqueues/stateinformation/${VISUAL_QUEUE_RESULT}`, - { headers: { Authorization: `Bearer ${token}` } }, +export async function fetchQueueStats(token: string): Promise { + const customerKey = getSettingWithDefault('customer_key', '') + const userId = getSettingWithDefault('user_id', '') + + const url = new URL( + `${customerKey}/visualqueues/stateinformation/${VISUAL_QUEUE_RESULT}`, + `${API_BASE_URL}/`, ) + url.search = new URLSearchParams({ userId }).toString() + + const res = await fetch(url, { + headers: { Authorization: `Bearer ${token}` }, + }) throwIfAuthError(res) if (!res.ok) - throw new Error(`Failed to fetch queue stats: ${res.status} ${res.statusText}`) + throw new Error( + `Failed to fetch queue stats: ${res.status} ${res.statusText}`, + ) const data = (await res.json()) as { result: QueueStats[] } return data.result } export async function fetchAgentStatuses( - baseUrl: string, token: string, - customerKey: string, - userGroupName: string, ): Promise { - const params = new URLSearchParams() + const customerKey = getSettingWithDefault('customer_key', '') + const userId = getSettingWithDefault('user_id', '') + const userGroupName = getSettingWithDefault('user_group_name', '') + + const url = new URL( + `${customerKey}/users/stateinformation/${TICKER_PERIOD_WINDOW}`, + `${API_BASE_URL}/`, + ) + const params = new URLSearchParams({ userId }) if (userGroupName) { params.set('userGroupName', userGroupName) } + url.search = params.toString() - const res = await fetch( - `${baseUrl}/${customerKey}/users/stateinformation/${TICKER_PERIOD_WINDOW}?${params}`, - { headers: { Authorization: `Bearer ${token}` } }, - ) + const res = await fetch(url, { + headers: { Authorization: `Bearer ${token}` }, + }) throwIfAuthError(res) if (!res.ok) diff --git a/src/app.ts b/src/app.ts deleted file mode 100644 index df7c2de..0000000 --- a/src/app.ts +++ /dev/null @@ -1,53 +0,0 @@ -export function showScreen(screenId: string): void { - const screens = ['dashboard', 'error-screen'] - screens.forEach((id) => { - const el = document.getElementById(id) - if (el) { - el.style.display = id === screenId ? 'flex' : 'none' - } - }) -} - -export function showError(message: string): void { - showScreen('error-screen') - const el = document.getElementById('error-message') - if (el) { - el.textContent = message - } -} - -export type ErrorReporter = (message: string) => void - -export function createErrorReporter(displayErrors: boolean): ErrorReporter { - if (displayErrors) - return (msg) => { - throw new Error(msg) - } - return showError -} - -export function formatDuration(totalSeconds: number): string { - const minutes = Math.floor(totalSeconds / 60) - const seconds = totalSeconds % 60 - return `${minutes}:${String(seconds).padStart(2, '0')}` -} - -const AGENT_STATUS_LABELS: Record = { - LoggedOff: 'Logged Off', - LoggedOn: 'Ready', - Pause: 'Paused', -} - -const AGENT_STATUS_COLORS: Record = { - LoggedOff: 'bg-neutral-600', - LoggedOn: 'bg-emerald-600', - Pause: 'bg-amber-600', -} - -export function agentStatusLabel(status: string): string { - return AGENT_STATUS_LABELS[status] ?? status -} - -export function agentStatusColor(status: string): string { - return AGENT_STATUS_COLORS[status] ?? 'bg-neutral-600' -} diff --git a/src/auth.ts b/src/auth.ts deleted file mode 100644 index cb3dc34..0000000 --- a/src/auth.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { AuthError } from './api' -import type { ErrorReporter } from './app' - -export type RefreshToken = () => Promise -export type RuntimeState = { - accessToken: string | null - credentialError: Error | null -} - -export async function withFreshToken( - getRuntimeState: () => RuntimeState, - refreshToken: RefreshToken, - reportError: ErrorReporter, - action: (token: string) => Promise, -): Promise { - const { accessToken, credentialError } = getRuntimeState() - - if (!accessToken) { - reportError(credentialError?.message ?? 'No access token available.') - return - } - - let firstErr: unknown - try { - await action(accessToken) - return - } catch (err) { - firstErr = err - } - - if (!(firstErr instanceof AuthError)) { - reportError( - firstErr instanceof Error ? firstErr.message : 'Failed to load data.', - ) - return - } - - try { - await refreshToken() - const { accessToken: refreshed } = getRuntimeState() - - if (!refreshed) { - reportError('No access token after refresh.') - return - } - - await action(refreshed) - } catch (err) { - reportError( - err instanceof Error - ? err.message - : 'Session expired. Please re-authenticate.', - ) - } -} diff --git a/src/constants.ts b/src/constants.ts index 9201973..4fa8f95 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,4 +1,5 @@ -export const DEFAULT_API_BASE_URL = 'https://api.puzzel.com/ContactCentre5' +export const API_BASE_URL = 'https://api.puzzel.com/ContactCentre5' export const DEFAULT_REFRESH_INTERVAL_SECONDS = 15 export const TICKER_PERIOD_WINDOW = 'Today' -export const VISUAL_QUEUE_RESULT = 'Active' +export const VISUAL_QUEUE_RESULT = 'All' +export const CACHE_NAMESPACE = 'puzzel-dashboard-app:v1' diff --git a/src/credentials.test.ts b/src/credentials.test.ts new file mode 100644 index 0000000..1218eea --- /dev/null +++ b/src/credentials.test.ts @@ -0,0 +1,164 @@ +import '@screenly/edge-apps/test' +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from 'bun:test' +import { resetScreenlyMock, setupScreenlyMock } from '@screenly/edge-apps/test' +import * as utils from '@screenly/edge-apps/utils' +import { fetchAccessToken } from './credentials' + +const BASE_SETTINGS = { + access_token: '', + display_errors: 'false', + screenly_oauth_tokens_url: 'https://api.example.com/oauth/', + screenly_app_auth_token: 'app-auth', +} + +const reportError = spyOn(utils, 'reportError') +const readEdgeAppCache = spyOn(utils, 'readEdgeAppCache') +const writeEdgeAppCache = spyOn(utils, 'writeEdgeAppCache') + +const originalFetch = globalThis.fetch + +function stubFetch(impl: () => Promise) { + const fetchMock = mock(impl) + globalThis.fetch = fetchMock as unknown as typeof fetch + return fetchMock +} + +function fakeResponse(status: number, body: unknown) { + return stubFetch(async () => ({ + status, + ok: status >= 200 && status < 300, + json: async () => body, + })) +} + +// eslint-disable-next-line max-lines-per-function +describe('fetchAccessToken', () => { + beforeEach(() => { + setupScreenlyMock({}, BASE_SETTINGS) + reportError.mockReset().mockImplementation(() => {}) + readEdgeAppCache.mockReset().mockReturnValue(null) + writeEdgeAppCache.mockReset().mockImplementation(() => {}) + }) + + afterEach(() => { + globalThis.fetch = originalFetch + resetScreenlyMock() + }) + + test('returns the access_token setting immediately without calling the broker', async () => { + setupScreenlyMock({}, { ...BASE_SETTINGS, access_token: 'dev-token' }) + const fetchMock = stubFetch(async () => { + throw new Error('should not be called') + }) + + const token = await fetchAccessToken() + + expect(token).toBe('dev-token') + expect(fetchMock).not.toHaveBeenCalled() + }) + + test('returns the fetched token and writes it to cache on success', async () => { + fakeResponse(200, { token: 'live-token' }) + + const token = await fetchAccessToken() + + expect(token).toBe('live-token') + expect(writeEdgeAppCache).toHaveBeenCalledWith( + expect.any(String), + 'credentials', + { accessToken: 'live-token' }, + ) + }) + + test('requests the access token endpoint with the expected url and auth header', async () => { + const fetchMock = fakeResponse(200, { token: 'live-token' }) + + await fetchAccessToken() + + const [requestUrl, init] = fetchMock.mock.calls[0] as [URL, RequestInit] + expect(requestUrl.toString()).toBe( + 'https://api.example.com/oauth/access_token/', + ) + expect(init.headers).toEqual( + expect.objectContaining({ Authorization: 'Bearer app-auth' }), + ) + }) + + test('builds the correct url when screenly_oauth_tokens_url has no trailing slash', async () => { + setupScreenlyMock( + {}, + { + ...BASE_SETTINGS, + screenly_oauth_tokens_url: 'https://api.example.com/oauth', + }, + ) + const fetchMock = fakeResponse(200, { token: 'live-token' }) + + await fetchAccessToken() + + const [requestUrl] = fetchMock.mock.calls[0] as [URL] + expect(requestUrl.toString()).toBe( + 'https://api.example.com/oauth/access_token/', + ) + }) + + test('rethrows and skips the cache when display_errors is on', async () => { + setupScreenlyMock({}, { ...BASE_SETTINGS, display_errors: 'true' }) + readEdgeAppCache.mockReturnValue({ accessToken: 'cached-token' }) + stubFetch(async () => { + throw new Error('network down') + }) + + await expect(fetchAccessToken()).rejects.toThrow('network down') + expect(readEdgeAppCache).not.toHaveBeenCalled() + }) + + test('falls back to the cached token when display_errors is off', async () => { + readEdgeAppCache.mockReturnValue({ accessToken: 'cached-token' }) + stubFetch(async () => { + throw new Error('network down') + }) + + const token = await fetchAccessToken() + + expect(token).toBe('cached-token') + expect(reportError).toHaveBeenCalledTimes(1) + }) + + test('falls back to an empty string when there is no cache and no dev token', async () => { + readEdgeAppCache.mockReturnValue(null) + stubFetch(async () => { + throw new Error('network down') + }) + + const token = await fetchAccessToken() + + expect(token).toBe('') + }) + + test('falls back when the backend responds without a token', async () => { + fakeResponse(200, { token: '' }) + + const token = await fetchAccessToken() + + expect(token).toBe('') + expect(reportError).toHaveBeenCalledTimes(1) + }) + + test('falls back on a non-ok response', async () => { + fakeResponse(503, {}) + + const token = await fetchAccessToken() + + expect(token).toBe('') + expect(reportError).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/credentials.ts b/src/credentials.ts new file mode 100644 index 0000000..12459df --- /dev/null +++ b/src/credentials.ts @@ -0,0 +1,54 @@ +import { getSettingWithDefault } from '@screenly/edge-apps' +import { + readEdgeAppCache, + reportError, + writeEdgeAppCache, +} from '@screenly/edge-apps/utils' +import { CACHE_NAMESPACE } from './constants' + +type CachedCredentials = { accessToken: string } + +export async function fetchAccessToken(): Promise { + const devAccessToken = getSettingWithDefault('access_token', '') + if (devAccessToken) return devAccessToken + + const displayErrors = + getSettingWithDefault('display_errors', 'false') === 'true' + + try { + const oauthTokensUrl = String(screenly.settings.screenly_oauth_tokens_url) + const url = new URL( + 'access_token/', + oauthTokensUrl.endsWith('/') ? oauthTokensUrl : `${oauthTokensUrl}/`, + ) + const response = await fetch(url, { + headers: { + Accept: 'application/json', + Authorization: `Bearer ${screenly.settings.screenly_app_auth_token}`, + }, + }) + if (!response.ok) { + throw new Error( + `Screenly returned an unexpected error (${response.status}).`, + ) + } + + const body = (await response.json()) as { token?: string } + if (!body.token) throw new Error('No access token available.') + + writeEdgeAppCache(CACHE_NAMESPACE, 'credentials', { + accessToken: body.token, + }) + return body.token + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)) + reportError(error, { source: 'puzzel-credentials' }) + if (displayErrors) throw error + + const cached = readEdgeAppCache( + CACHE_NAMESPACE, + 'credentials', + ) + return cached?.accessToken ?? '' + } +} diff --git a/src/dashboard.test.ts b/src/dashboard.test.ts new file mode 100644 index 0000000..102e7be --- /dev/null +++ b/src/dashboard.test.ts @@ -0,0 +1,126 @@ +import '@screenly/edge-apps/test' +import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test' +import { resetScreenlyMock, setupScreenlyMock } from '@screenly/edge-apps/test' +import * as utils from '@screenly/edge-apps/utils' +import * as api from './api' +import { CACHE_NAMESPACE } from './constants' +import * as credentials from './credentials' +import { refresh } from './dashboard' +import * as templates from './templates' + +const readEdgeAppCache = spyOn(utils, 'readEdgeAppCache') +const writeEdgeAppCache = spyOn(utils, 'writeEdgeAppCache') +const reportError = spyOn(utils, 'reportError') + +const fetchAccessToken = spyOn(credentials, 'fetchAccessToken') +const fetchQueueStats = spyOn(api, 'fetchQueueStats') +const fetchAgentStatuses = spyOn(api, 'fetchAgentStatuses') +const renderDashboard = spyOn(templates, 'renderDashboard') + +const BASE_SETTINGS = { + customer_key: '12345', + user_id: '67890', + display_errors: 'false', +} + +const LOCALE = 'en' + +// eslint-disable-next-line max-lines-per-function +describe('refresh', () => { + beforeEach(() => { + setupScreenlyMock({}, BASE_SETTINGS) + fetchAccessToken.mockReset().mockImplementation(async () => 'access-token') + fetchQueueStats + .mockReset() + .mockImplementation(async () => [{ id: 1, description: 'Support' }]) + fetchAgentStatuses + .mockReset() + .mockImplementation(async () => [{ userId: 1, firstName: 'Ada' }]) + renderDashboard.mockReset().mockImplementation(() => {}) + readEdgeAppCache.mockReset().mockReturnValue(null) + writeEdgeAppCache.mockReset().mockImplementation(() => {}) + reportError.mockReset().mockImplementation(() => {}) + }) + + afterEach(() => { + resetScreenlyMock() + }) + + test('throws when customer_key is missing', async () => { + setupScreenlyMock({}, { ...BASE_SETTINGS, customer_key: '' }) + + await expect(refresh(LOCALE)).rejects.toThrow( + 'Please set a Customer Key in settings.', + ) + }) + + test('throws when user_id is missing', async () => { + setupScreenlyMock({}, { ...BASE_SETTINGS, user_id: '' }) + + await expect(refresh(LOCALE)).rejects.toThrow( + 'Please set a User ID in settings.', + ) + }) + + test('writes fetched data to cache and renders it on success', async () => { + await refresh(LOCALE) + + const data = { + queues: [{ id: 1, description: 'Support' }], + agents: [{ userId: 1, firstName: 'Ada' }], + } + expect(writeEdgeAppCache).toHaveBeenCalledWith( + CACHE_NAMESPACE, + 'dashboard-data', + data, + ) + expect(renderDashboard).toHaveBeenCalledWith(data, LOCALE) + }) + + test('rethrows and skips the cache when display_errors is on', async () => { + setupScreenlyMock({}, { ...BASE_SETTINGS, display_errors: 'true' }) + fetchQueueStats.mockImplementation(async () => { + throw new Error('down') + }) + readEdgeAppCache.mockReturnValue({ queues: [], agents: [] }) + + await expect(refresh(LOCALE)).rejects.toThrow('down') + + expect(readEdgeAppCache).not.toHaveBeenCalled() + expect(renderDashboard).not.toHaveBeenCalled() + }) + + test('renders from cache on failure when display_errors is off', async () => { + fetchQueueStats.mockImplementation(async () => { + throw new Error('down') + }) + const cached = { + queues: [{ id: 2, description: 'Cached Queue' }], + agents: [], + } + readEdgeAppCache.mockReturnValue(cached) + + await refresh(LOCALE) + + expect(renderDashboard).toHaveBeenCalledWith(cached, LOCALE) + expect(reportError).toHaveBeenCalledWith( + new Error('down'), + expect.objectContaining({ + source: 'puzzel-dashboard', + customerKey: '12345', + }), + ) + }) + + test('throws when there is no cache to fall back to', async () => { + fetchQueueStats.mockImplementation(async () => { + throw new Error('down') + }) + readEdgeAppCache.mockReturnValue(null) + + await expect(refresh(LOCALE)).rejects.toThrow( + 'No cached dashboard data found.', + ) + expect(renderDashboard).not.toHaveBeenCalled() + }) +}) diff --git a/src/dashboard.ts b/src/dashboard.ts new file mode 100644 index 0000000..583ae13 --- /dev/null +++ b/src/dashboard.ts @@ -0,0 +1,52 @@ +import { getSettingWithDefault } from '@screenly/edge-apps' +import { + readEdgeAppCache, + reportError, + writeEdgeAppCache, +} from '@screenly/edge-apps/utils' +import { fetchAgentStatuses, fetchQueueStats } from './api' +import { CACHE_NAMESPACE } from './constants' +import { fetchAccessToken } from './credentials' +import { renderDashboard } from './templates' +import type { DashboardData } from './templates' + +async function getDashboardData(): Promise { + const displayErrors = + getSettingWithDefault('display_errors', 'false') === 'true' + + try { + const accessToken = await fetchAccessToken() + + const [queues, agents] = await Promise.all([ + fetchQueueStats(accessToken), + fetchAgentStatuses(accessToken), + ]) + const data = { queues, agents } + writeEdgeAppCache(CACHE_NAMESPACE, 'dashboard-data', data) + return data + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)) + reportError(error, { + source: 'puzzel-dashboard', + customerKey: getSettingWithDefault('customer_key', ''), + }) + if (displayErrors) throw error + } + + const cached = readEdgeAppCache( + CACHE_NAMESPACE, + 'dashboard-data', + ) + if (!cached) throw new Error('No cached dashboard data found.') + return cached +} + +export async function refresh(locale: string): Promise { + const customerKey = getSettingWithDefault('customer_key', '') + const userId = getSettingWithDefault('user_id', '') + if (!customerKey) throw new Error('Please set a Customer Key in settings.') + if (!userId) throw new Error('Please set a User ID in settings.') + + const data = await getDashboardData() + renderDashboard(data, locale) +} diff --git a/src/main.ts b/src/main.ts index f8352cb..674ae99 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,88 +1,27 @@ import './style.css' import '@screenly/edge-apps/components' import { - getCredentials, getSettingWithDefault, - initTokenRefreshLoop, setupErrorHandling, setupTheme, signalReady, } from '@screenly/edge-apps' -import { createErrorReporter, showError } from './app' -import { withFreshToken } from './auth' -import type { RuntimeState } from './auth' -import { DEFAULT_API_BASE_URL, DEFAULT_REFRESH_INTERVAL_SECONDS } from './constants' -import { fetchDashboardData, renderDashboard } from './render' +import { getLocale } from '@screenly/edge-apps/utils' +import { refresh } from './dashboard' +import { DEFAULT_REFRESH_INTERVAL_SECONDS } from './constants' document.addEventListener('DOMContentLoaded', async () => { setupErrorHandling() setupTheme() - const customerKey = getSettingWithDefault('customer_key', '') - const userGroupName = getSettingWithDefault('user_group_name', '') - const baseUrl = getSettingWithDefault( - 'api_base_url', - DEFAULT_API_BASE_URL, - ) const refreshInterval = getSettingWithDefault( 'refresh_interval', DEFAULT_REFRESH_INTERVAL_SECONDS, ) - const displayErrors = - getSettingWithDefault('display_errors', 'false') === 'true' - const reportError = createErrorReporter(displayErrors) - - if (!customerKey) { - showError('Please set a Customer Key in settings.') - signalReady() - return - } - - let accessToken: string | null = null - let credentialError: Error | null = null - - const refreshToken = async () => { - const devToken = getSettingWithDefault('access_token', '') - if (devToken) { - accessToken = devToken - credentialError = null - return - } - const { token } = await getCredentials() - accessToken = token - credentialError = null - } - - try { - await refreshToken() - } catch (err) { - credentialError = err instanceof Error ? err : new Error(String(err)) - console.warn('Failed to fetch initial credentials:', err) - } - - initTokenRefreshLoop(refreshToken) - - const getRuntimeState = (): RuntimeState => ({ accessToken, credentialError }) - - const run = () => - withFreshToken(getRuntimeState, refreshToken, reportError, async (token) => { - const data = await fetchDashboardData( - token, - baseUrl, - customerKey, - userGroupName, - ) - renderDashboard(data) - }) + const locale = await getLocale() - await run() + await refresh(locale) signalReady() - setInterval(async () => { - try { - await run() - } catch (err) { - console.error('Refresh failed:', err) - } - }, refreshInterval * 1000) + setInterval(() => refresh(locale), refreshInterval * 1000) }) diff --git a/src/render.ts b/src/render.ts deleted file mode 100644 index 32b98ac..0000000 --- a/src/render.ts +++ /dev/null @@ -1,98 +0,0 @@ -import type { AgentStatus, QueueStats } from './api' -import { fetchAgentStatuses, fetchQueueStats } from './api' -import { - agentStatusColor, - agentStatusLabel, - formatDuration, - showScreen, -} from './app' - -export type DashboardData = { - queues: QueueStats[] - agents: AgentStatus[] -} - -export async function fetchDashboardData( - accessToken: string, - baseUrl: string, - customerKey: string, - userGroupName: string, -): Promise { - const [queues, agents] = await Promise.all([ - fetchQueueStats(baseUrl, accessToken, customerKey), - fetchAgentStatuses(baseUrl, accessToken, customerKey, userGroupName), - ]) - - return { queues, agents } -} - -function renderQueues(queues: QueueStats[]): void { - const container = document.getElementById('queue-cards') - if (!container) return - - container.innerHTML = '' - queues.forEach((queue) => { - const card = document.createElement('div') - card.className = 'flex flex-col gap-3 rounded-xl bg-neutral-800 p-5' - - const title = document.createElement('h2') - title.className = 'text-2xl font-semibold' - title.textContent = queue.description - card.appendChild(title) - - const stats = document.createElement('dl') - stats.className = 'grid grid-cols-2 gap-x-4 gap-y-2 text-lg' - const rows: [string, string][] = [ - ['Ready', String(queue.agentsReady)], - ['Logged On', String(queue.agentsLoggedOn)], - ['In Queue', String(queue.queueSize)], - ['SLA', `${queue.sla}%`], - ['Offered', String(queue.callsOfferedToday)], - ['Answered', String(queue.callsAnsweredToday)], - ['Avg Wait', formatDuration(queue.waitTimeAverageSeconds)], - ['Max Wait', formatDuration(queue.waitTimeMaxSeconds)], - ] - rows.forEach(([label, value]) => { - const dt = document.createElement('dt') - dt.className = 'text-neutral-400' - dt.textContent = label - const dd = document.createElement('dd') - dd.className = 'text-right font-medium' - dd.textContent = value - stats.appendChild(dt) - stats.appendChild(dd) - }) - card.appendChild(stats) - - container.appendChild(card) - }) -} - -function renderAgents(agents: AgentStatus[]): void { - const container = document.getElementById('agent-tiles') - if (!container) return - - container.innerHTML = '' - agents.forEach((agent) => { - const tile = document.createElement('div') - tile.className = `flex flex-col items-center justify-center gap-1 rounded-lg p-4 text-center ${agentStatusColor(agent.contactCentreStatus)}` - - const name = document.createElement('span') - name.className = 'text-lg font-semibold' - name.textContent = `${agent.firstName} ${agent.lastName}` - tile.appendChild(name) - - const status = document.createElement('span') - status.className = 'text-sm uppercase tracking-wide opacity-90' - status.textContent = agentStatusLabel(agent.contactCentreStatus) - tile.appendChild(status) - - container.appendChild(tile) - }) -} - -export function renderDashboard(data: DashboardData): void { - renderQueues(data.queues) - renderAgents(data.agents) - showScreen('dashboard') -} diff --git a/src/style.css b/src/style.css index e9f5b14..648f987 100644 --- a/src/style.css +++ b/src/style.css @@ -1,4 +1,4 @@ -@layer theme, base, utilities; +@layer theme, base, components, utilities; @import 'tailwindcss/theme.css' layer(theme); @import '@screenly/edge-apps/styles' layer(base); @@ -13,6 +13,45 @@ margin: 0; padding: 0; overflow: hidden; + background-color: #171717; font-family: 'Inter', system-ui, sans-serif; } } + +@layer components { + .queue-card { + @apply flex flex-col gap-3 rounded-xl bg-neutral-800 p-5; + } + + .queue-card-title { + @apply text-5xl font-semibold; + } + + .queue-card-stats { + @apply grid grid-cols-2 gap-x-4 gap-y-2 text-2xl; + } + + .queue-card-stats dt { + @apply text-neutral-400; + } + + .queue-card-stats dd { + @apply text-right font-medium; + } + + .queue-card-chart { + @apply mt-1 h-32; + } + + .agent-tile { + @apply flex flex-col items-center justify-center gap-1 rounded-lg p-4 text-center; + } + + .agent-tile-name { + @apply text-3xl font-semibold; + } + + .agent-tile-status { + @apply text-2xl uppercase tracking-wide opacity-90; + } +} diff --git a/src/templates/agent-tile.lib.test.ts b/src/templates/agent-tile.lib.test.ts new file mode 100644 index 0000000..4198d0d --- /dev/null +++ b/src/templates/agent-tile.lib.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from 'bun:test' +import { agentStatusColor, agentStatusLabel } from './agent-tile.lib' + +describe('agentStatusLabel', () => { + test('maps known statuses to their labels', () => { + expect(agentStatusLabel('LoggedOff')).toBe('Logged Off') + expect(agentStatusLabel('LoggedOn')).toBe('Ready') + expect(agentStatusLabel('Pause')).toBe('Paused') + }) + + test('passes an unknown status through unchanged', () => { + expect(agentStatusLabel('OnBreak')).toBe('OnBreak') + }) +}) + +describe('agentStatusColor', () => { + test('maps known statuses to their colors', () => { + expect(agentStatusColor('LoggedOff')).toBe('bg-neutral-600') + expect(agentStatusColor('LoggedOn')).toBe('bg-emerald-600') + expect(agentStatusColor('Pause')).toBe('bg-amber-600') + }) + + test('falls back to the default color for an unknown status', () => { + expect(agentStatusColor('OnBreak')).toBe('bg-neutral-600') + }) +}) diff --git a/src/templates/agent-tile.lib.ts b/src/templates/agent-tile.lib.ts new file mode 100644 index 0000000..75a3f12 --- /dev/null +++ b/src/templates/agent-tile.lib.ts @@ -0,0 +1,19 @@ +const AGENT_STATUS_LABELS: Record = { + LoggedOff: 'Logged Off', + LoggedOn: 'Ready', + Pause: 'Paused', +} + +const AGENT_STATUS_COLORS: Record = { + LoggedOff: 'bg-neutral-600', + LoggedOn: 'bg-emerald-600', + Pause: 'bg-amber-600', +} + +export function agentStatusLabel(status: string): string { + return AGENT_STATUS_LABELS[status] ?? status +} + +export function agentStatusColor(status: string): string { + return AGENT_STATUS_COLORS[status] ?? 'bg-neutral-600' +} diff --git a/src/templates/agent-tile.ts b/src/templates/agent-tile.ts new file mode 100644 index 0000000..eecd998 --- /dev/null +++ b/src/templates/agent-tile.ts @@ -0,0 +1,39 @@ +import { html, render, type TemplateResult } from 'lit-html' +import type { AgentStatus } from '../api' +import { agentStatusColor, agentStatusLabel } from './agent-tile.lib' + +function agentTileTemplate(agent: AgentStatus): TemplateResult { + return html` +
+ ${agent.firstName} ${agent.lastName} + ${agentStatusLabel(agent.contactCentreStatus)} +
+ ` +} + +function renderTiles(container: HTMLElement, agents: AgentStatus[]): void { + render(html`${agents.map((agent) => agentTileTemplate(agent))}`, container) +} + +function countFittingTiles(container: HTMLElement): number { + const tiles = Array.from(container.children) as HTMLElement[] + const bottomLimit = container.getBoundingClientRect().bottom + + return tiles.filter( + (tile) => tile.getBoundingClientRect().bottom <= bottomLimit, + ).length +} + +export function mountAgentTiles( + container: HTMLElement, + agents: AgentStatus[], +): void { + renderTiles(container, agents) + + const fittingCount = countFittingTiles(container) + if (fittingCount < agents.length) { + renderTiles(container, agents.slice(0, fittingCount)) + } +} diff --git a/src/templates/index.ts b/src/templates/index.ts new file mode 100644 index 0000000..3e47063 --- /dev/null +++ b/src/templates/index.ts @@ -0,0 +1,15 @@ +import type { AgentStatus, QueueStats } from '../api' +import { mountAgentTiles } from './agent-tile' +import { mountQueueCards } from './queue-card' + +export type DashboardData = { queues: QueueStats[]; agents: AgentStatus[] } + +export function renderDashboard(data: DashboardData, locale: string): void { + const queueContainer = document.getElementById('queue-cards')! + const agentContainer = document.getElementById('agent-tiles')! + const dashboard = document.getElementById('dashboard')! + + dashboard.style.display = 'flex' + mountQueueCards(queueContainer, data.queues, locale) + mountAgentTiles(agentContainer, data.agents) +} diff --git a/src/templates/queue-card.lib.test.ts b/src/templates/queue-card.lib.test.ts new file mode 100644 index 0000000..8d3455d --- /dev/null +++ b/src/templates/queue-card.lib.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, test } from 'bun:test' +import { formatDuration } from './queue-card.lib' + +describe('formatDuration', () => { + test('formats seconds under a minute', () => { + expect(formatDuration(32)).toBe('0:32') + }) + + test('formats seconds over a minute, padding seconds', () => { + expect(formatDuration(95)).toBe('1:35') + }) +}) diff --git a/src/templates/queue-card.lib.ts b/src/templates/queue-card.lib.ts new file mode 100644 index 0000000..ff3cdad --- /dev/null +++ b/src/templates/queue-card.lib.ts @@ -0,0 +1,5 @@ +export function formatDuration(totalSeconds: number): string { + const minutes = Math.floor(totalSeconds / 60) + const seconds = totalSeconds % 60 + return `${minutes}:${String(seconds).padStart(2, '0')}` +} diff --git a/src/templates/queue-card.ts b/src/templates/queue-card.ts new file mode 100644 index 0000000..42fb57f --- /dev/null +++ b/src/templates/queue-card.ts @@ -0,0 +1,73 @@ +import { html, render, type TemplateResult } from 'lit-html' +import type { QueueStats } from '../api' +import { formatDuration } from './queue-card.lib' +import { mountQueueChart } from './queue-chart' + +function queueCardTemplate(queue: QueueStats, locale: string): TemplateResult { + const number = new Intl.NumberFormat(locale) + + return html` +
+

${queue.description}

+
+
Ready
+
${number.format(queue.agentsReady)}
+
Logged On
+
${number.format(queue.agentsLoggedOn)}
+
In Queue
+
${number.format(queue.queueSize)}
+
SLA
+
${number.format(queue.sla)}%
+
Offered
+
${number.format(queue.callsOfferedToday)}
+
Answered
+
${number.format(queue.callsAnsweredToday)}
+
Avg Wait
+
${formatDuration(queue.waitTimeAverageSeconds)}
+
Max Wait
+
${formatDuration(queue.waitTimeMaxSeconds)}
+
+
+
+ ` +} + +function renderCards( + container: HTMLElement, + queues: QueueStats[], + locale: string, +): void { + render( + html`${queues.map((queue) => queueCardTemplate(queue, locale))}`, + container, + ) + + for (const queue of queues) { + const chartContainer = document.getElementById(`queue-chart-${queue.id}`) + if (chartContainer) mountQueueChart(chartContainer, queue) + } +} + +function countFittingCards(container: HTMLElement): number { + const cards = Array.from(container.children) as HTMLElement[] + const bottomLimit = container.parentElement?.getBoundingClientRect().bottom + + if (bottomLimit === undefined) return cards.length + + return cards.filter( + (card) => card.getBoundingClientRect().bottom <= bottomLimit, + ).length +} + +export function mountQueueCards( + container: HTMLElement, + queues: QueueStats[], + locale: string, +): void { + renderCards(container, queues, locale) + + const fittingCount = countFittingCards(container) + if (fittingCount < queues.length) { + renderCards(container, queues.slice(0, fittingCount), locale) + } +} diff --git a/src/templates/queue-chart.ts b/src/templates/queue-chart.ts new file mode 100644 index 0000000..26abade --- /dev/null +++ b/src/templates/queue-chart.ts @@ -0,0 +1,51 @@ +import { Chart, registerables } from 'chart.js' +import type { QueueStats } from '../api' + +Chart.register(...registerables) +Chart.defaults.animation = false + +const CHART_COLORS = ['#4F8EF7', '#00C9A7', '#FFB347'] + +export function mountQueueChart( + container: HTMLElement, + queue: QueueStats, +): void { + const existingCanvas = container.querySelector('canvas') + if (existingCanvas) Chart.getChart(existingCanvas)?.destroy() + container.replaceChildren() + + const canvas = document.createElement('canvas') + container.appendChild(canvas) + + new Chart(canvas, { + type: 'bar', + data: { + labels: ['Offered', 'Answered', 'Callback'], + datasets: [ + { + data: [ + queue.callsOfferedToday, + queue.callsAnsweredToday, + queue.ciqsAnsweredToday, + ], + backgroundColor: CHART_COLORS, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { display: false } }, + scales: { + x: { + ticks: { color: '#ffffff', font: { size: 24 } }, + grid: { display: false }, + }, + y: { + ticks: { color: '#ffffff', font: { size: 24 } }, + grid: { color: '#ffffff22' }, + }, + }, + }, + }) +}