From ce332ead676dd5c8b3d0be535a8fd8ad48e12d72 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Tue, 12 May 2026 14:04:21 -0500 Subject: [PATCH] feat(switcher): per-workspace device count in dropdown rows /me's accessible_workspaces query gains a device_count field via a correlated subquery on workspaces.id - WHERE workspace_id = w.id strictly excludes the unclaimed pair-pool (workspace_id IS NULL fails equality). Added to both query branches (platform_admin LEFT JOIN and regular INNER JOIN); microseconds per row at current scale (~37 rows worst case), not optimizing. Frontend appends the count to the muted org-name line with a middle-dot separator: 'Acme Studios . 2 devices'. Singular/plural respected via the existing tn() helper convention; 'No devices' for empty workspaces. New formatResourceCount(n, keyBase, zeroKey) helper is generic so the same shape can wire users/playlists/schedules counts later without refactor. New i18n keys: switcher.devices_count_one, switcher.devices_count_other, switcher.no_devices. Added to en.js only; other locales fall back to en via the existing lookup chain (verified in i18n.js:19). API smoke verified: switcher-test sees Studio A=2, Field Crew=2; dw5304 (platform_admin) sees all 37 workspaces with their device counts varying 0-4; single-workspace zero-device user (geoff.case) sees 0. --- frontend/js/components/workspace-switcher.js | 27 +++++++++++++++++--- frontend/js/i18n/en.js | 6 +++++ server/routes/auth.js | 10 ++++++-- 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/frontend/js/components/workspace-switcher.js b/frontend/js/components/workspace-switcher.js index 968eefd..1c8d213 100644 --- a/frontend/js/components/workspace-switcher.js +++ b/frontend/js/components/workspace-switcher.js @@ -1,5 +1,18 @@ import { api } from '../api.js'; import { showToast } from './toast.js'; +import { t, tn } from '../i18n.js'; + +// Reusable resource-count formatter. Returns localized "1 device" / "N devices" +// / "No devices" based on n. Generic so the same shape can wire users / +// playlists / schedules counts later without refactor - caller supplies the +// i18n key bases. +// keyBase: e.g. 'switcher.devices_count' (looks up _one / _other variants via tn) +// zeroKey: e.g. 'switcher.no_devices' (direct lookup for n === 0) +function formatResourceCount(n, keyBase, zeroKey) { + if (n === undefined || n === null) return ''; + if (n === 0) return t(zeroKey); + return tn(keyBase, n); +} // Render the workspace switcher inside #workspaceSwitcher based on the // /api/auth/me response. Three modes: @@ -37,14 +50,21 @@ export function renderWorkspaceSwitcher(me) {
- ${sorted.map(w => ` + ${sorted.map(w => { + const countStr = formatResourceCount(w.device_count, 'switcher.devices_count', 'switcher.no_devices'); + const orgName = w.organization_name || ''; + const subtitle = orgName && countStr ? esc(orgName) + ' ยท ' + esc(countStr) + : orgName ? esc(orgName) + : countStr ? esc(countStr) + : ''; + return `
${esc(w.name)}
-
${esc(w.organization_name || '')}
+
${subtitle}
${w.can_admin ? ` ` : ''}
- `).join('')} + `; + }).join('')}
`; diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index f5633f2..24d7a1d 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -1105,4 +1105,10 @@ export default { 'add_display.windows': 'Windows', 'add_display.smart_tv_note': 'Smart TVs (LG/Samsung): open the built-in browser and navigate to /player', 'add_display.pair_btn': 'Pair Display', + + // Workspace switcher (Phase 3 MVP). devices_count is the only count exposed + // today; matching pattern for users/playlists/etc. when those land later. + 'switcher.devices_count_one': '1 device', + 'switcher.devices_count_other': '{n} devices', + 'switcher.no_devices': 'No devices', }; diff --git a/server/routes/auth.js b/server/routes/auth.js index ce04777..c0db230 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -305,11 +305,16 @@ router.get('/me', requireAuth, resolveTenancy, (req, res) => { // UI can render admin affordances (rename pencil etc.) only where the // caller has permission. The server still enforces permission on the // actual mutation routes regardless of this advisory flag. + // device_count: correlated subquery on workspaces.id. Equality fails on NULL + // so unclaimed pair-pool devices (workspace_id IS NULL) are correctly excluded. + // Microseconds per row at current scale (~37 rows worst case for platform_admin); + // not optimizing - revisit if the admin list grows past a few hundred workspaces. const isPlatformAdmin = req.user.role === 'platform_admin' || req.user.role === 'superadmin'; const accessible = isPlatformAdmin ? db.prepare(` SELECT w.id, w.name, w.organization_id, o.name AS organization_name, - wm.role AS workspace_role, om.role AS org_role + wm.role AS workspace_role, om.role AS org_role, + (SELECT COUNT(*) FROM devices WHERE workspace_id = w.id) AS device_count FROM workspaces w JOIN organizations o ON o.id = w.organization_id LEFT JOIN workspace_members wm ON wm.workspace_id = w.id AND wm.user_id = ? @@ -318,7 +323,8 @@ router.get('/me', requireAuth, resolveTenancy, (req, res) => { `).all(req.user.id, req.user.id) : db.prepare(` SELECT w.id, w.name, w.organization_id, o.name AS organization_name, - wm.role AS workspace_role, om.role AS org_role + wm.role AS workspace_role, om.role AS org_role, + (SELECT COUNT(*) FROM devices WHERE workspace_id = w.id) AS device_count FROM workspace_members wm JOIN workspaces w ON w.id = wm.workspace_id JOIN organizations o ON o.id = w.organization_id