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.
This commit is contained in:
ScreenTinker 2026-05-12 14:04:21 -05:00
parent 42966da973
commit ce332ead67
3 changed files with 38 additions and 5 deletions

View file

@ -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) {
</svg>
</button>
<div class="workspace-switcher-menu" role="listbox">
${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 `
<div class="workspace-switcher-item ${w.id === currentId ? 'current' : ''}" data-workspace-id="${esc(w.id)}" role="option">
<svg class="check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" style="${w.id === currentId ? '' : 'visibility:hidden'}">
<polyline points="20 6 9 17 4 12"/>
</svg>
<div class="ws-meta">
<div class="ws-name">${esc(w.name)}</div>
<div class="ws-org">${esc(w.organization_name || '')}</div>
<div class="ws-org">${subtitle}</div>
</div>
${w.can_admin ? `
<button class="workspace-switcher-pencil" type="button" data-rename-id="${esc(w.id)}" aria-label="Rename workspace" title="Rename">
@ -54,7 +74,8 @@ export function renderWorkspaceSwitcher(me) {
</button>
` : ''}
</div>
`).join('')}
`;
}).join('')}
</div>
`;

View file

@ -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 <code style="background:var(--bg-input,#0f172a);padding:1px 4px;border-radius:3px">/player</code>',
'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',
};

View file

@ -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