Files
claw3d/src/features/retro-office/core/geometry.ts
T
Luke The Dev a997f13601 feat(kanban): Interactive Kanban board with real-time task tracking (#83)
* feat(kanban): add Kanban board with task-manager skill, modal UI, and desk clutter

Implement a full Kanban board system for tracking agent tasks:
- Add task-manager skill with shared JSON task store for persistence
- Render board as a floating modal over the live 3D office (not immersive)
- Auto-create tasks from actionable user messages with heuristic filtering
- Sync task status through OpenClaw agent lifecycle events
- Collapse task details panel by default, expand on card click
- Add dynamic desk clutter (papers, folders, etc.) reflecting active task count
- Exclude done tasks from desk clutter count
- Extract KANBAN_CLUTTER_OFFSET for easy positioning adjustment
- Add install flow with progress bar for the task-manager skill
- Include unit and e2e test coverage

Made-with: Cursor

* feat(kanban): production-harden task board with AI-free classification, resilient persistence, and modal UX

- Harden shared task store with atomic writes, payload size limits, and server-side enum validation
- Add client resilience: request timeouts (AbortController), exponential backoff retries, poll deduplication
- Implement optimistic UI with rollback on all card mutations (update, move, archive)
- Add modal accessibility: focus trap, Escape to close, aria-modal, keyboard card navigation
- Trust OpenClaw agent lifecycle phase=start as task classification signal instead of regex heuristics
- Keep regex heuristic only as lightweight filter for direct chat events (conversational noise)
- Expand verb recognition with typo tolerance and broader action vocabulary
- Create tasks from agent runs even when no chat event is received (external channel support)
- Merge dual header bars into single bar; reposition close button outside modal corner
- Exclude done tasks from desk clutter count; make clutter position configurable via KANBAN_CLUTTER_OFFSET
- Update default furniture layout to match user configuration
- Ensure kanban_board furniture persists in local storage across sessions
- Add comprehensive test coverage for store, API route, and controller logic

Made-with: Cursor

---------

Co-authored-by: iamlukethedev <lucas.guilherme@smartwayslfl.com>
2026-03-30 22:58:18 -05:00

242 lines
10 KiB
TypeScript

import {
CANVAS_H,
CANVAS_W,
DOOR_LENGTH,
DOOR_THICKNESS,
MIN_WALL_LENGTH,
SCALE,
SNAP_GRID,
WALL_THICKNESS,
} from "@/features/retro-office/core/constants";
import type {
CanvasPoint,
FurnitureItem,
} from "@/features/retro-office/core/types";
export const toWorld = (cx: number, cy: number): [number, number, number] => [
cx * SCALE - CANVAS_W * SCALE * 0.5,
0,
cy * SCALE - CANVAS_H * SCALE * 0.5,
];
export const snap = (value: number) =>
Math.round(value / SNAP_GRID) * SNAP_GRID;
let uidCounter = 0;
export const nextUid = () => `fi_${Date.now()}_${uidCounter++}`;
export const normalizeDegrees = (value: number) => {
const normalized = value % 360;
return normalized < 0 ? normalized + 360 : normalized;
};
export const resolveItemTypeKey = (item: FurnitureItem) =>
item.type === "couch" && item.vertical ? "couch_v" : item.type;
export const ITEM_FOOTPRINT: Record<string, [number, number]> = {
wall: [80, WALL_THICKNESS],
door: [DOOR_LENGTH, DOOR_THICKNESS],
desk_cubicle: [100, 55],
chair: [24, 24],
round_table: [120, 120],
executive_desk: [130, 65],
couch: [100, 40],
couch_v: [40, 80],
bookshelf: [80, 120],
plant: [24, 24],
beanbag: [40, 40],
pingpong: [100, 60],
table_rect: [80, 40],
coffee_machine: [32, 34],
fridge: [40, 80],
water_cooler: [20, 54],
atm: [42, 38],
sms_booth: [58, 54],
phone_booth: [78, 72],
whiteboard: [10, 60],
cabinet: [200, 40],
computer: [30, 20],
lamp: [30, 30],
printer: [40, 35],
stove: [40, 40],
microwave: [30, 20],
wall_cabinet: [80, 20],
sink: [40, 40],
vending: [40, 60],
server_rack: [45, 90],
server_terminal: [42, 34],
qa_terminal: [54, 38],
kanban_board: [130, 65],
device_rack: [70, 36],
test_bench: [90, 42],
treadmill: [70, 35],
weight_bench: [90, 45],
dumbbell_rack: [80, 28],
exercise_bike: [45, 65],
punching_bag: [28, 28],
jukebox: [60, 40],
rowing_machine: [90, 34],
kettlebell_rack: [70, 26],
yoga_mat: [70, 30],
keyboard: [30, 14],
mouse: [16, 10],
trash: [20, 20],
mug: [14, 14],
clock: [20, 20],
};
export const getItemBaseSize = (item: FurnitureItem) => {
if (item.r !== undefined) {
return { width: item.r * 2, height: item.r * 2 };
}
const [defaultWidth, defaultHeight] = ITEM_FOOTPRINT[
resolveItemTypeKey(item)
] ?? [item.w ?? 40, item.h ?? 40];
return {
width: item.w ?? defaultWidth,
height: item.h ?? defaultHeight,
};
};
/**
* Per-type metadata for furniture items.
*
* blocksNavigation: true → solid floor-standing prop; marks grid cells as impassable.
* blocksNavigation: false → desk decoration, wall-mounted, elevated, or passable item.
*
* This is the single source of truth for nav-blocking behaviour. `buildNavGrid` in
* navigation.ts reads this instead of maintaining its own hardcoded type set.
*/
export const ITEM_METADATA: Record<string, { blocksNavigation: boolean; navPadding?: number }> = {
// ── structural ────────────────────────────────────────────────────────────
wall: { blocksNavigation: true },
door: { blocksNavigation: false }, // passable
// ── seating / lounge ──────────────────────────────────────────────────────
chair: { blocksNavigation: false }, // passable / agents sit on them
couch: { blocksNavigation: true },
couch_v: { blocksNavigation: true },
beanbag: { blocksNavigation: true }, // large floor seat (issue #4)
// ── desks / workstations ──────────────────────────────────────────────────
desk_cubicle: { blocksNavigation: true, navPadding: 0 }, // blocks nav with zero padding (tight to desk body)
executive_desk: { blocksNavigation: true },
// ── tables ────────────────────────────────────────────────────────────────
round_table: { blocksNavigation: true },
table_rect: { blocksNavigation: true },
pingpong: { blocksNavigation: true },
// ── storage / shelving ────────────────────────────────────────────────────
bookshelf: { blocksNavigation: true },
cabinet: { blocksNavigation: true },
wall_cabinet: { blocksNavigation: false }, // wall-mounted; agents walk under
// ── kitchen appliances ────────────────────────────────────────────────────
fridge: { blocksNavigation: true },
stove: { blocksNavigation: true },
microwave: { blocksNavigation: false }, // counter-top / elevated
dishwasher: { blocksNavigation: true }, // floor appliance (issue #4)
sink: { blocksNavigation: true },
coffee_machine: { blocksNavigation: false }, // elevated on counter
// ── office equipment ──────────────────────────────────────────────────────
printer: { blocksNavigation: true },
vending: { blocksNavigation: true },
atm: { blocksNavigation: true },
whiteboard: { blocksNavigation: true },
computer: { blocksNavigation: false }, // desk item
keyboard: { blocksNavigation: false }, // desk decoration
mouse: { blocksNavigation: false }, // desk decoration
// ── server room ───────────────────────────────────────────────────────────
server_rack: { blocksNavigation: true },
server_terminal: { blocksNavigation: true }, // floor-standing terminal (issue #4)
sms_booth: { blocksNavigation: true },
phone_booth: { blocksNavigation: true },
// ── QA lab ────────────────────────────────────────────────────────────────
qa_terminal: { blocksNavigation: true },
kanban_board: { blocksNavigation: true },
device_rack: { blocksNavigation: true },
test_bench: { blocksNavigation: true },
// ── gym ───────────────────────────────────────────────────────────────────
treadmill: { blocksNavigation: true },
weight_bench: { blocksNavigation: true },
dumbbell_rack: { blocksNavigation: true },
exercise_bike: { blocksNavigation: true },
punching_bag: { blocksNavigation: true },
jukebox: { blocksNavigation: true },
rowing_machine: { blocksNavigation: true },
kettlebell_rack: { blocksNavigation: true },
yoga_mat: { blocksNavigation: true },
// ── art room ──────────────────────────────────────────────────────────────
easel: { blocksNavigation: true }, // floor-standing prop (issue #4)
// ── water cooler ──────────────────────────────────────────────────────────
water_cooler: { blocksNavigation: true }, // freestanding floor appliance (issue #4)
// ── decorative / small ────────────────────────────────────────────────────
plant: { blocksNavigation: true },
lamp: { blocksNavigation: false }, // floor lamp but thin; passable in practice
trash: { blocksNavigation: false }, // small bin
clock: { blocksNavigation: false }, // wall-mounted
mug: { blocksNavigation: false }, // desk item
};
export const FURNITURE_ROTATION: Record<string, number> = {
couch: Math.PI,
couch_v: Math.PI / 2,
executive_desk: -Math.PI / 2,
whiteboard: Math.PI / 2,
};
export const getItemRotationRadians = (item: FurnitureItem) =>
((item.facing ?? 0) * Math.PI) / 180 +
(FURNITURE_ROTATION[resolveItemTypeKey(item)] ?? 0);
export const getItemBounds = (item: FurnitureItem) => {
const { width, height } = getItemBaseSize(item);
const rotation = getItemRotationRadians(item);
const absCos = Math.abs(Math.cos(rotation));
const absSin = Math.abs(Math.sin(rotation));
const boundsWidth = width * absCos + height * absSin;
const boundsHeight = width * absSin + height * absCos;
const centerX = item.x + width / 2;
const centerY = item.y + height / 2;
return {
x: centerX - boundsWidth / 2,
y: centerY - boundsHeight / 2,
w: boundsWidth,
h: boundsHeight,
width,
height,
};
};
export const createWallItem = (
start: CanvasPoint,
end: CanvasPoint,
uid: string,
): FurnitureItem => {
const dx = end.x - start.x;
const dy = end.y - start.y;
const horizontal = Math.abs(dx) >= Math.abs(dy);
if (horizontal) {
const minX = Math.min(start.x, end.x);
const maxX = Math.max(start.x, end.x);
return {
_uid: uid,
type: "wall",
x: snap(minX),
y: snap(start.y) - WALL_THICKNESS / 2,
w: Math.max(MIN_WALL_LENGTH, snap(maxX - minX) + WALL_THICKNESS),
h: WALL_THICKNESS,
facing: 0,
};
}
const minY = Math.min(start.y, end.y);
const maxY = Math.max(start.y, end.y);
return {
_uid: uid,
type: "wall",
x: snap(start.x) - WALL_THICKNESS / 2,
y: snap(minY),
w: WALL_THICKNESS,
h: Math.max(MIN_WALL_LENGTH, snap(maxY - minY) + WALL_THICKNESS),
facing: 0,
};
};