From ac6813ae82adfc646ddd9a2ce5d19eec4a3fb217 Mon Sep 17 00:00:00 2001 From: Ibrahim Roshdy <22573766+ibrahimroshdy@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:58:29 +0300 Subject: [PATCH 1/3] fix(map): open a room facing its screens (#150) A room opens with the camera on the front side of its screens (monitors, TVs, laptops, weighted by width), at the map's usual half-quarter angle; a room with none keeps the current turn. Turning inside a room (buttons or two fingers) is remembered per room on this device and wins next time. Try the map shares the engine, so it behaves the same. Closes #150 Signed-off-by: Ibrahim Roshdy <22573766+ibrahimroshdy@users.noreply.github.com> --- .../src/content/docs/users/location-map.mdx | 3 + apps/web/src/map/engine/engine.ts | 42 +++++-- apps/web/src/map/facing.test.ts | 109 ++++++++++++++++++ apps/web/src/map/layout.ts | 40 +++++++ apps/web/src/map/turns.ts | 48 ++++++++ 5 files changed, 234 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/map/facing.test.ts create mode 100644 apps/web/src/map/turns.ts diff --git a/apps/docs/src/content/docs/users/location-map.mdx b/apps/docs/src/content/docs/users/location-map.mdx index 3b5cb9a1..3730256a 100644 --- a/apps/docs/src/content/docs/users/location-map.mdx +++ b/apps/docs/src/content/docs/users/location-map.mdx @@ -60,6 +60,9 @@ The floor buttons work like a lift: **R**, **1**, **G** and so on, each named af buttons, and step aside while a card is open. **Plan** looks straight down, with names and what's in each shelf. Stairs on the map take you to the floor above or below. +A room opens facing the front of its screens (a monitor, a TV, a laptop), so you see them, not +their backs. Turn it another way and it opens that way next time on this device. + ![Plan: the same home seen from above, each room named](../../../assets/screens/map-plan-light.webp) ![The map on a phone: the List · Grid · Map switch, the 3D and Plan buttons, and a selected thing's card at the bottom](../../../assets/screens/map-phone-light.webp) diff --git a/apps/web/src/map/engine/engine.ts b/apps/web/src/map/engine/engine.ts index 3caf6e21..10cd9a22 100644 --- a/apps/web/src/map/engine/engine.ts +++ b/apps/web/src/map/engine/engine.ts @@ -24,6 +24,7 @@ import { childrenOn, clampIn, dropOn, + facingAz, fp, hostAt, type Item, @@ -33,10 +34,12 @@ import { type Room, rehome, roomAt, + screensFacing, snap, standingOn, turn, } from '../layout'; +import { nearestTurn, readTurn, writeTurn } from '../turns'; import { block, cy, @@ -209,6 +212,8 @@ export class MapEngine { prev: ReturnType; } | null = null; private lastTap = { t: 0, key: '' }; + /** Set when two fingers turned the view; the room it was turned in remembers it (#150). */ + private twisted = false; private drag: | { type: 'item'; id: string; off: THREE.Vector3 } | { type: 'resize'; id: string } @@ -878,7 +883,7 @@ export class MapEngine { return b; } - private fitTo(b: THREE.Box3, animate = true): void { + private fitTo(b: THREE.Box3, animate = true, az?: number): void { const c = b.getCenter(new THREE.Vector3()); const s = b.getSize(new THREE.Vector3()); const w = this.stage.clientWidth || 1; @@ -904,11 +909,13 @@ export class MapEngine { const units = ((this.view.span / zoom) * ((top - bottom) / 2)) / full; const away = this.s.planView ? new THREE.Vector3(0, 0, -units) - : new THREE.Vector3(-Math.sin(this.view.az), 0, -Math.cos(this.view.az)).multiplyScalar( - units / Math.sin(ELEV), - ); + : new THREE.Vector3( + -Math.sin(az ?? this.view.az), + 0, + -Math.cos(az ?? this.view.az), + ).multiplyScalar(units / Math.sin(ELEV)); const target = new THREE.Vector3(c.x, this.s.allFloors ? c.y - 1 : 0, c.z).add(away); - this.tween({ target, zoom }, animate); + this.tween({ target, zoom, ...(az === undefined ? {} : { az }) }, animate); } fitLevel = (animate = true): void => @@ -927,7 +934,19 @@ export class MapEngine { } turnBy(quarters: number): void { - this.tween({ az: this.view.az + (quarters * Math.PI) / 2 }); + const az = this.view.az + (quarters * Math.PI) / 2; + this.tween({ az }); + if (this.s.focused) writeTurn(this.s.focused, az); + } + + /** + * The turn a room opens at (#150): the one it was last left at on this device, else facing the + * front of its screens, else none (the view keeps its turn). The short way round. + */ + private roomTurn(roomId: string): number | undefined { + const rot = screensFacing(this.plan.items, roomId); + const want = readTurn(roomId) ?? (rot === null ? null : facingAz(rot)); + return want === null ? undefined : nearestTurn(this.view.az, want); } /** Home: out of a room, else the whole floor. */ @@ -1234,7 +1253,7 @@ export class MapEngine { if (this.s.allFloors || r.levelKey !== this.s.levelKey) this.setLevel(r.levelKey, false); this.s.focused = roomId; this.applyFocus(); - this.fitTo(this.boundsOfRooms([r])); + this.fitTo(this.boundsOfRooms([r]), true, this.roomTurn(roomId)); this.emit(); } @@ -1511,7 +1530,10 @@ export class MapEngine { if (a && b) this.view.target.add(a.sub(b)); if (now.n === 2 && prev.d > 0) { this.view.zoom = Math.max(0.2, Math.min(10, (this.view.zoom * now.d) / prev.d)); - if (!this.s.planView) this.view.az -= now.ang - prev.ang; + if (!this.s.planView && now.ang !== prev.ang) { + this.view.az -= now.ang - prev.ang; + this.twisted = true; + } } } this.gesture.prev = now; @@ -1660,6 +1682,10 @@ export class MapEngine { gesture.prev = this.snapshot(); return; } + if (this.twisted) { + this.twisted = false; + if (this.s.focused) writeTurn(this.s.focused, this.view.az); + } if (performance.now() - gesture.start < 350 && gesture.moved < 8) { const p = this.pick(e.clientX, e.clientY); if (this.s.allFloors) { diff --git a/apps/web/src/map/facing.test.ts b/apps/web/src/map/facing.test.ts new file mode 100644 index 00000000..c67f9adf --- /dev/null +++ b/apps/web/src/map/facing.test.ts @@ -0,0 +1,109 @@ +import { turnOffset } from '@kept/shared'; +import { afterEach, describe, expect, it } from 'vitest'; +import { facingAz, type Item, screensFacing } from './layout'; +import { nearestTurn, readTurn, writeTurn } from './turns'; + +// #150: a room opens facing the front of its screens, or as it was last turned on this device. + +const ROOM = 'room-study'; +let n = 0; +const item = (over: Partial): Item => ({ + id: `i${n++}`, + subject: 'thing', + roomId: ROOM, + name: 'x', + shortCode: null, + model: 'item', + x: 1, + z: 1, + rot: 0, + on: null, + parentId: ROOM, + isContainer: false, + quantity: 1, + auto: false, + ...over, +}); + +describe('screensFacing', () => { + it('is null in a room without a screen', () => { + expect(screensFacing([item({ model: 'desk' }), item({ model: 'webcam' })], ROOM)).toBeNull(); + }); + + it('takes the turn of the room’s screens, a TV counting more than a laptop', () => { + const items = [ + item({ model: 'desk', rot: 90 }), + item({ model: 'monitor', rot: 180 }), + item({ model: 'laptop', rot: 90 }), + item({ model: 'tv', rot: 270, roomId: 'elsewhere' }), + ]; + expect(screensFacing(items, ROOM)).toBe(180); + expect(screensFacing([...items, item({ model: 'tv', rot: 90 })], ROOM)).toBe(90); + }); + + it('keeps the first turn met on a tie', () => { + const items = [item({ model: 'monitor', rot: 270 }), item({ model: 'monitor', rot: 0 })]; + expect(screensFacing(items, ROOM)).toBe(270); + }); +}); + +describe('facingAz', () => { + it('is the map’s usual view for an unturned screen', () => { + expect(facingAz(0)).toBeCloseTo(Math.PI / 4); + // A screen facing +x is seen from the same corner, a whole turn away or not. + expect(Math.cos(facingAz(270) - Math.PI / 4)).toBeCloseTo(1); + }); + + it('looks from the side with fewer walls in the way', () => { + // A screen turned half round faces −z: from +x, so only the −z wall is between. + expect(Math.sin(facingAz(180))).toBeGreaterThan(0); + expect(Math.cos(facingAz(90))).toBeGreaterThan(0); + }); + + it('puts the camera on the screen’s front side for every turn', () => { + for (const rot of [0, 90, 180, 270]) { + const az = facingAz(rot); + const front = turnOffset(0, 1, rot); + // The camera sits towards (sin az, cos az): in front of the screen, 45° to its side. + const dot = Math.sin(az) * front.x + Math.cos(az) * front.z; + expect(dot).toBeCloseTo(Math.SQRT1_2); + } + }); +}); + +describe('remembered turns', () => { + afterEach(() => localStorage.clear()); + + it('remembers a room’s last turn on this device', () => { + expect(readTurn(ROOM)).toBeNull(); + writeTurn(ROOM, Math.PI / 4 + Math.PI * 2); + expect(readTurn(ROOM)).toBeCloseTo(Math.PI / 4, 3); + writeTurn(ROOM, -Math.PI / 4); + expect(readTurn(ROOM)).toBeCloseTo((7 * Math.PI) / 4, 3); + }); + + it('survives storage that is broken or throws', () => { + localStorage.setItem('kept.map.turns', '{not json'); + expect(readTurn(ROOM)).toBeNull(); + const set = Storage.prototype.setItem; + Storage.prototype.setItem = () => { + throw new Error('quota'); + }; + try { + expect(() => writeTurn(ROOM, 1)).not.toThrow(); + } finally { + Storage.prototype.setItem = set; + } + }); + + it('keeps the newest rooms when there are many', () => { + for (let i = 0; i < 205; i++) writeTurn(`r${i}`, 1); + expect(readTurn('r0')).toBeNull(); + expect(readTurn('r204')).toBe(1); + }); + + it('turns the short way round', () => { + expect(nearestTurn(Math.PI * 4 + 0.1, 0.2)).toBeCloseTo(Math.PI * 4 + 0.2); + expect(nearestTurn(-0.1, Math.PI * 1.9)).toBeCloseTo(-Math.PI * 0.1); + }); +}); diff --git a/apps/web/src/map/layout.ts b/apps/web/src/map/layout.ts index eac22662..d77a3ede 100644 --- a/apps/web/src/map/layout.ts +++ b/apps/web/src/map/layout.ts @@ -401,6 +401,46 @@ export function turn(items: readonly Item[], rooms: readonly Room[], it: Item): } } +// ---- Facing the screens (#150) ---------------------------------------------------------------- + +/** + * The turn most of a room's screens have (a monitor, a TV, a laptop: the models with a screen + * edge), each counted by its width so a TV outweighs a laptop; null when the room has none. Ties + * go to the first turn met. + */ +export function screensFacing(items: readonly Item[], roomId: string): number | null { + const weight = new Map(); + for (const it of items) { + if (it.roomId !== roomId || carryOf(it.model) !== 'edge') continue; + const r = ((it.rot % 360) + 360) % 360; + weight.set(r, (weight.get(r) ?? 0) + mapModel(it.model).w); + } + let best: number | null = null; + let most = 0; + for (const [r, w] of weight) { + if (w > most + 1e-9) { + best = r; + most = w; + } + } + return best; +} + +/** + * The 3D view's turn that looks at a model of this turn from its front, half a quarter to one side + * as the map always looks (the camera sits towards (sin az, cos az) from what it looks at, and a + * model's front points at turnOffset(0, 1, rot)). Of the two sides, the one with fewer of a + * room's walls between the camera and the room (they stand on its −x and −z sides): π/4, the + * map's usual view, for an unturned screen. + */ +export function facingAz(rot: number): number { + const front = -(rot * Math.PI) / 180; + const hidden = (az: number) => Number(Math.sin(az) < -1e-9) + Number(Math.cos(az) < -1e-9); + const a = front + Math.PI / 4; + const b = front - Math.PI / 4; + return hidden(b) < hidden(a) ? b : a; +} + // ---- Holders (#137): putting a thing on a desk, a screen's edge or an arm, and off again ------ /** Metres between things put side by side on a holder. */ diff --git a/apps/web/src/map/turns.ts b/apps/web/src/map/turns.ts new file mode 100644 index 00000000..b3c74735 --- /dev/null +++ b/apps/web/src/map/turns.ts @@ -0,0 +1,48 @@ +/** + * The way each room was last seen on this device (#150): the 3D view's turn when someone turned it + * inside a room, so the room opens that way next time. A convenience in local storage: without it + * (a private window, storage off) a room opens facing its screens. + */ + +const KEY = 'kept.map.turns'; +/** Rooms remembered at most; the oldest go first. */ +const MAX = 200; + +const TAU = Math.PI * 2; +const norm = (az: number) => Math.round((((az % TAU) + TAU) % TAU) * 1e4) / 1e4; + +function readAll(): Record { + try { + const raw = localStorage.getItem(KEY); + const v: unknown = raw ? JSON.parse(raw) : null; + return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record) : {}; + } catch { + return {}; + } +} + +/** The turn a room was last left at here, or null. */ +export function readTurn(roomId: string): number | null { + const v = readAll()[roomId]; + return typeof v === 'number' && Number.isFinite(v) ? v : null; +} + +/** Remembers a room's turn (radians), newest last. */ +export function writeTurn(roomId: string, az: number): void { + if (!Number.isFinite(az)) return; + const all = readAll(); + delete all[roomId]; + all[roomId] = norm(az); + const keys = Object.keys(all); + for (const k of keys.slice(0, Math.max(0, keys.length - MAX))) delete all[k]; + try { + localStorage.setItem(KEY, JSON.stringify(all)); + } catch { + // Remembering the turn is a convenience: the room still opens facing its screens. + } +} + +/** `want`, a whole number of turns away so the camera takes the short way round from `from`. */ +export function nearestTurn(from: number, want: number): number { + return want + Math.round((from - want) / TAU) * TAU; +} From fc8d9d67d0c8b1c1d4380c66ef8ab23499ced823 Mon Sep 17 00:00:00 2001 From: Ibrahim Roshdy <22573766+ibrahimroshdy@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:00:14 +0300 Subject: [PATCH 2/3] fix(map): monitor arms clamp at the desk's back edge (#151) Put on... and Add on it stand an arm at the back edge of a top (the side away from the chair, opposite the front its turn defines): centred, else a back corner, else anywhere along the back. It faces the desk's way, so its monitors hang over the desk in front of the pole; turning the desk turns the stack and keeps it at the back. Dragging an arm onto a desk snaps it to the back edge and slides it along it. Closes #151 Signed-off-by: Ibrahim Roshdy <22573766+ibrahimroshdy@users.noreply.github.com> --- .../src/content/docs/users/location-map.mdx | 5 +- apps/web/src/map/holders.test.ts | 85 ++++++++++++++++++- apps/web/src/map/layout.ts | 24 +++++- 3 files changed, 111 insertions(+), 3 deletions(-) diff --git a/apps/docs/src/content/docs/users/location-map.mdx b/apps/docs/src/content/docs/users/location-map.mdx index 3730256a..efc24797 100644 --- a/apps/docs/src/content/docs/users/location-map.mdx +++ b/apps/docs/src/content/docs/users/location-map.mdx @@ -173,7 +173,10 @@ What can carry what: light: small things up to 12 cm deep and 20 cm high, no wider than the screen. - **On an arm**: a **monitor arm** stands on a desk and holds one monitor, a **dual monitor arm** two, side by side and raised off the desk. **Add on it** on an arm lists the room's monitors - and says when the arm is full. A webcam on a monitor on an arm still works. + and says when the arm is full. A webcam on a monitor on an arm still works. An arm clamps at + the desk's back edge, the side away from the chair: in the middle, or at a back corner when the + middle is taken. Its monitors hang over the desk in front of it, and dragging it slides it along + the back. Moving a holder moves what is on it: move the desk and its arm, the monitors and their webcam go with it. Turn the desk and they turn with it. This is the map only: putting a webcam on a monitor diff --git a/apps/web/src/map/holders.test.ts b/apps/web/src/map/holders.test.ts index a103b457..ff00ef20 100644 --- a/apps/web/src/map/holders.test.ts +++ b/apps/web/src/map/holders.test.ts @@ -1,10 +1,11 @@ -import { mToCm, rectsOverlap } from '@kept/shared'; +import { mToCm, rectsOverlap, turnOffset, unturnOffset } from '@kept/shared'; import { describe, expect, it } from 'vitest'; import type { MapView } from '@/api/map/types'; import { addableOn, bodyOf, centreOf, + clampsAtBack, dropOn, fp, freeAlong, @@ -356,3 +357,85 @@ describe('old saves (#137)', () => { expect(on(id(5))).toBeNull(); }); }); + +describe('arms clamp at the back edge (#151)', () => { + // A desk's front is +z in its own frame: the back edge is the side away from the chair. + const BACK = -(fp({ model: 'desk', rot: 0 }).d / 2 - 0.05); + const local = (desk: Item, t: Item) => { + const dc = centreOf(desk); + const c = centreOf(t); + return unturnOffset(c.x - dc.x, c.z - dc.z, desk.rot); + }; + + it('stands a dual arm at the middle of a Corner desk’s back edge, monitors over the desk', () => { + const desk = item({ model: 'desk', name: 'Corner desk', x: 1, z: 1 }); + const arm = item({ model: 'dual_monitor_arm', name: 'Dual arm', x: 3, z: 3 }); + const left = item({ model: 'monitor', name: 'Left' }); + const right = item({ model: 'monitor', name: 'Right' }); + const items = [desk, arm, left, right]; + expect(clampsAtBack(arm.model)).toBe(true); + expect(clampsAtBack('webcam')).toBe(false); + expect(putOn(items, arm, desk)).toBe(true); + near(local(desk, arm), { x: 0, z: BACK }); + putOn(items, left, arm); + putOn(items, right, arm); + // In front of the pole, and over the desk. + for (const m of [left, right]) { + expect(local(desk, m).z).toBeGreaterThan(local(desk, arm).z); + expect(Math.abs(local(desk, m).z)).toBeLessThan(fp(desk).d / 2); + } + }); + + it('takes a back corner when the middle of the back is taken', () => { + const desk = item({ model: 'desk', name: 'Corner desk', x: 1, z: 1 }); + const first = item({ model: 'monitor_arm', name: 'Arm' }); + const second = item({ model: 'monitor_arm', name: 'Arm 2' }); + const items = [desk, first, second]; + putOn(items, first, desk); + putOn(items, second, desk); + near(local(desk, first), { x: 0, z: BACK }); + near(local(desk, second), { x: -(fp(desk).w / 2 - 0.05), z: BACK }); + }); + + it('puts an arm on a turned desk at that desk’s back, facing its way', () => { + const desk = item({ model: 'desk', name: 'Corner desk', x: 1, z: 1, rot: 180 }); + const arm = item({ model: 'monitor_arm', name: 'Arm' }); + const items = [desk, arm]; + putOn(items, arm, desk); + expect(arm.rot).toBe(180); + near(local(desk, arm), { x: 0, z: BACK }); + // In the room, the back of a desk turned half round is towards +z. + expect(centreOf(arm).z).toBeGreaterThan(centreOf(desk).z); + }); + + it('keeps the arm at the back when the desk turns', () => { + const desk = item({ model: 'desk', name: 'Corner desk', x: 1, z: 1 }); + const arm = item({ model: 'dual_monitor_arm', name: 'Dual arm' }); + const left = item({ model: 'monitor', name: 'Left' }); + const items = [desk, arm, left]; + putOn(items, arm, desk); + putOn(items, left, arm); + for (let i = 0; i < 4; i++) { + turn(items, rooms, desk); + near(local(desk, arm), { x: 0, z: BACK }); + expect(local(desk, left).z).toBeGreaterThan(local(desk, arm).z); + } + }); + + it('snaps a dragged arm onto the back edge, sliding along it', () => { + const desk = item({ model: 'desk', name: 'Corner desk', x: 1, z: 1, rot: 90 }); + const arm = item({ model: 'monitor_arm', name: 'Arm' }); + const items = [desk, arm]; + const dc = centreOf(desk); + // Dropped near the desk's front, a little to one side. + const p = turnOffset(0.3, 0.2, desk.rot); + dropOn(items, arm, desk, { x: dc.x + p.x, z: dc.z + p.z }); + expect(arm.on).toBe(desk.id); + expect(arm.rot).toBe(90); + near(local(desk, arm), { x: 0.3, z: BACK }); + // Past the end, it stops at the corner. + const far = turnOffset(2, 0, desk.rot); + dropOn(items, arm, desk, { x: dc.x + far.x, z: dc.z + far.z }); + near(local(desk, arm), { x: fp({ model: 'desk', rot: 0 }).w / 2 - 0.05, z: BACK }); + }); +}); diff --git a/apps/web/src/map/layout.ts b/apps/web/src/map/layout.ts index d77a3ede..7a7088d2 100644 --- a/apps/web/src/map/layout.ts +++ b/apps/web/src/map/layout.ts @@ -521,12 +521,23 @@ export function freeSpot( return out; }; const xs = steps(xr, x0); + const r2 = (v: number) => Math.round(v * 100) / 100; + // #151: an arm clamps at the back edge (away from the chair, the top's front being +z): in the + // middle, else a back corner, else anywhere along the back; only then where anything else goes. + if (clampsAtBack(it.model) && m.top !== undefined) { + const corners = [-xr, xr].sort((a, b) => Math.abs(a - near) - Math.abs(b - near)); + for (const x of [0, ...corners, ...xs]) + if (fits(x, -zr)) return { along: r2(x), depth: r2(-zr) }; + } for (const z of steps(zr, 0)) for (const x of xs) if (fits(x, z)) return { along: Math.round(x * 100) / 100, depth: Math.round(z * 100) / 100 }; return { along: x0, depth: 0 }; } +/** A monitor arm (#151): it clamps at the back edge of a top, facing the top's way. */ +export const clampsAtBack = (model: MapModelKey): boolean => carryOf(model) === 'arm'; + /** Across a holder only: an arm's free place or an edge's free spot (null when an arm is full). */ export function freeAlong( items: readonly Item[], @@ -664,7 +675,18 @@ export function dropOn( return; } const step = free ? 0.01 : snapStep(it.model); - const p = turnOffset(snap(local.x, step), snap(local.z, step), host.rot); + let lx = snap(local.x, step); + let lz = snap(local.z, step); + if (clampsAtBack(it.model)) { + // #151: an arm slides along the back edge only, facing the top's way. + const hm = mapModel(host.model); + const im = mapModel(it.model); + const xr = Math.max(0, hm.w / 2 - im.w / 2); + lx = Math.min(xr, Math.max(-xr, lx)); + lz = -Math.max(0, hm.d / 2 - im.d / 2); + turnStack(items, it, (host.rot - it.rot) / 90); + } + const p = turnOffset(lx, lz, host.rot); it.on = host.id; centreAt(items, it, { x: hc.x + p.x, z: hc.z + p.z }); } From 3241b9bdf02c21e533a02e09c7ae77fb3354e68a Mon Sep 17 00:00:00 2001 From: Ibrahim Roshdy <22573766+ibrahimroshdy@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:09:52 +0300 Subject: [PATCH 3/3] feat(map): Add on it puts things not on the map yet on a holder in one step (#152) Add on it also lists, under "Not on the map yet", the room's things that aren't on the map, each with the shape it would get (D249) and only when that shape could be carried and fits the holder. Picking one puts it on the map and on the holder in the same change: one Done, one Undo. Viewers are unchanged; Try the map does the same. The spec gets one D237 amendment row for #150-#152. Closes #152 Signed-off-by: Ibrahim Roshdy <22573766+ibrahimroshdy@users.noreply.github.com> --- apps/docs/src/components/try-map/demo.ts | 50 ++----- apps/docs/src/components/try-map/strings.ts | 15 +- .../src/content/docs/users/location-map.mdx | 2 + apps/web/src/locales/ar/messages.po | 16 +++ apps/web/src/locales/de/messages.po | 16 +++ apps/web/src/locales/en/messages.po | 16 +++ apps/web/src/locales/fr/messages.po | 16 +++ apps/web/src/locales/it/messages.po | 16 +++ apps/web/src/map/card.tsx | 6 +- apps/web/src/map/layout.ts | 32 +++++ apps/web/src/map/list.ts | 61 +++++++++ apps/web/src/map/map-view.tsx | 52 ++----- apps/web/src/map/map.css | 11 ++ apps/web/src/map/put-on.test.tsx | 129 +++++++++++++++++- apps/web/src/map/put-on.tsx | 86 +++++++++--- docs/specs/2026-09-25-kept-product-design.md | 1 + 16 files changed, 422 insertions(+), 103 deletions(-) diff --git a/apps/docs/src/components/try-map/demo.ts b/apps/docs/src/components/try-map/demo.ts index 177bca69..a5c8591f 100644 --- a/apps/docs/src/components/try-map/demo.ts +++ b/apps/docs/src/components/try-map/demo.ts @@ -29,7 +29,6 @@ import { type ShapeInput, STAIR_MODELS, searchShapes, - shapeOnAdd, suggestShapes, } from '@kept/shared'; import type { @@ -63,8 +62,9 @@ import { roomOn, seated, takeOff, + thingItem, } from '../../../../web/src/map/layout'; -import { matches, whereOf } from '../../../../web/src/map/list'; +import { addOn, matches, offMapFor, whereOf } from '../../../../web/src/map/list'; // Inline, not imported as stylesheets: Astro would link those from every page of the site. import mapCss from '../../../../web/src/map/map.css?inline'; // D249: the shape picker's pictures, the app's own sprite (fetched when the picker first opens). @@ -594,35 +594,14 @@ export function mount(root: HTMLElement, { lang, installHref }: MountOptions): ( /** Shapes picked in this session, kept if the thing goes off the map and back on (D249). */ const chosen = new Map(); + const chosenShape = (id: string) => chosen.get(id) ?? null; + function addThing(roomId: string, id: string) { const x = view.things.find((t) => t.id === id); if (!x) return; mutate((p) => { const room = p.rooms.find((r) => r.id === roomId); - if (!room) return; - const { model, from } = shapeOnAdd( - { name: x.name, typeKey: x.typeKey, typeName: x.typeName, isContainer: x.isContainer }, - chosen.get(x.id) ?? null, - ); - const it: Item = { - id: x.id, - subject: 'thing', - roomId, - name: x.name, - shortCode: x.shortCode, - model, - x: room.w / 2 - 0.3, - z: room.d / 2 - 0.3, - rot: 0, - on: null, - parentId: x.parentId, - isContainer: x.isContainer, - quantity: x.quantity, - auto: false, - ...(from === 'chosen' ? {} : { shapeFrom: from }), - }; - clampIn(it, room); - p.items.push(it); + if (room) p.items.push(thingItem(x, room, chosenShape(x.id))); }); engine?.select({ type: 'item', id: x.id }); } @@ -745,6 +724,8 @@ export function mount(root: HTMLElement, { lang, installHref }: MountOptions): ( if (!host) return; const room = plan.rooms.find((r) => r.id === host.roomId)?.name ?? ''; const things = addableOn(plan.items, host); + // #152: the room's things not on the map yet, with the shape each would get. + const offMap = offMapFor(plan, view, host, chosenShape); const max = capacityOf(host.model); const limited = Number.isFinite(max); const left = roomOn(plan.items, host); @@ -757,10 +738,14 @@ export function mount(root: HTMLElement, { lang, installHref }: MountOptions): ( : ''; const sub = (c: Item) => c.shortCode ? `${names[c.model]} · ${printedCode(c.shortCode)}` : names[c.model]; + const group = (label: string, list: Item[], heading: boolean) => + list.length + ? `
${heading ? `` : ''}${list.map((c) => putRow(c, sub(c))).join('')}
` + : ''; const body = full ? '' - : things.length - ? `
${things.map((c) => putRow(c, sub(c))).join('')}
+ : things.length || offMap.length + ? `
${group(w.put.onMap, things, offMap.length > 0)}${group(w.put.notOnMap, offMap, true)}
${btn('putcancel', w.cancel)}
` : `

${esc(carryOf(host.model) === 'arm' ? w.put.noMonitor(room) : w.put.allOn(room))}

`; const dlg = sheet( @@ -798,14 +783,7 @@ export function mount(root: HTMLElement, { lang, installHref }: MountOptions): ( add?.addEventListener('click', () => { const ids = [...picked]; dlg.close(); - mutate((p) => { - const h = p.items.find((x) => x.id === id); - if (!h) return; - for (const cid of ids) { - const t = p.items.find((x) => x.id === cid); - if (t) putOn(p.items, t, h); - } - }); + mutate((p) => addOn(p, view, id, ids, chosenShape)); engine?.select({ type: 'item', id }); }); dlg.querySelector('[role="option"][tabindex="0"]')?.focus(); diff --git a/apps/docs/src/components/try-map/strings.ts b/apps/docs/src/components/try-map/strings.ts index c5bbc570..1a74bc21 100644 --- a/apps/docs/src/components/try-map/strings.ts +++ b/apps/docs/src/components/try-map/strings.ts @@ -223,6 +223,9 @@ type Words = { left: (n: number, f: string) => string; noMonitor: (room: string) => string; allOn: (room: string) => string; + /** #152: Add on it's two lists. */ + onMap: string; + notOnMap: string; putN: (n: number, f: string) => string; pick: string; onItNow: (names: string) => string; @@ -365,8 +368,10 @@ export const WORDS: Record = { toAdd: 'Things to add on it', full: (n, f) => (n === 1 ? 'Full: it holds one monitor.' : `Full: it holds ${f} monitors.`), left: (n, f) => (n === 1 ? 'Room for one more.' : `Room for ${f} more.`), - noMonitor: (room) => `No monitor in ${room} is free. Put one on the map first.`, - allOn: (room) => `Everything small in ${room} is on something, or not on the map yet.`, + noMonitor: (room) => `No monitor in ${room} is free.`, + allOn: (room) => `Everything small in ${room} is on something already.`, + onMap: 'On the map', + notOnMap: 'Not on the map yet', putN: (_n, f) => `Put ${f} on it`, pick: 'Pick what goes on it', onItNow: (names) => `On it now: ${names}`, @@ -524,8 +529,10 @@ export const WORDS: Record = { 'مكان لـ# أخرى.', 'مكان لـ# أخرى.', ]), - noMonitor: (room) => `لا توجد شاشة متاحة في ${room}. ضع واحدة على الخريطة أولًا.`, - allOn: (room) => `كل شيء صغير في ${room} موضوع على شيء، أو ليس على الخريطة بعد.`, + noMonitor: (room) => `لا توجد شاشة متاحة في ${room}.`, + allOn: (room) => `كل شيء صغير في ${room} موضوع على شيء بالفعل.`, + onMap: 'على الخريطة', + notOnMap: 'ليست على الخريطة بعد', putN: (n, f) => arPlural(n, f, [ 'ضع # عليه', diff --git a/apps/docs/src/content/docs/users/location-map.mdx b/apps/docs/src/content/docs/users/location-map.mdx index efc24797..e1be22a3 100644 --- a/apps/docs/src/content/docs/users/location-map.mdx +++ b/apps/docs/src/content/docs/users/location-map.mdx @@ -162,6 +162,8 @@ A webcam is a few pixels wide at room zoom, so you don't have to drag it. In **A 3. To go the other way, select the desk or the monitor and tap **Add on it**. Pick one or several of the room's small things that aren't on anything yet, then **Put 3 on it**. They go side by side, not on top of each other. + The room's small things that aren't on the map yet are there too, under **Not on the map yet**, + with the shape each would get: picking one puts it on the map and on the desk in one go. 4. **Take off**, in **Put on…**, puts the thing back on the room's floor next to its holder. ![Put on… for a webcam: the room's desk and monitor, each with its picture](../../../assets/screens/map-put-on-phone-light.webp) diff --git a/apps/web/src/locales/ar/messages.po b/apps/web/src/locales/ar/messages.po index 10e89a1e..f832e33f 100644 --- a/apps/web/src/locales/ar/messages.po +++ b/apps/web/src/locales/ar/messages.po @@ -25137,3 +25137,19 @@ msgstr "اطلب من المالك إعداد البريد الإلكتروني. #: src/components/reports/print-sheet.tsx msgid "Add an email address to your account." msgstr "أضف عنوان بريد إلكتروني إلى حسابك." + +#: src/map/put-on.tsx +msgid "On the map" +msgstr "على الخريطة" + +#: src/map/put-on.tsx +msgid "Not on the map yet" +msgstr "ليست على الخريطة بعد" + +#: src/map/put-on.tsx +msgid "No monitor in {roomName} is free." +msgstr "لا توجد شاشة متاحة في {roomName}." + +#: src/map/put-on.tsx +msgid "Everything small in {roomName} is on something already." +msgstr "كل شيء صغير في {roomName} موضوع على شيء بالفعل." diff --git a/apps/web/src/locales/de/messages.po b/apps/web/src/locales/de/messages.po index cd01e469..fcb8a0d7 100644 --- a/apps/web/src/locales/de/messages.po +++ b/apps/web/src/locales/de/messages.po @@ -25137,3 +25137,19 @@ msgstr "Bitte den Eigentümer, E-Mail einzurichten." #: src/components/reports/print-sheet.tsx msgid "Add an email address to your account." msgstr "Füge deinem Konto eine E-Mail-Adresse hinzu." + +#: src/map/put-on.tsx +msgid "On the map" +msgstr "Auf der Karte" + +#: src/map/put-on.tsx +msgid "Not on the map yet" +msgstr "Noch nicht auf der Karte" + +#: src/map/put-on.tsx +msgid "No monitor in {roomName} is free." +msgstr "Kein freier Monitor in {roomName}." + +#: src/map/put-on.tsx +msgid "Everything small in {roomName} is on something already." +msgstr "Alles Kleine in {roomName} steht schon auf etwas." diff --git a/apps/web/src/locales/en/messages.po b/apps/web/src/locales/en/messages.po index 6a6e1060..42e915dd 100644 --- a/apps/web/src/locales/en/messages.po +++ b/apps/web/src/locales/en/messages.po @@ -25137,3 +25137,19 @@ msgstr "Ask the owner to set up email." #: src/components/reports/print-sheet.tsx msgid "Add an email address to your account." msgstr "Add an email address to your account." + +#: src/map/put-on.tsx +msgid "On the map" +msgstr "On the map" + +#: src/map/put-on.tsx +msgid "Not on the map yet" +msgstr "Not on the map yet" + +#: src/map/put-on.tsx +msgid "No monitor in {roomName} is free." +msgstr "No monitor in {roomName} is free." + +#: src/map/put-on.tsx +msgid "Everything small in {roomName} is on something already." +msgstr "Everything small in {roomName} is on something already." diff --git a/apps/web/src/locales/fr/messages.po b/apps/web/src/locales/fr/messages.po index 1f4a3d05..e5e08ac2 100644 --- a/apps/web/src/locales/fr/messages.po +++ b/apps/web/src/locales/fr/messages.po @@ -25137,3 +25137,19 @@ msgstr "Demandez au propriétaire de configurer l’e-mail." #: src/components/reports/print-sheet.tsx msgid "Add an email address to your account." msgstr "Ajoutez une adresse e-mail à votre compte." + +#: src/map/put-on.tsx +msgid "On the map" +msgstr "Sur la carte" + +#: src/map/put-on.tsx +msgid "Not on the map yet" +msgstr "Pas encore sur la carte" + +#: src/map/put-on.tsx +msgid "No monitor in {roomName} is free." +msgstr "Aucun écran libre dans {roomName}." + +#: src/map/put-on.tsx +msgid "Everything small in {roomName} is on something already." +msgstr "Tout ce qui est petit dans {roomName} est déjà posé sur quelque chose." diff --git a/apps/web/src/locales/it/messages.po b/apps/web/src/locales/it/messages.po index c6b350b2..d773b32e 100644 --- a/apps/web/src/locales/it/messages.po +++ b/apps/web/src/locales/it/messages.po @@ -25137,3 +25137,19 @@ msgstr "Chiedi al proprietario di configurare l'email." #: src/components/reports/print-sheet.tsx msgid "Add an email address to your account." msgstr "Aggiungi un indirizzo email al tuo account." + +#: src/map/put-on.tsx +msgid "On the map" +msgstr "Sulla mappa" + +#: src/map/put-on.tsx +msgid "Not on the map yet" +msgstr "Non ancora sulla mappa" + +#: src/map/put-on.tsx +msgid "No monitor in {roomName} is free." +msgstr "Nessun monitor libero in {roomName}." + +#: src/map/put-on.tsx +msgid "Everything small in {roomName} is on something already." +msgstr "Tutto ciò che è piccolo in {roomName} è già sopra qualcosa." diff --git a/apps/web/src/map/card.tsx b/apps/web/src/map/card.tsx index 59fb4465..0b3ed73b 100644 --- a/apps/web/src/map/card.tsx +++ b/apps/web/src/map/card.tsx @@ -41,7 +41,7 @@ import { type Room, seated, } from './layout'; -import { matches, whereOf } from './list'; +import { type ChosenShape, matches, whereOf } from './list'; import { AddOnSheet, PutOnSheet } from './put-on'; import { ShapeField, shapeInputOf } from './shapes'; @@ -74,6 +74,8 @@ export type CardProps = { onPutOn: (id: string, hostId: string) => void; onTakeOff: (id: string) => void; onAddOn: (hostId: string, ids: string[]) => void; + /** #152: the shape a thing not on the map would get back, for Add on it. */ + chosenShape?: ChosenShape; }; function Dots({ flags }: { flags: MapAttentionKind[] }) { @@ -598,6 +600,8 @@ export function MapCard(p: CardProps) { setSheet(open ? 'add' : null)} onAdd={(ids) => { diff --git a/apps/web/src/map/layout.ts b/apps/web/src/map/layout.ts index 7a7088d2..bea4db0d 100644 --- a/apps/web/src/map/layout.ts +++ b/apps/web/src/map/layout.ts @@ -27,6 +27,7 @@ import { ROOM_KIND_SURFACE, rectsOverlap, type ShapeFrom, + shapeOnAdd, snapStep, turnOffset, unturnOffset, @@ -691,6 +692,37 @@ export function dropOn( centreAt(items, it, { x: hc.x + p.x, z: hc.z + p.z }); } +/** + * A thing of a room as it goes on the map (D249): the shape someone already chose for it, else + * the first suggestion from its type and name (`shapeFrom` says which, or that it is a guess), + * in the middle of the room, unturned, on nothing. + */ +export function thingItem(x: MapThingView, room: Room, chosen: MapModelKey | null = null): Item { + const { model, from } = shapeOnAdd( + { name: x.name, typeKey: x.typeKey, typeName: x.typeName, isContainer: x.isContainer }, + chosen, + ); + const it: Item = { + id: x.id, + subject: 'thing', + roomId: room.id, + name: x.name, + shortCode: x.shortCode, + model, + x: room.w / 2 - 0.3, + z: room.d / 2 - 0.3, + rot: 0, + on: null, + parentId: x.parentId, + isContainer: x.isContainer, + quantity: x.quantity, + auto: false, + ...(from === 'chosen' ? {} : { shapeFrom: from }), + }; + clampIn(it, room); + return it; +} + /** Whether a rectangle overlaps another room on the level. */ export function overlapsRoom( rooms: readonly Room[], diff --git a/apps/web/src/map/list.ts b/apps/web/src/map/list.ts index 073b05d1..4532d3b9 100644 --- a/apps/web/src/map/list.ts +++ b/apps/web/src/map/list.ts @@ -3,15 +3,19 @@ * in them, or what needs attention (low stock, lent out, due soon), with a search, a floor * filter and Show more. Pure, so the filters are tested without the map. */ +import { canBeCarried, fitsOn, type MapModelKey } from '@kept/shared'; import type { MapAttention, MapAttentionKind, MapView } from '@/api/map/types'; import { allUnder, type Contents, + canPutOn, flagsOf, type Item, type Level, type Plan, + putOn, type Room, + thingItem, } from './layout'; export type ListShow = 'room' | 'attention'; @@ -173,6 +177,63 @@ export function whereOf(plan: Plan, view: Pick, id: string) { return { path, roomId }; } +/** A shape someone chose for a thing in this session, or that it has on the saved map. */ +export type ChosenShape = (id: string) => MapModelKey | null; + +/** + * Add on it's second list (#152): the holder's room's things that aren't on the map yet and, with + * the shape they'd get (thingItem), are small enough to go on something (they'd get Put on…) and + * fit on this holder; each as the item it would become, by name. + */ +export function offMapFor( + plan: Plan, + view: Pick, + host: Item, + chosen: ChosenShape = () => null, +): Item[] { + const room = plan.rooms.find((r) => r.id === host.roomId); + if (!room) return []; + const onMap = new Set(plan.items.map((i) => i.id)); + return view.things + .filter((x) => !onMap.has(x.id) && whereOf(plan, view, x.id).roomId === room.id) + .map((x) => thingItem(x, room, chosen(x.id))) + .filter( + (it) => + canBeCarried(it.model) && fitsOn(host.model, it.model) && canPutOn(plan.items, host, it), + ) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +/** + * Add on it, in one step (#152): puts the picked things on a holder side by side; one not on the + * map yet goes on it first, as offMapFor showed it. One change to the plan, so one Done saves it + * and one Undo takes it back. + */ +export function addOn( + plan: Plan, + view: Pick, + hostId: string, + ids: readonly string[], + chosen: ChosenShape = () => null, +): void { + const host = plan.items.find((i) => i.id === hostId); + const room = plan.rooms.find((r) => r.id === host?.roomId); + if (!host || !room) return; + for (const id of ids) { + const was = plan.items.find((i) => i.id === id); + if (was) { + putOn(plan.items, was, host); + continue; + } + const x = view.things.find((t) => t.id === id); + if (!x || whereOf(plan, view, id).roomId !== room.id) continue; + const it = thingItem(x, room, chosen(id)); + plan.items.push(it); + // It goes on the map only to go on the holder: if it can't (the holder filled up), it doesn't. + if (!putOn(plan.items, it, host)) plan.items.pop(); + } +} + export function attentionGroups( plan: Plan, view: Pick, diff --git a/apps/web/src/map/map-view.tsx b/apps/web/src/map/map-view.tsx index d74d9c57..094f39c2 100644 --- a/apps/web/src/map/map-view.tsx +++ b/apps/web/src/map/map-view.tsx @@ -17,7 +17,6 @@ import { type MapRoomKind, type MapSurface, ROOM_KIND_SURFACE, - shapeOnAdd, } from '@kept/shared'; import { Trans, useLingui } from '@lingui/react/macro'; import { useQueryClient } from '@tanstack/react-query'; @@ -48,14 +47,14 @@ import { contentsOf, countIn, flagsOf, - type Item, type Plan, planOf, putOn, type Room, takeOff, + thingItem, } from './layout'; -import { type ListQuery, type ListShow, whereOf } from './list'; +import { addOn, type ListQuery, type ListShow, whereOf } from './list'; import { WhatsIn } from './whats-in'; import './map.css'; @@ -332,36 +331,18 @@ function MapBody({ data, locationId, name }: { data: MapData; locationId: string /** Shapes someone picked in this session, kept if the thing goes off the map and back on. */ const chosen = useRef(new Map()); + /** D249: a shape someone chose stays (in this session, or on the saved map); otherwise the + * first suggestion from its type and name, said on the card ("from its type") when Kept is + * sure, a guess to check when not. */ + const chosenShape = (id: string): MapModelKey | null => { + const stored = data.nodes.find((n) => n.id === id)?.layout?.model; + return chosen.current.get(id) ?? (isMapModel(stored) ? stored : null); + }; + function addThing(roomId: string, x: MapThingView) { mutate((p) => { const room = p.rooms.find((r) => r.id === roomId); - if (!room) return; - // D249: a shape someone chose stays; otherwise the first suggestion from its type and - // name, said on the card ("from its type") when Kept is sure, a guess to check when not. - const stored = data.nodes.find((n) => n.id === x.id)?.layout?.model; - const { model, from } = shapeOnAdd( - { name: x.name, typeKey: x.typeKey, typeName: x.typeName, isContainer: x.isContainer }, - chosen.current.get(x.id) ?? (isMapModel(stored) ? stored : null), - ); - const it: Item = { - id: x.id, - subject: 'thing', - roomId, - name: x.name, - shortCode: x.shortCode, - model, - x: room.w / 2 - 0.3, - z: room.d / 2 - 0.3, - rot: 0, - on: null, - parentId: x.parentId, - isContainer: x.isContainer, - quantity: x.quantity, - auto: false, - ...(from === 'chosen' ? {} : { shapeFrom: from }), - }; - clampIn(it, room); - p.items.push(it); + if (room) p.items.push(thingItem(x, room, chosenShape(x.id))); }); engineRef.current?.select({ type: 'item', id: x.id }); } @@ -403,15 +384,9 @@ function MapBody({ data, locationId, name }: { data: MapData; locationId: string engineRef.current?.select({ type: 'item', id }); } + /** #152: things not on the map yet go on it and on the holder in the same change. */ function addOnIt(hostId: string, ids: string[]) { - mutate((p) => { - const host = p.items.find((i) => i.id === hostId); - if (!host) return; - for (const id of ids) { - const it = p.items.find((i) => i.id === id); - if (it) putOn(p.items, it, host); - } - }); + mutate((p) => addOn(p, data, hostId, ids, chosenShape)); engineRef.current?.select({ type: 'item', id: hostId }); } @@ -876,6 +851,7 @@ function MapBody({ data, locationId, name }: { data: MapData; locationId: string onPutOn={putThingOn} onTakeOff={takeThingOff} onAddOn={addOnIt} + chosenShape={chosenShape} /> ) : null} diff --git a/apps/web/src/map/map.css b/apps/web/src/map/map.css index 61f242e0..a7da4926 100644 --- a/apps/web/src/map/map.css +++ b/apps/web/src/map/map.css @@ -939,6 +939,17 @@ details[open] > .kmap-orow .kmap-chev { padding: 2px; outline: none; } +.kput-section { + display: grid; + gap: 6px; +} +.kput-section + .kput-section { + margin-block-start: 8px; +} +.kput-head { + color: var(--ink-2); + font: 600 12px / 1.3 var(--sans); +} .kput-option { display: flex; align-items: center; diff --git a/apps/web/src/map/put-on.test.tsx b/apps/web/src/map/put-on.test.tsx index 3b97b769..c806b5bd 100644 --- a/apps/web/src/map/put-on.test.tsx +++ b/apps/web/src/map/put-on.test.tsx @@ -1,10 +1,11 @@ import { act, screen, within } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; -import type { MapView } from '@/api/map/types'; +import type { MapThingView, MapView } from '@/api/map/types'; import { printedCode } from '@/components/id-chip'; import { expectLogicalOnly, renderUI } from '@/test/render'; import { MapCard } from './card'; -import { contentsOf, type Item, type Plan, putOn } from './layout'; +import { bodyOf, contentsOf, type Item, type Plan, putOn } from './layout'; +import { addOn, offMapFor } from './list'; import { AddOnSheet, PutOnSheet } from './put-on'; // #137: Put on… and Add on it, the cards that open them, and what a viewer sees. @@ -322,3 +323,127 @@ describe('the card (#137)', () => { await act(async () => {}); }); }); + +// #152: Add on it also lists the room's things that aren't on the map yet, and puts one on the map +// and on the holder in one step. +describe('Add on it with things not on the map yet (#152)', () => { + const thing = (over: Partial): MapThingView => ({ + id: id(n++), + name: 'x', + shortCode: null, + quantity: 1, + parentId: ROOM, + typeKey: null, + typeName: null, + isContainer: false, + ...over, + }); + const things = () => { + const lamp = thing({ name: 'Desk lamp', shortCode: 'LMP456' }); + const cam = thing({ name: 'Spare webcam' }); + const fridge = thing({ name: 'Fridge' }); + const away = thing({ name: 'Hall webcam', parentId: id(77) }); + return { lamp, cam, fridge, away, list: [lamp, cam, fridge, away] }; + }; + + it('lists the room’s things that would fit, with the shape each would get', () => { + const { plan, desk, monitor } = office(); + const { lamp, cam, list } = things(); + const off = offMapFor(plan, { things: list }, desk); + expect(off.map((i) => [i.name, i.model])).toEqual([ + ['Desk lamp', 'table_lamp'], + ['Spare webcam', 'webcam'], + ]); + // Nothing moved: they are only shown. + expect(plan.items.some((i) => i.id === lamp.id || i.id === cam.id)).toBe(false); + // A screen's edge takes the webcam, not the lamp. + expect(offMapFor(plan, { things: list }, monitor).map((i) => i.name)).toEqual(['Spare webcam']); + // A shape someone chose wins. + expect(offMapFor(plan, { things: list }, desk, () => 'speaker').map((i) => i.model)).toEqual([ + 'speaker', + 'speaker', + 'speaker', + ]); + }); + + it('puts one on the map and on the desk in one change, saved in one body', () => { + const { plan, desk, webcam } = office(); + const { cam, list } = things(); + const before = plan.items.length; + addOn(plan, { things: list }, desk.id, [cam.id, webcam.id]); + expect(plan.items.length).toBe(before + 1); + const added = plan.items.find((i) => i.id === cam.id); + expect(added?.on).toBe(desk.id); + expect(added?.model).toBe('webcam'); + expect(webcam.on).toBe(desk.id); + const body = bodyOf(plan, null); + expect(body.nodes.find((x) => x.id === cam.id)).toMatchObject({ + hostId: desk.id, + model: 'webcam', + }); + }); + + it('leaves off what isn’t in the room or can’t go on', () => { + const { plan, desk } = office(); + const arm = item({ model: 'monitor_arm', name: 'Arm' }); + plan.items.push(arm); + const { cam, away, list } = things(); + const before = plan.items.length; + addOn(plan, { things: list }, desk.id, [away.id]); + addOn(plan, { things: list }, arm.id, [cam.id]); + expect(plan.items.length).toBe(before); + }); + + it('shows them under their own heading, and hands both kinds back on Put', async () => { + const { plan, desk, keyboard } = office(); + const { cam, list } = things(); + const onAdd = vi.fn(); + const { user } = await renderUI( + {}} + onAdd={onAdd} + />, + ); + expect(screen.getByText('On the map')).toBeTruthy(); + const off = screen.getByRole('group', { name: 'Not on the map yet' }); + expect( + within(off) + .getAllByRole('option') + .map((o) => o.textContent), + ).toEqual([`Desk lampTable lamp · ${printedCode('LMP456')}`, 'Spare webcamWebcam']); + await user.click(within(off).getByRole('option', { name: 'Spare webcam' })); + await user.click(screen.getByRole('option', { name: 'Keyboard' })); + await user.click(screen.getByRole('button', { name: 'Put 2 on it' })); + expect(onAdd).toHaveBeenCalledWith([cam.id, keyboard.id]); + }); + + it('shows no heading when nothing is off the map', async () => { + const { plan, desk } = office(); + await renderUI( + {}} + onAdd={vi.fn()} + />, + ); + expect(screen.queryByText('On the map')).toBeNull(); + expect(screen.queryByText('Not on the map yet')).toBeNull(); + }); + + it('opens from a holder’s card with the location’s things', async () => { + const { plan, desk } = office(); + const { list } = things(); + const v = { ...view(plan), things: list }; + const { user, props } = await card(plan, desk, true, { view: v, contents: contentsOf(v) }); + await user.click(screen.getByRole('button', { name: 'Add on it' })); + await user.click(screen.getByRole('option', { name: 'Spare webcam' })); + await user.click(screen.getByRole('button', { name: 'Put 1 on it' })); + expect(props.onAddOn).toHaveBeenCalledWith(desk.id, [list[1]?.id]); + }); +}); diff --git a/apps/web/src/map/put-on.tsx b/apps/web/src/map/put-on.tsx index 884214db..dbbe21aa 100644 --- a/apps/web/src/map/put-on.tsx +++ b/apps/web/src/map/put-on.tsx @@ -9,16 +9,26 @@ import { capacityOf, carryOf } from '@kept/shared'; import { Plural, Trans, useLingui } from '@lingui/react/macro'; import { useMemo, useState } from 'react'; -import { ListBox, ListBoxItem, type Selection, Text } from 'react-aria-components'; +import { + Header, + ListBox, + ListBoxItem, + ListBoxSection, + type Selection, + Text, +} from 'react-aria-components'; +import type { MapView } from '@/api/map/types'; import { printedCode } from '@/components/id-chip'; import { Sheet } from '@/components/things/sheet'; import { Button } from '@/components/ui/button'; import { TextField } from '@/components/ui/text-field'; import { useModelNames } from './labels'; import { addableOn, childrenOn, holdersFor, type Item, type Plan, roomOn } from './layout'; -import { matches } from './list'; +import { type ChosenShape, matches, offMapFor } from './list'; import { ShapeThumb } from './shapes'; +const NO_THINGS: Pick = { things: [] }; + /** A list longer than this gets a search. */ const SEARCH_FROM = 8; @@ -117,16 +127,25 @@ export function PutOnSheet({ ); } -/** Add on it: the room's things on nothing yet, several at once, side by side on the holder. */ +/** + * Add on it: the room's things on nothing yet, several at once, side by side on the holder; and + * under "Not on the map yet" (#152) the room's things that aren't on the map, each with the shape + * it would get: picking one puts it on the map and on the holder in the same step. + */ export function AddOnSheet({ host, plan, + view = NO_THINGS, + chosenShape, roomName, onOpenChange, onAdd, }: { host: Item; plan: Plan; + /** The location's things, for those not on the map yet (#152). */ + view?: Pick; + chosenShape?: ChosenShape | undefined; roomName: string; onOpenChange: (open: boolean) => void; onAdd: (ids: string[]) => void; @@ -135,14 +154,22 @@ export function AddOnSheet({ const names = useModelNames(); const [query, setQuery] = useState(''); const [picked, setPicked] = useState>(new Set()); - const things = useMemo(() => addableOn(plan.items, host), [plan.items, host]); + const onMap = useMemo(() => addableOn(plan.items, host), [plan.items, host]); + // biome-ignore lint/correctness/useExhaustiveDependencies: the shapes chosen don't change while it is open + const offMap = useMemo(() => offMapFor(plan, view, host, chosenShape), [plan, view, host]); + const total = onMap.length + offMap.length; const max = capacityOf(host.model); const limited = Number.isFinite(max); const left = roomOn(plan.items, host); const full = limited && left <= 0; - const rows = things.filter((c) => matches(query, c.name, c.shortCode)); + const find = (c: Item) => matches(query, c.name, c.shortCode); + const rowsOn = onMap.filter(find); + const rowsOff = offMap.filter(find); + const rows = [...rowsOn, ...rowsOff]; // An arm takes no more than its places: the rest can't be picked once they are taken. const disabled = limited && picked.size >= left ? rows.filter((c) => !picked.has(c.id)) : []; + const sub = (c: Item) => + c.shortCode ? `${names[c.model]} · ${printedCode(c.shortCode)}` : names[c.model]; const n = picked.size; const onNow = childrenOn(plan.items, host.id) .map((c) => c.name) @@ -166,7 +193,7 @@ export function AddOnSheet({ ) : null} {full ? null : ( <> - {things.length > SEARCH_FROM ? ( + {total > SEARCH_FROM ? ( (

- {things.length ? ( + {total ? ( Nothing matches. ) : carryOf(host.model) === 'arm' ? ( - No monitor in {roomName} is free. Put one on the map first. + No monitor in {roomName} is free. ) : ( - - Everything small in {roomName} is on something, or not on the map yet. - + Everything small in {roomName} is on something already. )}

)} > - {rows.map((c) => ( - - ))} + {[ + ...(rowsOn.length + ? [ + + {rowsOff.length ? ( +
+ On the map +
+ ) : null} + {rowsOn.map((c) => ( + + ))} +
, + ] + : []), + ...(rowsOff.length + ? [ + +
+ Not on the map yet +
+ {rowsOff.map((c) => ( + + ))} +
, + ] + : []), + ]}