feat: Beute L3–L5 (3D-Szene, Umbauen, Durchsicht, offline) und Diktat (T7 L1)

Beute L3 Runden 2+3 und Review-Fixes:
- Glas-Holo ohne warmes Leuchten, Wanderboden-Fuss, Fokusflug mit
  Geist-Ansicht, Inhalte im Fokus, Rähmchen mit Art-Farbe beim Anheben
- Hinweis-Geometrie je Zarge nur einmal je Layout gebaut; Drehen mit
  Pointer-Capture, endet auch bei Loslassen ausserhalb (rig-pointer.ts)
- Tiefenunschärfe ab 55 fps, Leistungsprobe bei jeder Bewegungsstufe,
  Context-Loss-Listener aufgeräumt, Trefferboxen als skalierte Einheitsbox

Beute L4 Umbauen (/colonies/[id]/hive): Entwurf mit Basis-Schnappschuss
beim ersten Schritt, Rückgängig/Wiederholen, Hinweis bei Serverstand-
Änderung, Konfliktblatt.

Beute L5:
- Durchsicht-Abschnitt „Beute“: Aufbau-Änderungen in der Durchsicht,
  Blatt-Formulare stoppen das Submit-Bubbling durch Portale (sonst
  speicherte „Speichern“ im Blatt die ganze Durchsicht), Fokus nach
  Rückgängig, Folgevorschläge beim Speichern
- Offline: Beute im Offline-Paket, Warteschlange pending-hive-changes
  (DB v5), Abarbeiten in Reihenfolge, keine Online-Änderung überholt
  wartende, Hinweis bei abgelehnter Änderung mit „Warteschlange öffnen“

T7 L1 Diktat: Spracherkennungs-Kapsel (Web Speech API), Einwilligung
standardmässig aus, Diktat-Knopf an Freitextfeldern, Seite
„Sprache & Datenschutz“; axe-core als exakte devDependency.

Backend: NotificationsService liest Empfänger und schreibt in einer
Transaktion mit FOR KEY SHARE (paralleles Löschen eines Benutzers
führte zu P2003 und verzögerten Vorschlägen; Ursache der wackeligen
Endtests). Zwei e2e-Prüfungen gegen geteilten DB-Zustand entschärft.

Gates: Frontend lint, typecheck, 2740 Tests, build; Backend lint,
typecheck, 4266 Unit-Tests, build; parallele e2e 3x grün (978).
Browser-Prüfung 1280/390 px, Tastatur, offline, reduced motion.

Refs #19 #20 #21 #22 #27 #32

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-16 09:12:04 +02:00
1 parent ab45d6e24e
commit 1a57de8573
123 files changed
+7567 -283

No files matched your search

@@ -1,12 +1,13 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AutopilotModule } from '../autopilot/autopilot.module'; import { AutopilotModule } from '../autopilot/autopilot.module';
import { DomainEventsModule } from '../domain-events/domain-events.module'; import { DomainEventsModule } from '../domain-events/domain-events.module';
import { HiveModule } from '../hive/hive.module';
import { ApiariesController } from './apiaries.controller'; import { ApiariesController } from './apiaries.controller';
import { ApiariesService } from './apiaries.service'; import { ApiariesService } from './apiaries.service';
import { ApiaryFieldService } from './apiary-field.service'; import { ApiaryFieldService } from './apiary-field.service';
@Module({ @Module({
imports: [AutopilotModule, DomainEventsModule], imports: [AutopilotModule, DomainEventsModule, HiveModule],
controllers: [ApiariesController], controllers: [ApiariesController],
providers: [ApiariesService, ApiaryFieldService], providers: [ApiariesService, ApiaryFieldService],
exports: [ApiariesService], exports: [ApiariesService],
@@ -4,6 +4,8 @@ import { PrismaService } from '../prisma/prisma.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import type { ColonyNextSteps } from '../autopilot/next-step-ranking'; import type { ColonyNextSteps } from '../autopilot/next-step-ranking';
import { NextStepsService } from '../autopilot/next-steps.service'; import { NextStepsService } from '../autopilot/next-steps.service';
import { HiveService } from '../hive/hive.service';
import { HiveCatalogService } from '../hive/hive-catalog.service';
describe('ApiaryFieldService', () => { describe('ApiaryFieldService', () => {
let service: ApiaryFieldService; let service: ApiaryFieldService;
@@ -17,6 +19,8 @@ describe('ApiaryFieldService', () => {
}; };
let permissionResolver: { getPermissions: jest.Mock }; let permissionResolver: { getPermissions: jest.Mock };
let nextSteps: { forColonies: jest.Mock; warningCounts: jest.Mock }; let nextSteps: { forColonies: jest.Mock; warningCounts: jest.Mock };
let hives: { getMany: jest.Mock };
let hiveCatalogs: { listSystems: jest.Mock };
const stepsOf = ( const stepsOf = (
colonyId: string, colonyId: string,
@@ -87,10 +91,14 @@ describe('ApiaryFieldService', () => {
), ),
warningCounts: jest.fn().mockResolvedValue(new Map()), warningCounts: jest.fn().mockResolvedValue(new Map()),
}; };
hives = { getMany: jest.fn().mockResolvedValue({}) };
hiveCatalogs = { listSystems: jest.fn().mockResolvedValue([]) };
service = new ApiaryFieldService( service = new ApiaryFieldService(
prisma as unknown as PrismaService, prisma as unknown as PrismaService,
permissionResolver as unknown as PermissionResolverService, permissionResolver as unknown as PermissionResolverService,
nextSteps as unknown as NextStepsService, nextSteps as unknown as NextStepsService,
hives as unknown as HiveService,
hiveCatalogs as unknown as HiveCatalogService,
); );
}); });
@@ -129,6 +137,40 @@ describe('ApiaryFieldService', () => {
expect(prisma.task.findMany).not.toHaveBeenCalled(); expect(prisma.task.findMany).not.toHaveBeenCalled();
}); });
it('carries the hives of the apiary colonies and the hive catalog', async () => {
grant('colonies:read');
const view = { colonyId: 'c2', systemId: 's1', version: 3 };
hives.getMany.mockResolvedValue({ c2: view });
const systems = [{ id: 's1', name: 'Zander', active: true, partTypes: [] }];
hiveCatalogs.listSystems.mockResolvedValue(systems);
const pack = await service.offlinePack('apiary-1', 'u1');
expect(hives.getMany).toHaveBeenCalledWith(['c2', 'c10']);
expect(pack.hives).toEqual({ c2: view });
expect(pack.hiveCatalog).toEqual(systems);
});
it('limits the hive catalog to active systems and those the hives use', async () => {
grant('colonies:read');
hives.getMany.mockResolvedValue({ c2: { colonyId: 'c2', systemId: 's2' } });
hiveCatalogs.listSystems.mockResolvedValue([
{ id: 's1', active: true },
{ id: 's2', active: false },
{ id: 's3', active: false },
]);
const pack = await service.offlinePack('apiary-1', 'u1');
expect(pack.hiveCatalog?.map((system) => system.id)).toEqual(['s1', 's2']);
});
it('keeps colonies without a hive setup out of the hives', async () => {
grant('colonies:read');
const pack = await service.offlinePack('apiary-1', 'u1');
expect(pack.hives).toEqual({});
});
it('fills the permitted sections and the last inspection date', async () => { it('fills the permitted sections and the last inspection date', async () => {
grant('colonies:read', 'inspections:read', 'queens:read', 'tasks:read'); grant('colonies:read', 'inspections:read', 'queens:read', 'tasks:read');
const date = new Date('2026-05-01T10:00:00.000Z'); const date = new Date('2026-05-01T10:00:00.000Z');
@@ -2,6 +2,8 @@ import { Injectable, NotFoundException } from '@nestjs/common';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import type { ColonyNextSteps } from '../autopilot/next-step-ranking'; import type { ColonyNextSteps } from '../autopilot/next-step-ranking';
import { NextStepsService } from '../autopilot/next-steps.service'; import { NextStepsService } from '../autopilot/next-steps.service';
import { HiveCatalogService } from '../hive/hive-catalog.service';
import { HiveService } from '../hive/hive.service';
import { LIVING_COLONY_WHERE } from '../colonies/colony-status'; import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import type { import type {
@@ -12,6 +14,15 @@ import type {
const INSPECTIONS_PER_COLONY = 3; const INSPECTIONS_PER_COLONY = 3;
/** Active systems plus the ones the pack's hives use (a hidden system still builds an old hive). */
function activeOrUsed<S extends { id: string; active: boolean }>(
systems: S[],
hives: Record<string, { systemId: string }>,
): S[] {
const used = new Set(Object.values(hives).map((hive) => hive.systemId));
return systems.filter((system) => system.active || used.has(system.id));
}
type PackColony = OfflinePackV1['colonies'][number]; type PackColony = OfflinePackV1['colonies'][number];
interface FieldData { interface FieldData {
@@ -33,6 +44,8 @@ export class ApiaryFieldService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly permissionResolver: PermissionResolverService, private readonly permissionResolver: PermissionResolverService,
private readonly nextSteps: NextStepsService, private readonly nextSteps: NextStepsService,
private readonly hives: HiveService,
private readonly hiveCatalogs: HiveCatalogService,
) {} ) {}
async colonyOverview( async colonyOverview(
@@ -45,12 +58,14 @@ export class ApiaryFieldService {
async offlinePack(apiaryId: string, userId: string): Promise<OfflinePackV1> { async offlinePack(apiaryId: string, userId: string): Promise<OfflinePackV1> {
const { apiary, colonies, permissions, overview, nextSteps } = const { apiary, colonies, permissions, overview, nextSteps } =
await this.load(apiaryId, userId); await this.load(apiaryId, userId);
const [queens, inspections, tasks] = await Promise.all([ const [queens, inspections, tasks, hives, hiveCatalog] = await Promise.all([
permissions.has('queens:read') ? this.loadQueens(colonies) : null, permissions.has('queens:read') ? this.loadQueens(colonies) : null,
permissions.has('inspections:read') permissions.has('inspections:read')
? this.loadInspections(colonies) ? this.loadInspections(colonies)
: null, : null,
permissions.has('tasks:read') ? this.loadTasks(apiaryId, colonies) : null, permissions.has('tasks:read') ? this.loadTasks(apiaryId, colonies) : null,
this.hives.getMany(colonies.map((colony) => colony.id)),
this.hiveCatalogs.listSystems(),
]); ]);
return { return {
version: 1, version: 1,
@@ -61,6 +76,8 @@ export class ApiaryFieldService {
queens, queens,
inspections, inspections,
tasks, tasks,
hives,
hiveCatalog: activeOrUsed(hiveCatalog, hives),
nextSteps: Object.fromEntries( nextSteps: Object.fromEntries(
colonies.map((colony) => [ colonies.map((colony) => [
colony.id, colony.id,
@@ -7,6 +7,8 @@ import type {
Queen, Queen,
Task, Task,
} from '../../generated/prisma'; } from '../../generated/prisma';
import type { HiveSystemDto } from '../hive/hive-catalog.service';
import type { HiveSetupView } from '../hive/hive.service';
import type { NextStep } from '../autopilot/next-step-ranking'; import type { NextStep } from '../autopilot/next-step-ranking';
import type { SeasonSegmentKey } from '../season/season-types'; import type { SeasonSegmentKey } from '../season/season-types';
@@ -106,4 +108,11 @@ export interface OfflinePackV1 {
* returns as `steps`). Absent in packs cached before T4. * returns as `steps`). Absent in packs cached before T4.
*/ */
nextSteps?: Record<string, NextStep[]>; nextSteps?: Record<string, NextStep[]>;
/**
* The hive setup per colony id, only for colonies of this apiary that have
* one. Absent in packs cached before the hive card.
*/
hives?: Record<string, HiveSetupView>;
/** The hive systems with their part types. Absent in older packs. */
hiveCatalog?: HiveSystemDto[];
} }
@@ -79,6 +79,7 @@ describe('HiveService', () => {
findUnique: jest.Mock; findUnique: jest.Mock;
create: jest.Mock; create: jest.Mock;
update: jest.Mock; update: jest.Mock;
findMany: jest.Mock;
}; };
hiveChange: { hiveChange: {
findUnique: jest.Mock; findUnique: jest.Mock;
@@ -112,6 +113,7 @@ describe('HiveService', () => {
}, },
hiveSetup: { hiveSetup: {
findUnique: jest.fn().mockResolvedValue(setup()), findUnique: jest.fn().mockResolvedValue(setup()),
findMany: jest.fn().mockResolvedValue([setup()]),
create: jest.fn().mockImplementation(({ data }: { data: object }) => ({ create: jest.fn().mockImplementation(({ data }: { data: object }) => ({
...data, ...data,
updatedAt: new Date('2026-10-02T10:00:00.000Z'), updatedAt: new Date('2026-10-02T10:00:00.000Z'),
@@ -169,6 +171,30 @@ describe('HiveService', () => {
}); });
}); });
describe('getMany', () => {
it('returns the views of the colonies that have a setup, keyed by colony', async () => {
const views = await service.getMany([COLONY, 'colony-without']);
expect(tx.hiveSetup.findMany).toHaveBeenCalledWith({
where: { colonyId: { in: [COLONY, 'colony-without'] } },
});
expect(Object.keys(views)).toEqual([COLONY]);
expect(views[COLONY].facts.boxCount).toBe(1);
});
it('loads each catalog once and skips the query for no colonies', async () => {
tx.hiveSetup.findMany.mockResolvedValue([
setup(),
{ ...setup(2), colonyId: 'colony-2' },
]);
await service.getMany([COLONY, 'colony-2']);
expect(catalogs.loadCatalog).toHaveBeenCalledTimes(1);
tx.hiveSetup.findMany.mockClear();
await expect(service.getMany([])).resolves.toEqual({});
expect(tx.hiveSetup.findMany).not.toHaveBeenCalled();
});
});
describe('create', () => { describe('create', () => {
const input = (overrides = {}) => ({ const input = (overrides = {}) => ({
systemId: SYSTEM_ID, systemId: SYSTEM_ID,
+42 -2
View File
@@ -20,7 +20,7 @@ import { deriveHiveFacts } from './hive-facts';
import { HiveCatalogService } from './hive-catalog.service'; import { HiveCatalogService } from './hive-catalog.service';
import { canonicalOp } from './hive-canonical'; import { canonicalOp } from './hive-canonical';
import { applyHiveOps } from './hive-ops'; import { applyHiveOps } from './hive-ops';
import type { HiveFacts, HiveOp, HiveState } from './hive-types'; import type { HiveCatalog, HiveFacts, HiveOp, HiveState } from './hive-types';
import { validateHiveState } from './hive-validate'; import { validateHiveState } from './hive-validate';
export interface HiveSetupView { export interface HiveSetupView {
@@ -139,6 +139,31 @@ export class HiveService {
return this.view(setup); return this.view(setup);
} }
/** The setups of the given colonies, keyed by colony id; colonies without one are left out. */
async getMany(
colonyIds: readonly string[],
): Promise<Record<string, HiveSetupView>> {
if (colonyIds.length === 0) return {};
const setups = await this.prisma.hiveSetup.findMany({
where: { colonyId: { in: [...colonyIds] } },
});
const catalogs = new Map<string, Promise<HiveCatalog>>();
const catalogOf = (systemId: string) => {
let catalog = catalogs.get(systemId);
if (!catalog) {
catalog = this.catalogs.loadCatalog(systemId);
catalogs.set(systemId, catalog);
}
return catalog;
};
const views = await Promise.all(
setups.map(async (setup) =>
this.viewWith(setup, await catalogOf(setup.systemId)),
),
);
return Object.fromEntries(views.map((view) => [view.colonyId, view]));
}
async create( async create(
colonyId: string, colonyId: string,
input: CreateSetupInput, input: CreateSetupInput,
@@ -364,7 +389,22 @@ export class HiveService {
state: Prisma.JsonValue; state: Prisma.JsonValue;
updatedAt: Date; updatedAt: Date;
}): Promise<HiveSetupView> { }): Promise<HiveSetupView> {
const catalog = await this.catalogs.loadCatalog(setup.systemId); return this.viewWith(
setup,
await this.catalogs.loadCatalog(setup.systemId),
);
}
private viewWith(
setup: {
colonyId: string;
systemId: string;
version: number;
state: Prisma.JsonValue;
updatedAt: Date;
},
catalog: HiveCatalog,
): HiveSetupView {
const state = setup.state as unknown as HiveState; const state = setup.state as unknown as HiveState;
return { return {
colonyId: setup.colonyId, colonyId: setup.colonyId,
@@ -32,6 +32,7 @@ describe('NotificationsService', () => {
user: { findMany: jest.Mock }; user: { findMany: jest.Mock };
$connect: jest.Mock; $connect: jest.Mock;
$transaction: jest.Mock; $transaction: jest.Mock;
$queryRaw: jest.Mock;
}; };
beforeEach(() => { beforeEach(() => {
@@ -52,10 +53,19 @@ describe('NotificationsService', () => {
// A transaction client lacks $connect; the root client has it. // A transaction client lacks $connect; the root client has it.
$connect: jest.fn(), $connect: jest.fn(),
$transaction: jest.fn(), $transaction: jest.fn(),
// The FOR KEY SHARE lock on the recipients: every user still exists.
$queryRaw: jest.fn().mockImplementation(() => {
const found = prisma.user.findMany.mock.results.at(-1);
return Promise.resolve(found ? (found.value as unknown) : []);
}),
}; };
prisma.$transaction.mockImplementation((work: (tx: unknown) => unknown) => prisma.$transaction.mockImplementation((work: (tx: unknown) => unknown) => {
Promise.resolve(work(prisma)), // A transaction client has no $connect; the code tells the two apart.
const txClient = Object.fromEntries(
Object.entries(prisma).filter(([key]) => key !== '$connect'),
); );
return Promise.resolve(work(txClient));
});
held = new Set(); held = new Set();
service = new NotificationsService( service = new NotificationsService(
prisma as unknown as PrismaService, prisma as unknown as PrismaService,
@@ -78,6 +88,28 @@ describe('NotificationsService', () => {
void service.record({ priority: 'INFO', category: 'SYSTEM', message: 'm' }); void service.record({ priority: 'INFO', category: 'SYSTEM', message: 'm' });
}); });
describe('recipient locking', () => {
it('locks the recipients in the write transaction and skips a user deleted meanwhile', async () => {
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }, { id: 'u2' }]);
prisma.$queryRaw.mockResolvedValue([{ id: 'u1' }]);
const rows = await service.recordForUsers(['u1', 'u2'], {
priority: 'INFO',
category: 'SYSTEM',
message: 'm',
});
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
expect(prisma.$queryRaw).toHaveBeenCalledTimes(1);
expect(
(prisma.$queryRaw.mock.calls[0] as [{ sql: string }])[0].sql,
).toContain('FOR KEY SHARE');
expect(rows.map((row) => (row as { userId: string }).userId)).toEqual([
'u1',
]);
});
});
describe('record', () => { describe('record', () => {
it('writes one row for an eligible recipient and returns it', async () => { it('writes one row for an eligible recipient and returns it', async () => {
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }]); prisma.user.findMany.mockResolvedValue([{ id: 'u1' }]);
@@ -138,6 +170,7 @@ describe('NotificationsService', () => {
it('writes through the transaction client when one is given', async () => { it('writes through the transaction client when one is given', async () => {
const tx = { const tx = {
user: { findMany: jest.fn().mockResolvedValue([{ id: 'u1' }]) }, user: { findMany: jest.fn().mockResolvedValue([{ id: 'u1' }]) },
$queryRaw: jest.fn().mockResolvedValue([{ id: 'u1' }]),
notification: { notification: {
createManyAndReturn: jest.fn().mockResolvedValue([{ id: 'n' }]), createManyAndReturn: jest.fn().mockResolvedValue([{ id: 'n' }]),
}, },
@@ -180,7 +213,10 @@ describe('NotificationsService', () => {
const rows = await service.recordForUsers(['u1', 'u2'], base); const rows = await service.recordForUsers(['u1', 'u2'], base);
expect(planner.planIn).toHaveBeenCalledTimes(1); expect(planner.planIn).toHaveBeenCalledTimes(1);
expect(planner.planIn).toHaveBeenCalledWith(prisma, rows); expect(planner.planIn).toHaveBeenCalledWith(
expect.objectContaining({ notification: prisma.notification }),
rows,
);
expect( expect(
prisma.notification.createManyAndReturn.mock.invocationCallOrder[0], prisma.notification.createManyAndReturn.mock.invocationCallOrder[0],
).toBeLessThan(planner.planIn.mock.invocationCallOrder[0]); ).toBeLessThan(planner.planIn.mock.invocationCallOrder[0]);
@@ -189,6 +225,7 @@ describe('NotificationsService', () => {
it('plans with the caller transaction and opens none of its own', async () => { it('plans with the caller transaction and opens none of its own', async () => {
const tx = { const tx = {
user: { findMany: jest.fn().mockResolvedValue([{ id: 'u1' }]) }, user: { findMany: jest.fn().mockResolvedValue([{ id: 'u1' }]) },
$queryRaw: jest.fn().mockResolvedValue([{ id: 'u1' }]),
notification: { notification: {
createManyAndReturn: jest.fn().mockResolvedValue([{ id: 'n' }]), createManyAndReturn: jest.fn().mockResolvedValue([{ id: 'n' }]),
}, },
@@ -316,6 +353,7 @@ describe('NotificationsService', () => {
it('reads and writes through the transaction client when one is given', async () => { it('reads and writes through the transaction client when one is given', async () => {
const tx = { const tx = {
user: { findMany: jest.fn().mockResolvedValue([{ id: 'u1' }]) }, user: { findMany: jest.fn().mockResolvedValue([{ id: 'u1' }]) },
$queryRaw: jest.fn().mockResolvedValue([{ id: 'u1' }]),
notification: { notification: {
createManyAndReturn: jest.fn().mockResolvedValue([{ id: 'n1' }]), createManyAndReturn: jest.fn().mockResolvedValue([{ id: 'n1' }]),
}, },
@@ -342,6 +380,7 @@ describe('NotificationsService', () => {
let calls: string[]; let calls: string[];
let tx: { let tx: {
$executeRaw: jest.Mock; $executeRaw: jest.Mock;
$queryRaw: jest.Mock;
user: { findMany: jest.Mock }; user: { findMany: jest.Mock };
notification: { findMany: jest.Mock; createManyAndReturn: jest.Mock }; notification: { findMany: jest.Mock; createManyAndReturn: jest.Mock };
}; };
@@ -353,6 +392,11 @@ describe('NotificationsService', () => {
calls.push('lock'); calls.push('lock');
return Promise.resolve(1); return Promise.resolve(1);
}), }),
// The key-share lock keeps every user the read just returned.
$queryRaw: jest.fn().mockImplementation(() => {
const read = tx.user.findMany.mock.results.at(-1);
return Promise.resolve(read ? (read.value as unknown) : []);
}),
user: { user: {
// Like the database: the id filter narrows the three eligible users. // Like the database: the id filter narrows the three eligible users.
findMany: jest findMany: jest
@@ -104,9 +104,9 @@ export class NotificationsService {
input: CreateNotificationInput, input: CreateNotificationInput,
tx?: Prisma.TransactionClient, tx?: Prisma.TransactionClient,
): Promise<Notification | null> { ): Promise<Notification | null> {
const db: DbClient = tx ?? this.prisma;
const { userId, dedupeKey, ...content } = input; const { userId, dedupeKey, ...content } = input;
const keys = uniqueSorted(content.requiredPermissions ?? []); const keys = uniqueSorted(content.requiredPermissions ?? []);
return this.inTransaction(tx, async (db) => {
const recipients = await this.recipients(db, keys, [userId]); const recipients = await this.recipients(db, keys, [userId]);
const [row] = await this.write( const [row] = await this.write(
db, db,
@@ -115,6 +115,7 @@ export class NotificationsService {
keys, keys,
); );
return row ?? null; return row ?? null;
});
} }
/** The given users, minus those missing a permission the content needs. */ /** The given users, minus those missing a permission the content needs. */
@@ -127,7 +128,9 @@ export class NotificationsService {
return []; return [];
} }
const keys = uniqueSorted(input.requiredPermissions ?? []); const keys = uniqueSorted(input.requiredPermissions ?? []);
return this.fanOut(tx ?? this.prisma, keys, input, userIds); return this.inTransaction(tx, (db) =>
this.fanOut(db, keys, input, userIds),
);
} }
/** Every user holding all of `permission` (and the content's permissions). */ /** Every user holding all of `permission` (and the content's permissions). */
@@ -146,7 +149,9 @@ export class NotificationsService {
...recipientKeys, ...recipientKeys,
...(input.requiredPermissions ?? []), ...(input.requiredPermissions ?? []),
]); ]);
return this.fanOut(tx ?? this.prisma, keys, input, undefined); return this.inTransaction(tx, (db) =>
this.fanOut(db, keys, input, undefined),
);
} }
/** /**
@@ -264,8 +269,20 @@ export class NotificationsService {
return { updated: count }; return { updated: count };
} }
/**
* Recipients are read and then inserted: both steps belong to one
* transaction, in which recipients() locks the user rows, so a user deleted
* in between cannot turn the insert into a foreign key violation.
*/
private inTransaction<T>(
tx: Prisma.TransactionClient | undefined,
work: (db: Prisma.TransactionClient) => Promise<T>,
): Promise<T> {
return tx ? work(tx) : this.prisma.$transaction((own) => work(own));
}
private async fanOut( private async fanOut(
db: DbClient, db: Prisma.TransactionClient,
keys: readonly PermissionKey[], keys: readonly PermissionKey[],
input: FanOutNotificationInput, input: FanOutNotificationInput,
userIds: readonly string[] | undefined, userIds: readonly string[] | undefined,
@@ -287,7 +304,7 @@ export class NotificationsService {
/** Active user, active role, every key granted - in one query. */ /** Active user, active role, every key granted - in one query. */
private async recipients( private async recipients(
db: DbClient, db: Prisma.TransactionClient,
keys: readonly PermissionKey[], keys: readonly PermissionKey[],
userIds: readonly string[] | undefined, userIds: readonly string[] | undefined,
): Promise<string[]> { ): Promise<string[]> {
@@ -305,7 +322,15 @@ export class NotificationsService {
select: { id: true }, select: { id: true },
orderBy: { id: 'asc' }, orderBy: { id: 'asc' },
}); });
return users.map((user) => user.id); if (users.length === 0) {
return [];
}
// FOR KEY SHARE: a concurrent DELETE of one of these users waits for this
// transaction; one that already committed drops out of the result.
const locked = await db.$queryRaw<{ id: string }[]>(
Prisma.sql`SELECT "id" FROM "User" WHERE "id" = ANY(${users.map((user) => user.id)}) ORDER BY "id" FOR KEY SHARE`,
);
return locked.map((user) => user.id);
} }
private async write( private async write(
+39
View File
@@ -107,6 +107,9 @@ describe('Apiaries (e2e)', () => {
await prisma.suggestion.deleteMany({ await prisma.suggestion.deleteMany({
where: { colony: { apiaryId: fieldApiaryId } }, where: { colony: { apiaryId: fieldApiaryId } },
}); });
await prisma.hiveSetup.deleteMany({
where: { colony: { apiaryId: fieldApiaryId } },
});
await prisma.colony.deleteMany({ where: { apiaryId: fieldApiaryId } }); await prisma.colony.deleteMany({ where: { apiaryId: fieldApiaryId } });
await prisma.apiary.delete({ where: { id: fieldApiaryId } }); await prisma.apiary.delete({ where: { id: fieldApiaryId } });
} }
@@ -455,6 +458,42 @@ describe('Apiaries (e2e)', () => {
]); ]);
}); });
it('offline-pack carries the hives of the apiary colonies and the hive catalog', async () => {
const [withHive, withoutHive] = await Promise.all([
prisma.colony.findFirstOrThrow({
where: { apiaryId: fieldApiaryId, code: { startsWith: 'V-10-' } },
}),
prisma.colony.findFirstOrThrow({
where: { apiaryId: fieldApiaryId, code: { startsWith: 'V-2-' } },
}),
]);
await prisma.hiveSetup.create({
data: {
colonyId: withHive.id,
systemId: 'hive-system-zander-liebig-10',
state: { schemaVersion: 1, stack: [] },
},
});
const response = await request(app.getHttpServer())
.get(`/api/v1/apiaries/${fieldApiaryId}/offline-pack`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.expect(200);
const body = response.body as {
hives?: Record<string, { systemId: string; version: number }>;
hiveCatalog?: { id: string; partTypes: unknown[] }[];
};
expect(Object.keys(body.hives ?? {})).toEqual([withHive.id]);
expect(body.hives?.[withHive.id]).toMatchObject({
systemId: 'hive-system-zander-liebig-10',
version: 1,
});
expect(body.hives).not.toHaveProperty(withoutHive.id);
const system = body.hiveCatalog?.find(
(candidate) => candidate.id === 'hive-system-zander-liebig-10',
);
expect(system?.partTypes.length).toBeGreaterThan(0);
});
it('rejects both endpoints without colonies:read', async () => { it('rejects both endpoints without colonies:read', async () => {
for (const path of ['colony-overview', 'offline-pack']) { for (const path of ['colony-overview', 'offline-pack']) {
await request(app.getHttpServer()) await request(app.getHttpServer())
@@ -3,7 +3,7 @@ import * as bcrypt from 'bcryptjs';
import request from 'supertest'; import request from 'supertest';
import { App } from 'supertest/types'; import { App } from 'supertest/types';
import { berlinToday } from '../src/common/dates'; import { berlinToday } from '../src/common/dates';
import { findBaselineIn, herdStatusIn } from '../src/compliance/herd-status'; import { findBaselineIn } from '../src/compliance/herd-status';
import { NotificationsService } from '../src/notifications/notifications.service'; import { NotificationsService } from '../src/notifications/notifications.service';
import { DomainEventRegistry } from '../src/domain-events/domain-event-registry'; import { DomainEventRegistry } from '../src/domain-events/domain-event-registry';
import { PrismaService } from '../src/prisma/prisma.service'; import { PrismaService } from '../src/prisma/prisma.service';
@@ -241,9 +241,10 @@ describe('Authority notices: herd change notification (e2e)', () => {
data: { read: true }, data: { read: true },
}); });
// The sent notice is the new baseline: nothing deviates any more. // The sent notice is the new baseline. (Not asserted: "nothing deviates"
const status = await herdStatusIn(prisma, new Date()); // against the live database - colonies that parallel files create in the
expect(status.deviation?.triggered).toBe(false); // same instant would make that flaky.)
expect((await findBaselineIn(prisma))?.id).toBe(notice.id);
expect(await unreadOf(managerIds)).toHaveLength(0); expect(await unreadOf(managerIds)).toHaveLength(0);
await addColony(); await addColony();
@@ -245,7 +245,8 @@ describe('daily-evaluation (e2e)', () => {
describe('TSK reference day', () => { describe('TSK reference day', () => {
let savedReferenceDay: string; let savedReferenceDay: string;
let permittedUsers: number; /** The shared database gains and loses users while parallel files run. */
let permittedUserIds: string[];
beforeEach(async () => { beforeEach(async () => {
// The seeded singleton stays: only its reference day is borrowed. // The seeded singleton stays: only its reference day is borrowed.
@@ -259,15 +260,20 @@ describe('daily-evaluation (e2e)', () => {
where: { id: 'singleton' }, where: { id: 'singleton' },
data: { tskReferenceDay: '03-03' }, data: { tskReferenceDay: '03-03' },
}); });
permittedUsers = await prisma.user.count({ permittedUserIds = (
await prisma.user.findMany({
where: { where: {
isActive: true, isActive: true,
role: { role: {
status: 'ACTIVE', status: 'ACTIVE',
permissions: { some: { permission: { key: 'compliance:manage' } } }, permissions: {
some: { permission: { key: 'compliance:manage' } },
}, },
}, },
}); },
select: { id: true },
})
).map((user) => user.id);
}); });
afterEach(async () => { afterEach(async () => {
@@ -306,10 +312,19 @@ describe('daily-evaluation (e2e)', () => {
where: { relatedType: 'ComplianceItem', relatedId: 'tsk-report:2001' }, where: { relatedType: 'ComplianceItem', relatedId: 'tsk-report:2001' },
select: { userId: true, priority: true, category: true, message: true }, select: { userId: true, priority: true, category: true, message: true },
}); });
expect(notifications).toHaveLength(permittedUsers); // One copy per recipient, written once - and every permitted user that
expect(new Set(notifications.map((row) => row.userId)).size).toBe( // still exists got theirs (users other files delete meanwhile do not
permittedUsers, // count).
); const notified = new Set(notifications.map((row) => row.userId));
expect(notified.size).toBe(notifications.length);
const stillThere = await prisma.user.findMany({
where: { id: { in: permittedUserIds } },
select: { id: true },
});
expect(stillThere.length).toBeGreaterThan(0);
for (const { id } of stillThere) {
expect(notified.has(id)).toBe(true);
}
for (const row of notifications) { for (const row of notifications) {
expect(row).toMatchObject({ expect(row).toMatchObject({
priority: 'WARNUNG', priority: 'WARNUNG',
+34
View File
@@ -0,0 +1,34 @@
[
"Königin",
"Stifte",
"Weiselzellen",
"Brutwaben",
"Futterkranz",
"Honigraum",
"Varroa",
"Schwarmstimmung",
"sanftmütig",
"Wabensitz",
"Drohnenbrut",
"Brutnest",
"Brutfläche",
"Spielnäpfchen",
"Schwarmzellen",
"Absperrgitter",
"Futterteig",
"Ableger",
"Jungvolk",
"Rähmchen",
"Mittelwand",
"Flugloch",
"Beute",
"Zarge",
"Ameisensäure",
"Oxalsäure",
"Windel",
"Faulbrut",
"Kalkbrut",
"Nosema",
"Wachsmotten",
"Pollen"
]
+43 -1
View File
@@ -26,7 +26,7 @@ interface FixturePartType {
} }
interface TemplateBody { interface TemplateBody {
schemaVersion: number; schemaVersion: number;
stack: { id: string; inserts?: { id: string }[] }[]; stack: { id: string; partTypeId?: string; inserts?: { id: string }[] }[];
} }
interface SystemBody { interface SystemBody {
id: string; id: string;
@@ -302,4 +302,46 @@ describe('Hive catalog (e2e)', () => {
const overlap = second.stack.filter((p) => firstIds.includes(p.id)); const overlap = second.stack.filter((p) => firstIds.includes(p.id));
expect(overlap).toEqual([]); expect(overlap).toEqual([]);
}); });
it('accepts an id-less template (as the hive editor sends it) and serves fresh ids afterwards', async () => {
const current = (
await api()
.get(`/api/v1/hive-systems/${SYSTEM_ID}/template`)
.set(auth(tokens.reader))
.expect(200)
).body as TemplateBody;
const stripIds = (
part: Record<string, unknown>,
): Record<string, unknown> => {
const rest = { ...part };
delete rest.id;
const inserts = rest.inserts;
delete rest.inserts;
return Array.isArray(inserts)
? { ...rest, inserts: inserts.map(stripIds) }
: rest;
};
const idLess = {
schemaVersion: 1,
stack: current.stack.map((p) => stripIds(p as never)),
};
await api()
.patch(`/api/v1/hive-systems/${SYSTEM_ID}`)
.set(auth(tokens.manager))
.send({ template: idLess })
.expect(200);
const after = (
await api()
.get(`/api/v1/hive-systems/${SYSTEM_ID}/template`)
.set(auth(tokens.reader))
.expect(200)
).body as TemplateBody;
expect(after.stack.map((p) => p.partTypeId)).toEqual(
current.stack.map((p) => p.partTypeId),
);
expect(
after.stack.every((p) => typeof p.id === 'string' && p.id !== ''),
).toBe(true);
});
}); });
+1
View File
@@ -43,6 +43,7 @@
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@types/three": "0.186.0", "@types/three": "0.186.0",
"@vitejs/plugin-react": "^4.7.0", "@vitejs/plugin-react": "^4.7.0",
"axe-core": "4.13.0",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.3.1", "eslint-config-next": "16.3.1",
"fake-indexeddb": "^6.2.5", "fake-indexeddb": "^6.2.5",
+1
View File
@@ -47,6 +47,7 @@
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@types/three": "0.186.0", "@types/three": "0.186.0",
"@vitejs/plugin-react": "^4.7.0", "@vitejs/plugin-react": "^4.7.0",
"axe-core": "4.13.0",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.3.1", "eslint-config-next": "16.3.1",
"fake-indexeddb": "^6.2.5", "fake-indexeddb": "^6.2.5",
+2
View File
@@ -68,6 +68,8 @@
/\/api\/v1\/colonies\/?$/i, /\/api\/v1\/colonies\/?$/i,
/\/api\/v1\/colonies\/[^/]+\/inspections\/?$/i, /\/api\/v1\/colonies\/[^/]+\/inspections\/?$/i,
/\/api\/v1\/apiaries\/[^/]+\/(colony-overview|offline-pack)\/?$/i, /\/api\/v1\/apiaries\/[^/]+\/(colony-overview|offline-pack)\/?$/i,
// the hive setup of a colony: the pack answers it, an old kept copy would hide a newer pack
/\/api\/v1\/colonies\/[^/]+\/hive\/?$/i,
// Prüfbereitschaft and authority data: file numbers, addresses, notices, // Prüfbereitschaft and authority data: file numbers, addresses, notices,
// restricted zones around an apiary, medicine packs with receipts and // restricted zones around an apiary, medicine packs with receipts and
// suppliers, and the label check and print (they carry the food business address) // suppliers, and the label check and print (they carry the food business address)
+1 -1
View File
@@ -21,7 +21,7 @@
// Content version of sw-routes.js (sha256 of its LF text, first 12 hex // Content version of sw-routes.js (sha256 of its LF text, first 12 hex
// characters; sw-routes.spec.ts checks it). A changed value changes this // characters; sw-routes.spec.ts checks it). A changed value changes this
// file, so every browser - Safari too - installs the worker with new rules. // file, so every browser - Safari too - installs the worker with new rules.
const SW_ROUTES_VERSION = "17503174a2a9"; const SW_ROUTES_VERSION = "215afb9f84e9";
importScripts(`/sw-routes.js?v=${SW_ROUTES_VERSION}`); importScripts(`/sw-routes.js?v=${SW_ROUTES_VERSION}`);
const { const {
@@ -0,0 +1,255 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { act, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fetchColony } from "@/lib/colonies/api";
import { ApiError } from "@/lib/auth/api-client";
import { fetchHive, fetchHiveSystemsWithFallback, hiveKeys, postHiveChange } from "@/lib/hive/api";
import { updateHiveSystem } from "@/lib/hive/catalog-api";
import { colonyFixture, sampleState, setupView, systemDto } from "@/components/hive/test-fixtures";
import HiveEditorPage from "./page";
const push = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push }),
useParams: () => ({ id: "colony-1" }),
}));
vi.mock("sonner", () => ({ toast: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn() }) }));
vi.mock("@/lib/colonies/api", () => ({ fetchColony: vi.fn(), fetchColonies: vi.fn().mockResolvedValue([]) }));
vi.mock("@/lib/hive/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/hive/api")>()),
fetchHive: vi.fn(),
fetchHiveSystemsWithFallback: vi.fn(),
postHiveChange: vi.fn(),
}));
vi.mock("@/lib/hive/catalog-api", () => ({ updateHiveSystem: vi.fn() }));
let permissions: string[] = [];
vi.mock("@/lib/auth/auth-context", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/auth/auth-context")>()),
useAuth: () => ({ hasPermission: (key: string) => permissions.includes(key) }),
useCurrentUserId: () => null,
}));
const connectivity = vi.hoisted(() => ({ value: "online" as "online" | "offline" }));
vi.mock("@/lib/offline/connectivity", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/offline/connectivity")>()),
useConnectivity: () => connectivity.value,
}));
function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const view = render(
<QueryClientProvider client={client}>
<HiveEditorPage />
</QueryClientProvider>,
);
return { ...view, client };
}
/** Clicks „Hinzufügen …“ of the part type, then the dialog's „Hinzufügen“. */
async function addPart(name: string) {
await userEvent.click(await screen.findByRole("button", { name: `Hinzufügen … ${name}` }));
const dialog = await screen.findByRole("dialog");
await userEvent.click(within(dialog).getByRole("button", { name: "Hinzufügen" }));
return dialog;
}
describe("HiveEditorPage", () => {
beforeEach(() => {
permissions = ["colonies:read", "colonies:update"];
connectivity.value = "online";
push.mockReset();
vi.mocked(fetchColony).mockReset().mockResolvedValue(colonyFixture());
vi.mocked(fetchHive).mockReset().mockResolvedValue(setupView());
vi.mocked(fetchHiveSystemsWithFallback).mockReset().mockResolvedValue([systemDto()]);
vi.mocked(postHiveChange).mockReset().mockResolvedValue(setupView());
vi.mocked(updateHiveSystem).mockReset().mockResolvedValue(systemDto());
});
it("shows the offline hint with a way back", async () => {
connectivity.value = "offline";
renderPage();
expect(await screen.findByText("Umbauen geht nur mit Netz")).toBeInTheDocument();
expect(screen.getByRole("link", { name: /Zurück zum Volk/ })).toHaveAttribute("href", "/colonies/colony-1");
});
it("adds a part, undoes it with Ctrl+Z and redoes it with Ctrl+Shift+Z", async () => {
renderPage();
const stack = await screen.findByRole("list", { name: "Aufbau" });
expect(within(stack).getAllByRole("listitem")).toHaveLength(7); // 6 parts + the floor insert
await addPart("cover");
expect(within(stack).getByRole("button", { name: "Entfernen: Abdeckfolie" })).toBeInTheDocument();
await userEvent.keyboard("{Control>}z{/Control}");
expect(within(stack).queryByRole("button", { name: "Entfernen: Abdeckfolie" })).not.toBeInTheDocument();
await userEvent.keyboard("{Control>}{Shift>}z{/Shift}{/Control}");
expect(within(stack).getByRole("button", { name: "Entfernen: Abdeckfolie" })).toBeInTheDocument();
});
it("shows the floor rule inline when a second floor is added", async () => {
renderPage();
const dialog = await addPart("floor");
expect(await within(dialog).findByRole("alert")).toHaveTextContent("Der Boden muss ganz unten liegen.");
});
it("moves and removes parts from the stack list", async () => {
renderPage();
const stack = await screen.findByRole("list", { name: "Aufbau" });
await userEvent.click(within(stack).getByRole("button", { name: "Nach unten: Zarge 2" }));
expect(within(stack).getAllByRole("button", { name: /^Nach (oben|unten): Zarge/ })).toHaveLength(4);
await userEvent.click(within(stack).getByRole("button", { name: "Entfernen: Futterzarge 2" }));
expect(within(stack).queryByRole("button", { name: "Entfernen: Futterzarge 2" })).not.toBeInTheDocument();
expect(within(stack).getByRole("button", { name: "Entfernen: Futterzarge" })).toBeInTheDocument();
});
it("saves all collected ops as one EDITOR change and leaves", async () => {
renderPage();
await addPart("cover");
await userEvent.click(within(await screen.findByRole("list", { name: "Aufbau" })).getByRole("button", { name: "Entfernen: Futterzarge 2" }));
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(postHiveChange).toHaveBeenCalledTimes(1);
const [colonyId, input] = vi.mocked(postHiveChange).mock.calls[0];
expect(colonyId).toBe("colony-1");
expect(input.source).toBe("EDITOR");
expect(input.occurredOn).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(input.ops.map((op) => op.op)).toEqual(["addPart", "removePart"]);
await vi.waitFor(() => expect(push).toHaveBeenCalledWith("/colonies/colony-1"));
});
it("asks before „Verwerfen“ when ops exist and not when there are none", async () => {
renderPage();
await screen.findByRole("list", { name: "Aufbau" });
await userEvent.click(screen.getByRole("button", { name: "Verwerfen" }));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
await addPart("cover");
await userEvent.click(screen.getByRole("button", { name: "Verwerfen" }));
const dialog = await screen.findByRole("dialog", { name: "Änderungen verwerfen?" });
await userEvent.click(within(dialog).getByRole("button", { name: "Verwerfen bestätigen" }));
expect(screen.getByRole("button", { name: "Speichern" })).toBeDisabled();
});
it("asks before the back link leaves with open ops", async () => {
renderPage();
await addPart("cover");
await userEvent.click(screen.getByRole("link", { name: /Zurück/ }));
const dialog = await screen.findByRole("dialog", { name: "Umbau verlassen?" });
expect(push).not.toHaveBeenCalled();
await userEvent.click(within(dialog).getByRole("button", { name: "Verlassen" }));
expect(push).toHaveBeenCalledWith("/colonies/colony-1");
});
it("offers „Als Vorlage speichern“ only with hive-systems:manage and sends the template", async () => {
const { unmount } = renderPage();
await screen.findByRole("list", { name: "Aufbau" });
expect(screen.queryByRole("button", { name: "Als Vorlage speichern" })).not.toBeInTheDocument();
unmount();
permissions = [...permissions, "hive-systems:manage"];
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Als Vorlage speichern" }));
const dialog = await screen.findByRole("dialog");
await userEvent.click(within(dialog).getByRole("button", { name: "Vorlage ersetzen" }));
await vi.waitFor(() => expect(updateHiveSystem).toHaveBeenCalledTimes(1));
const [systemId, patch] = vi.mocked(updateHiveSystem).mock.calls[0];
expect(systemId).toBe("sys");
expect(JSON.stringify(patch.template)).not.toContain('"id"');
});
const conflictBody = {
code: "HIVE_CONFLICT",
opIndex: 0,
reason: "MISSING_ID: x",
current: { version: 9, state: sampleState() },
};
it("shows the save error for a network failure but not for a conflict", async () => {
vi.mocked(postHiveChange).mockRejectedValueOnce(new ApiError(500, "boom"));
renderPage();
await addPart("cover");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(await screen.findByText(/Speichern fehlgeschlagen/)).toBeInTheDocument();
vi.mocked(postHiveChange).mockRejectedValueOnce(new ApiError(409, "conflict", conflictBody));
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(await screen.findByRole("dialog", { name: "Die Beute wurde inzwischen geändert" })).toBeInTheDocument();
expect(screen.queryByText(/Speichern fehlgeschlagen/)).not.toBeInTheDocument();
});
it("discards the draft when the conflict is discarded", async () => {
vi.mocked(postHiveChange).mockRejectedValueOnce(new ApiError(409, "conflict", conflictBody));
renderPage();
await addPart("cover");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
const sheet = await screen.findByRole("dialog", { name: "Die Beute wurde inzwischen geändert" });
await userEvent.click(within(sheet).getByRole("button", { name: "Verwerfen" }));
await vi.waitFor(() => expect(screen.getByRole("button", { name: "Speichern" })).toBeDisabled());
expect(screen.queryByRole("button", { name: "Entfernen: Abdeckfolie" })).not.toBeInTheDocument();
});
it("keeps showing the draft's own base and hints when the server state changes underneath", async () => {
const { client } = renderPage();
await addPart("cover");
expect(screen.queryByText(/inzwischen geändert/)).not.toBeInTheDocument();
const other = sampleState();
other.stack = other.stack.filter((p) => p.id !== "b2");
act(() => client.setQueryData(hiveKeys.detail("colony-1"), setupView(other, "colony-1", 4)));
expect(await screen.findByText(/Die Beute wurde inzwischen geändert\. Beim Speichern/)).toBeInTheDocument();
const stack = screen.getByRole("list", { name: "Aufbau" });
expect(within(stack).getByRole("button", { name: "Entfernen: Zarge 2" })).toBeInTheDocument();
expect(within(stack).getByRole("button", { name: "Entfernen: Abdeckfolie" })).toBeInTheDocument();
});
it("shows a dialog refusal only in the dialog", async () => {
renderPage();
const dialog = await addPart("floor");
expect(await within(dialog).findByRole("alert")).toBeInTheDocument();
expect(screen.getAllByRole("alert")).toHaveLength(1);
});
it("does not bring a selection back when a removed part returns by undo", async () => {
renderPage();
const stack = await screen.findByRole("list", { name: "Aufbau" });
await userEvent.click(within(stack).getByRole("button", { name: "Futterzarge 2" }));
expect(within(stack).getByRole("button", { name: "Futterzarge 2" })).toHaveAttribute("aria-current", "true");
await userEvent.click(within(stack).getByRole("button", { name: "Entfernen: Futterzarge 2" }));
await userEvent.keyboard("{Control>}z{/Control}");
expect(within(stack).getByRole("button", { name: "Futterzarge 2" })).not.toHaveAttribute("aria-current");
});
it("does not retry a missing hive", async () => {
vi.mocked(fetchHive).mockReset().mockRejectedValue(new ApiError(404, "nf", { code: "HIVE_NOT_FOUND" }));
renderPage();
expect(await screen.findByText("Noch keine Beute")).toBeInTheDocument();
expect(fetchHive).toHaveBeenCalledTimes(1);
});
it("announces undo and redo", async () => {
renderPage();
await addPart("cover");
await userEvent.keyboard("{Control>}z{/Control}");
expect(screen.getByRole("status")).toHaveTextContent(/^Rückgängig: /);
await userEvent.keyboard("{Control>}{Shift>}z{/Shift}{/Control}");
expect(screen.getByRole("status")).toHaveTextContent(/^Wiederholt: /);
});
});
@@ -0,0 +1,77 @@
"use client";
import { useParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/lib/auth/auth-context";
import { fetchColony } from "@/lib/colonies/api";
import { isLivingColony } from "@/lib/colonies/status";
import { fetchHive, hiveErrorCode, hiveKeys } from "@/lib/hive/api";
import { useConnectivity } from "@/lib/offline/connectivity";
import { EditorNotice } from "@/components/hive/editor/editor-notice";
import { HiveEditor } from "@/components/hive/editor/hive-editor";
import { useHiveSystems, useSystemCatalog } from "@/components/hive/use-hive-systems";
/** Full-screen „Umbauen“ of a colony's hive (spec 9.5). Online only. */
export default function HiveEditorPage() {
const { id } = useParams<{ id: string }>();
const { hasPermission } = useAuth();
const connectivity = useConnectivity();
const backHref = `/colonies/${encodeURIComponent(id)}`;
const colony = useQuery({ queryKey: ["colonies", id], queryFn: () => fetchColony(id) });
const hive = useQuery({ queryKey: hiveKeys.detail(id), queryFn: () => fetchHive(id),
// A colony without a hive answers 404 for good; no point retrying.
retry: (count, error) => hiveErrorCode(error) !== "HIVE_NOT_FOUND" && count < 3,
});
const systems = useHiveSystems();
const catalog = useSystemCatalog(systems.data, hive.data?.systemId);
if (connectivity === "offline") {
return (
<EditorNotice title="Umbauen geht nur mit Netz" backHref={backHref}>
<p className="text-sm">Kleine Änderungen in der Karte gehen auch ohne Netz.</p>
</EditorNotice>
);
}
if (colony.data && (!hasPermission("colonies:update") || !isLivingColony(colony.data))) {
return (
<EditorNotice title="Umbauen ist hier nicht möglich" backHref={backHref}>
<p className="text-sm">Dafür fehlt die Berechtigung, oder das Volk ist nicht mehr im Bestand.</p>
</EditorNotice>
);
}
if (hiveErrorCode(hive.error) === "HIVE_NOT_FOUND") {
return (
<EditorNotice title="Noch keine Beute" backHref={backHref}>
<p className="text-sm">Lege die Beute zuerst auf der Volk-Seite an.</p>
</EditorNotice>
);
}
if (colony.isError || hive.isError || systems.isError) {
return (
<EditorNotice title="Beute konnte nicht geladen werden." backHref={backHref}>
<button
type="button"
className="h-11 rounded-xl border border-white/20 px-4 text-sm"
onClick={() => {
void colony.refetch();
void hive.refetch();
void systems.refetch();
}}
>
Erneut versuchen
</button>
</EditorNotice>
);
}
if (!colony.data || !hive.data || !catalog) {
return <div role="status" aria-label="Beute wird geladen" className="fixed inset-0 z-40 bg-holo-bg motion-safe:animate-pulse" />;
}
return (
<HiveEditor
colonyId={id}
setup={hive.data}
catalog={catalog}
canManageTemplates={hasPermission("hive-systems:manage")}
/>
);
}
@@ -0,0 +1,111 @@
import { describe, expect, it } from "vitest";
import { ApiError } from "@/lib/auth/api-client";
import { inlineCatalog } from "@/lib/hive/test-utils";
import type { HiveOp } from "@/lib/hive/types";
import { sampleState } from "@/components/hive/test-fixtures";
import {
broodHintFor,
broodHintLabel,
hiveEntryTexts,
hiveErrorText,
undoHiveEntry,
} from "./hive-change";
const catalog = inlineCatalog();
const base = sampleState();
const FILL: HiveOp = { op: "setState", id: "fe", key: "syrupLiters", value: 2 };
const FRAME: HiveOp = { op: "setFrame", boxId: "b1", slot: 5, type: "BROOD", name: null };
describe("hiveEntryTexts", () => {
it("describes each op against the state the ops before it left", () => {
const texts = hiveEntryTexts([FRAME, FILL], base, catalog);
expect(texts).toHaveLength(2);
expect(texts[0]).toMatch(/Rähmchen 5/);
expect(texts[1]).toMatch(/Flüssigfutter/);
});
it("falls back to a plain sentence when the ops no longer apply", () => {
const broken: HiveOp = { op: "removePart", id: "missing" };
expect(hiveEntryTexts([broken], base, catalog)).toEqual(["Änderung am Aufbau"]);
});
});
describe("undoHiveEntry", () => {
it("rebuilds the ops without the chosen entry", () => {
const result = undoHiveEntry([FRAME, FILL], 0, base, catalog);
expect(result).toEqual({ ok: true, ops: [FILL] });
});
it("refuses with a reason text when the rest does not apply any more", () => {
const add: HiveOp = {
op: "addPart",
part: { id: "n1", partTypeId: "feeder", state: { syrupLiters: 0, fondantKg: 0 } },
at: 5,
};
const fill: HiveOp = { op: "setState", id: "n1", key: "syrupLiters", value: 1 };
const result = undoHiveEntry([add, fill], 0, base, catalog);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toBe("Ein Teil, auf das sich die Änderung bezieht, gibt es nicht mehr.");
});
it("ignores an index outside the list", () => {
expect(undoHiveEntry([FILL], 3, base, catalog)).toEqual({ ok: true, ops: [FILL] });
});
});
describe("broodHintFor", () => {
it("counts the brood frames of the state the ops lead to", () => {
expect(broodHintFor(base, [], catalog)).toBe(6);
expect(broodHintFor(base, [FRAME], catalog)).toBe(7);
});
it("is null while there is no setup", () => {
expect(broodHintFor(null, [], catalog)).toBeNull();
});
it("is null when the ops do not apply", () => {
expect(broodHintFor(base, [{ op: "removePart", id: "missing" }], catalog)).toBeNull();
});
});
describe("broodHintLabel", () => {
it("uses singular and plural", () => {
expect(broodHintLabel(1)).toBe("1 Brutwabe");
expect(broodHintLabel(6)).toBe("6 Brutwaben");
});
});
describe("hiveErrorText", () => {
const conflict = new ApiError(409, "Conflict", {
code: "HIVE_CONFLICT",
opIndex: 0,
reason: "MISSING_ID: no part x",
current: { version: 4, state: base },
});
it("explains a conflict with the reason text", () => {
expect(hiveErrorText(conflict)).toBe(
"Die Beute hat sich inzwischen geändert. Ein Teil, auf das sich die Änderung bezieht, gibt es nicht mehr. Nimm die Änderungen an der Beute heraus und speichere erneut.",
);
});
it("explains a missing right", () => {
const forbidden = new ApiError(403, "Forbidden", { code: "HIVE_FORBIDDEN" });
expect(hiveErrorText(forbidden)).toBe(
"Dir fehlt das Recht, die Beute zu ändern. Nimm die Änderungen an der Beute heraus und speichere erneut.",
);
});
it("leaves other errors to the form", () => {
expect(hiveErrorText(new ApiError(500, "Boom"))).toBeNull();
expect(hiveErrorText(null)).toBeNull();
expect(hiveErrorText(new ApiError(403, "Forbidden", { code: "OTHER" }))).toBeNull();
});
});
@@ -0,0 +1,66 @@
import { hiveConflictOf, hiveErrorCode } from "@/lib/hive/api";
import { describeHiveOps } from "@/lib/hive/describe";
import { deriveHiveFacts } from "@/lib/hive/facts";
import { applyHiveOps } from "@/lib/hive/ops";
import { hiveReasonText } from "@/lib/hive/reason-text";
import type { HiveCatalog, HiveOp, HiveState } from "@/lib/hive/types";
const FALLBACK_ENTRY_TEXT = "Änderung am Aufbau";
/** One German line per op, each described against the state the ops before it leave. */
export function hiveEntryTexts(ops: HiveOp[], base: HiveState, catalog: HiveCatalog): string[] {
let state = base;
return ops.map((op) => {
const text = describeHiveOps([op], state, catalog);
const applied = applyHiveOps(state, [op], catalog);
if (!applied.ok) {
// The ops no longer fit the loaded setup (it changed meanwhile): keep the list readable.
state = base;
return FALLBACK_ENTRY_TEXT;
}
state = applied.state;
return text;
});
}
export type UndoResult = { ok: true; ops: HiveOp[] } | { ok: false; reason: string };
/**
* The ops without entry `index`. When the rest no longer applies to the setup
* (a later op builds on the removed one) the entry stays and the reason is given.
*/
export function undoHiveEntry(
ops: HiveOp[],
index: number,
base: HiveState,
catalog: HiveCatalog,
): UndoResult {
const rest = ops.filter((_, position) => position !== index);
const applied = applyHiveOps(base, rest, catalog);
return applied.ok ? { ok: true, ops: rest } : { ok: false, reason: hiveReasonText(applied.reason) };
}
/** Brood frames of the build the ops lead to, or null without a setup or when the ops do not apply. */
export function broodHintFor(base: HiveState | null, ops: HiveOp[], catalog: HiveCatalog | null): number | null {
if (!base || !catalog) return null;
const applied = applyHiveOps(base, ops, catalog);
return applied.ok ? deriveHiveFacts(applied.state, catalog).broodFrames : null;
}
export function broodHintLabel(count: number): string {
return count === 1 ? "1 Brutwabe" : `${count} Brutwaben`;
}
const TAKE_OUT_HINT = "Nimm die Änderungen an der Beute heraus und speichere erneut.";
/** Inline text for a refusal of the hive part (409 HIVE_CONFLICT, 403 HIVE_FORBIDDEN); null for any other error. */
export function hiveErrorText(error: unknown): string | null {
const conflict = hiveConflictOf(error);
if (conflict) {
return `Die Beute hat sich inzwischen geändert. ${hiveReasonText(conflict.reason)} ${TAKE_OUT_HINT}`;
}
if (hiveErrorCode(error) === "HIVE_FORBIDDEN") {
return `Dir fehlt das Recht, die Beute zu ändern. ${TAKE_OUT_HINT}`;
}
return null;
}
@@ -0,0 +1,43 @@
import { showHiveFollowUps } from "@/components/hive/follow-up-toast";
import { applyHiveOps } from "@/lib/hive/ops";
import type { HiveCatalog, HiveOp, HiveState } from "@/lib/hive/types";
import { fetchColonyTreatments } from "@/lib/treatments/api";
async function hasOpenTreatment(colonyId: string): Promise<boolean> {
try {
const treatments = await fetchColonyTreatments(colonyId);
return treatments.some((treatment) => treatment.endDate == null);
} catch {
return false;
}
}
export interface SavedHiveChange {
colonyId: string;
/** Calendar day of the inspection; becomes the date of the suggested forms. */
occurredOn: string;
ops: HiveOp[];
/** The setup the ops were collected against. */
base: HiveState;
catalog: HiveCatalog;
navigate: (href: string) => void;
}
/**
* Spec section 8: after the inspection is saved, the batch's feeding and
* treatment suggestions appear as toasts. Suggestions only, nothing is booked.
*/
export async function suggestAfterInspection(saved: SavedHiveChange): Promise<void> {
const { ops, base, catalog, colonyId, occurredOn, navigate } = saved;
const applied = applyHiveOps(base, ops, catalog);
// The server was authoritative and saved the inspection: if the ops do not apply to our
// (possibly stale) copy of the setup, we just skip the suggestions instead of guessing.
if (!applied.ok) return;
const open = ops.some((op) => op.op === "removePart") ? await hasOpenTreatment(colonyId) : false;
showHiveFollowUps(ops, base, applied.state, catalog, {
colonyId,
occurredOn,
navigate,
hasOpenTreatment: open,
});
}
@@ -0,0 +1,185 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { HiveCard } from "@/components/hive/hive-card";
import { useHiveSystems, useSystemCatalog } from "@/components/hive/use-hive-systems";
import type { Colony } from "@/lib/colonies/types";
import { fetchHive, hiveKeys } from "@/lib/hive/api";
import type { HiveCatalog, HiveOp, HiveState } from "@/lib/hive/types";
import { hiveEntryTexts, undoHiveEntry } from "./hive-change";
/** The loaded setup and catalog of a colony's hive; same queries (and cache) as the card. */
export function useInspectionHive(colonyId: string): { base: HiveState | null; catalog: HiveCatalog | null } {
const enabled = colonyId !== "";
const hive = useQuery({
queryKey: hiveKeys.detail(colonyId),
queryFn: () => fetchHive(colonyId),
enabled,
retry: false,
});
const systems = useHiveSystems();
const catalog = useSystemCatalog(systems.data, hive.data?.systemId);
return { base: hive.data?.state ?? null, catalog };
}
/** Stable per entry: the op's content plus its n-th occurrence, so removing another entry keeps the key. */
function opKeys(ops: HiveOp[]): string[] {
const seen = new Map<string, number>();
return ops.map((op) => {
const json = JSON.stringify(op);
const nth = seen.get(json) ?? 0;
seen.set(json, nth + 1);
return `${json}#${nth}`;
});
}
interface HiveOpsListProps {
ops: HiveOp[];
base: HiveState;
catalog: HiveCatalog;
onChange: (ops: HiveOp[]) => void;
/** Called with the undone entry's text, for the live region. */
onUndone: (text: string) => void;
/** The list became empty: focus moves to the section. */
onEmptied: () => void;
}
function HiveOpsList({ ops, base, catalog, onChange, onUndone, onEmptied }: HiveOpsListProps) {
const buttons = useRef(new Map<string, HTMLButtonElement>());
const keys = useMemo(() => opKeys(ops), [ops]);
// Index to focus once the shortened list is committed (rows may remount when keys renumber).
const focusIndex = useRef<number | null>(null);
useEffect(() => {
if (focusIndex.current === null) return;
const target = keys[Math.min(focusIndex.current, keys.length - 1)];
focusIndex.current = null;
if (target) buttons.current.get(target)?.focus();
}, [keys]);
const [refusal, setRefusal] = useState<{ ops: HiveOp[]; index: number; reason: string } | null>(null);
const texts = useMemo(() => hiveEntryTexts(ops, base, catalog), [ops, base, catalog]);
// A refusal belongs to the list it was given for; a changed list makes it obsolete.
const blocked = refusal?.ops === ops ? refusal : null;
function undo(index: number) {
const result = undoHiveEntry(ops, index, base, catalog);
if (result.ok) {
// The neighbouring entry gets focus after the commit (see the effect above).
if (keys.length > 1) focusIndex.current = index;
else onEmptied();
onUndone(texts[index]);
onChange(result.ops);
} else setRefusal({ ops, index, reason: result.reason });
}
return (
<div className="flex flex-col gap-2">
<h3 className="text-sm font-semibold">Änderungen an der Beute ({ops.length})</h3>
<ul className="flex flex-col gap-2">
{texts.map((text, index) => (
<li key={keys[index]} className="flex flex-col gap-1 rounded-xl bg-muted/50 p-3">
<div className="flex items-center justify-between gap-3">
<span className="text-sm">{text}</span>
<Button
type="button"
variant="outline"
ref={(node) => {
if (node) buttons.current.set(keys[index], node);
else buttons.current.delete(keys[index]);
}}
className="min-h-11 shrink-0"
aria-label={`Rückgängig: ${text}`}
onClick={() => undo(index)}
>
Rückgängig
</Button>
</div>
{blocked?.index === index ? (
<p role="alert" className="flex items-start gap-2 text-sm">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-warning" aria-hidden />
{blocked.reason} Mach zuerst die späteren Änderungen rückgängig.
</p>
) : null}
</li>
))}
</ul>
</div>
);
}
export interface InspectionHiveSectionProps {
colony: Colony;
/** colonies:update: edit the hive as a draft; without it the card is read-only. */
canEdit: boolean;
ops: HiveOp[];
/** Changes whenever the card must be rebuilt from `ops` (after „Rückgängig“, new colony). */
cardKey: string;
onOpsChange: (ops: HiveOp[]) => void;
/** Rebuilds the card with these ops (after „Rückgängig“). */
onOpsReplace: (ops: HiveOp[]) => void;
base: HiveState | null;
catalog: HiveCatalog | null;
/** Server refusal of the hive part (409 HIVE_CONFLICT, 403), shown right here. */
errorText: string | null;
}
/**
* Content of the form's „Beute“ section: the card (draft mode only with
* colonies:update), the list of collected changes and a server refusal.
*/
export function InspectionHiveSection({
colony,
canEdit,
ops,
cardKey,
onOpsChange,
onOpsReplace,
base,
catalog,
errorText,
}: InspectionHiveSectionProps) {
const sectionRef = useRef<HTMLDivElement>(null);
const [announcement, setAnnouncement] = useState("");
return (
// A portal form inside the card (frame/part sheet) must never submit the enclosing inspection form.
<div
ref={sectionRef}
data-testid="hive-section"
tabIndex={-1}
className="flex flex-col gap-4 outline-none"
onSubmit={(event) => event.stopPropagation()}
>
<p role="status" aria-live="polite" className="sr-only">
{announcement}
</p>
<div className="flex h-[min(55svh,520px)] min-h-80 flex-col *:min-h-0 *:flex-1">
<HiveCard
key={cardKey}
colonyId={colony.id}
colony={colony}
mode={canEdit ? "inspection" : "view"}
onOpsChange={canEdit ? onOpsChange : undefined}
initialOps={canEdit ? ops : undefined}
/>
</div>
{canEdit && ops.length > 0 && base && catalog ? (
<HiveOpsList
ops={ops}
base={base}
catalog={catalog}
onChange={onOpsReplace}
onUndone={(text) => setAnnouncement(`Rückgängig: ${text}`)}
onEmptied={() => sectionRef.current?.focus()}
/>
) : null}
{errorText ? (
<p role="alert" className="flex items-start gap-2 text-sm font-medium text-destructive">
<AlertTriangle className="mt-0.5 size-4 shrink-0" aria-hidden />
{errorText}
</p>
) : null}
</div>
);
}
@@ -1,4 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import type { HiveOp } from "@/lib/hive/types";
import { import {
currentDraftValues, currentDraftValues,
feedLegacyHint, feedLegacyHint,
@@ -209,6 +210,31 @@ describe("Volkskarte selection", () => {
}); });
}); });
describe("toCreateInspectionInput with hive ops", () => {
const ops: HiveOp[] = [{ op: "setState", id: "fe", key: "syrupLiters", value: 2 }];
const values = { ...initialInspectionValues("c-1"), date: "2026-10-01" };
it("carries hiveChange only when there are ops", () => {
expect(toCreateInspectionInput(values, true, ops).hiveChange).toEqual({ ops });
expect(toCreateInspectionInput(values, true, [])).not.toHaveProperty("hiveChange");
expect(toCreateInspectionInput(values, true)).not.toHaveProperty("hiveChange");
});
it("never sends hiveChange without colonies:update", () => {
expect(toCreateInspectionInput(values, false, ops)).not.toHaveProperty("hiveChange");
});
it("keeps applyToColony next to hiveChange", () => {
const input = toCreateInspectionInput(
{ ...values, colonyStrength: "6", applyToColony: ["occupiedFrames"] },
true,
ops,
);
expect(input).toMatchObject({ applyToColony: ["occupiedFrames"], hiveChange: { ops } });
});
});
describe("feedStatusOptions", () => { describe("feedStatusOptions", () => {
const labels = (stored: Parameters<typeof feedStatusOptions>[0]) => const labels = (stored: Parameters<typeof feedStatusOptions>[0]) =>
feedStatusOptions(stored).map((option) => option.label); feedStatusOptions(stored).map((option) => option.label);
@@ -7,6 +7,7 @@ import {
} from "@/lib/colonies/condition"; } from "@/lib/colonies/condition";
import type { ColonyCondition } from "@/lib/colonies/types"; import type { ColonyCondition } from "@/lib/colonies/types";
import { toApiDate, todayLocalIsoDate } from "@/lib/dates"; import { toApiDate, todayLocalIsoDate } from "@/lib/dates";
import type { HiveOp } from "@/lib/hive/types";
import { emptyToNull } from "@/lib/forms/empty-to-null"; import { emptyToNull } from "@/lib/forms/empty-to-null";
import { import {
COLONY_TAKEOVER_FIELDS, COLONY_TAKEOVER_FIELDS,
@@ -238,20 +239,26 @@ export function syncTakeoverSelection(
/** /**
* What a new inspection sends: the correction input plus, when the beekeeper * What a new inspection sends: the correction input plus, when the beekeeper
* may update colonies, the checked Volkskarte fields that have a value. * may update colonies, the checked Volkskarte fields that have a value and the
* Omitted when empty, so nothing is asked of the server that was not wanted. * hive ops collected in the „Beute“ section. Both are omitted when empty, so
* nothing is asked of the server that was not wanted.
*/ */
export function toCreateInspectionInput( export function toCreateInspectionInput(
values: InspectionFormValues, values: InspectionFormValues,
canUpdateColony: boolean, canUpdateColony: boolean,
hiveOps: HiveOp[] = [],
): CreateInspectionInput { ): CreateInspectionInput {
const input = toInspectionInput(values); const input: CreateInspectionInput = toInspectionInput(values);
if (!canUpdateColony) { if (!canUpdateColony) {
return input; return input;
} }
const filled = new Set(takeoverFieldsFilled(values)); const filled = new Set(takeoverFieldsFilled(values));
const applyToColony = values.applyToColony.filter((field) => filled.has(field)); const applyToColony = values.applyToColony.filter((field) => filled.has(field));
return applyToColony.length > 0 ? { ...input, applyToColony } : input; return {
...input,
...(applyToColony.length > 0 ? { applyToColony } : {}),
...(hiveOps.length > 0 ? { hiveChange: { ops: hiveOps } } : {}),
};
} }
/** /**
@@ -0,0 +1,371 @@
import { createPortal } from "react-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { toast } from "sonner";
import NewInspectionPage from "./page";
import { ApiError } from "@/lib/auth/api-client";
import { fetchColonies } from "@/lib/colonies/api";
import { createInspection, fetchColonyInspections } from "@/lib/inspections/api";
import { fetchHive, fetchHiveSystems } from "@/lib/hive/api";
import { fetchApiaryWeather } from "@/lib/weather/api";
import { showHiveFollowUps } from "@/components/hive/follow-up-toast";
import { sampleState, setupView, systemDto } from "@/components/hive/test-fixtures";
import { queueInspectionWithPhotos } from "@/lib/photos/store";
import { syncPendingEntries } from "@/lib/offline/sync";
// Section „Beute“ of the inspection form (Task 19). The card itself is stubbed:
// its buttons report ops the way the real card does in draft mode.
const push = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push }),
useSearchParams: () => new URLSearchParams("colonyId=colony-1"),
}));
let permissions = new Set<string>();
vi.mock("@/lib/auth/auth-context", () => ({
useCurrentUserId: () => "user-1",
useAuth: () => ({ hasPermission: (permission: string) => permissions.has(permission) }),
}));
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() }));
vi.mock("@/lib/inspections/api", () => ({ createInspection: vi.fn(), fetchColonyInspections: vi.fn() }));
vi.mock("@/lib/weather/api", () => ({ fetchApiaryWeather: vi.fn() }));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }));
vi.mock("@/lib/offline/sync", () => ({ syncPendingEntries: vi.fn() }));
vi.mock("@/lib/photos/store", () => ({
PHOTOS_PER_INSPECTION_MAX: 5,
listSessionPhotos: vi.fn().mockResolvedValue([]),
addDraftPhoto: vi.fn(),
removePhoto: vi.fn(),
handOverSessionPhotos: vi.fn().mockResolvedValue(0),
queueInspectionWithPhotos: vi.fn().mockResolvedValue(0),
moveDraftPhotos: vi.fn(),
discardSessionPhotos: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/components/hive/follow-up-toast", () => ({ showHiveFollowUps: vi.fn() }));
vi.mock("@/lib/hive/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/hive/api")>()),
fetchHive: vi.fn(),
fetchHiveSystems: vi.fn(),
}));
const card = vi.hoisted(() => ({ renders: [] as { mode?: string; initialOps?: unknown[] }[] }));
vi.mock("@/components/hive/hive-card", () => ({
HiveCard: (props: { mode?: string; initialOps?: unknown[]; onOpsChange?: (ops: unknown[]) => void }) => {
card.renders.push({ mode: props.mode, initialOps: props.initialOps });
const fill = { op: "setState", id: "fe", key: "syrupLiters", value: 2 };
const frame = { op: "setFrame", boxId: "b1", slot: 5, type: "BROOD", name: null };
const add = {
op: "addPart",
part: { id: "n1", partTypeId: "feeder", state: { syrupLiters: 0, fondantKg: 0 } },
at: 5,
};
const fillNew = { op: "setState", id: "n1", key: "syrupLiters", value: 1 };
return (
<div data-testid="hive-card" data-mode={props.mode}>
<button type="button" onClick={() => props.onOpsChange?.([fill])}>
Test Futter
</button>
<button type="button" onClick={() => props.onOpsChange?.([frame, fill])}>
Test Rähmchen und Futter
</button>
<button type="button" onClick={() => props.onOpsChange?.([add, fillNew])}>
Test neue Zarge
</button>
<button type="button" onClick={() => props.onOpsChange?.([fill, fill])}>
Test zwei gleiche
</button>
{createPortal(
<form onSubmit={(event) => event.preventDefault()}>
<button type="submit">Sheet speichern</button>
</form>,
document.body,
)}
</div>
);
},
}));
function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<NewInspectionPage />
</QueryClientProvider>,
);
}
async function save() {
await userEvent.click(screen.getByRole("button", { name: "Durchsicht speichern" }));
}
function sentInput(): Record<string, unknown> {
return vi.mocked(createInspection).mock.calls[0][0] as unknown as Record<string, unknown>;
}
const FILL = { op: "setState", id: "fe", key: "syrupLiters", value: 2 };
const FRAME = { op: "setFrame", boxId: "b1", slot: 5, type: "BROOD", name: null };
describe("NewInspectionPage: section Beute", () => {
beforeEach(() => {
window.localStorage.clear();
window.sessionStorage.clear();
push.mockReset();
card.renders.length = 0;
permissions = new Set(["colonies:update"]);
vi.mocked(fetchColonies).mockResolvedValue([
{ id: "colony-1", code: "V-01", status: "ACTIVE", apiaryId: "apiary-1" },
] as never);
vi.mocked(fetchColonyInspections).mockResolvedValue([]);
vi.mocked(fetchApiaryWeather).mockResolvedValue({ daily: [] });
vi.mocked(fetchHive).mockResolvedValue(setupView(sampleState()));
vi.mocked(fetchHiveSystems).mockResolvedValue([systemDto()]);
vi.mocked(createInspection)
.mockReset()
.mockResolvedValue({ id: "i-1", colonyId: "colony-1" } as never);
vi.mocked(showHiveFollowUps).mockReset();
vi.mocked(toast.success).mockReset();
vi.mocked(queueInspectionWithPhotos).mockReset().mockResolvedValue(0);
vi.mocked(syncPendingEntries).mockReset().mockResolvedValue({ synced: 0, failed: 0, skippedForeign: 0 });
});
it("shows the card in draft mode with colonies:update", async () => {
renderPage();
const section = await screen.findByTestId("hive-card");
expect(section).toHaveAttribute("data-mode", "inspection");
expect(screen.getByRole("group", { name: /Beute/ })).toBeInTheDocument();
});
it("shows the model read-only without colonies:update", async () => {
permissions = new Set();
renderPage();
expect(await screen.findByTestId("hive-card")).toHaveAttribute("data-mode", "view");
await userEvent.click(screen.getByRole("button", { name: "Test Futter" }));
expect(screen.queryByText(/Änderungen an der Beute/)).not.toBeInTheDocument();
await save();
expect(sentInput()).not.toHaveProperty("hiveChange");
});
it("lists the changes and sends them with the inspection", async () => {
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Test Rähmchen und Futter" }));
expect(screen.getByRole("heading", { name: "Änderungen an der Beute (2)" })).toBeInTheDocument();
expect(screen.getAllByRole("button", { name: /^Rückgängig/ })).toHaveLength(2);
await save();
expect(sentInput()).toMatchObject({ hiveChange: { ops: [FRAME, FILL] } });
});
it("sends no hiveChange while there are no ops", async () => {
renderPage();
await screen.findByTestId("hive-card");
await save();
expect(sentInput()).not.toHaveProperty("hiveChange");
});
it("Rückgängig removes one entry and rebuilds the card from the rest", async () => {
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Test Rähmchen und Futter" }));
await userEvent.click(screen.getAllByRole("button", { name: /^Rückgängig/ })[0]);
expect(screen.getByRole("heading", { name: "Änderungen an der Beute (1)" })).toBeInTheDocument();
expect(card.renders.at(-1)?.initialOps).toEqual([FILL]);
await save();
expect(sentInput()).toMatchObject({ hiveChange: { ops: [FILL] } });
});
it("remounts the card seeded with the remaining ops after Rückgängig", async () => {
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Test Rähmchen und Futter" }));
const rendersBefore = card.renders.length;
await userEvent.click(screen.getAllByRole("button", { name: /^Rückgängig/ })[1]);
expect(card.renders.length).toBeGreaterThan(rendersBefore);
expect(card.renders.at(-1)?.initialOps).toEqual([FRAME]);
expect(screen.getByText(/Laut Beute: 7 Brutwaben/)).toBeInTheDocument();
});
it("does not save the inspection when a form inside the card submits", async () => {
renderPage();
await screen.findByTestId("hive-card");
await userEvent.click(screen.getByRole("button", { name: "Sheet speichern" }));
expect(createInspection).not.toHaveBeenCalled();
});
it("moves focus to the neighbouring entry and announces the undo", async () => {
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Test Rähmchen und Futter" }));
await userEvent.click(screen.getAllByRole("button", { name: /^Rückgängig/ })[0]);
expect(screen.getByRole("button", { name: /^Rückgängig/ })).toHaveFocus();
expect(screen.getAllByRole("status").some((el) => /^Rückgängig: /.test(el.textContent ?? ""))).toBe(true);
});
it("moves focus to the section when the list becomes empty", async () => {
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Test Futter" }));
await userEvent.click(screen.getByRole("button", { name: /^Rückgängig/ }));
expect(screen.getByTestId("hive-section")).toHaveFocus();
});
it("keeps focus on a row when identical ops renumber their keys", async () => {
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Test zwei gleiche" }));
await userEvent.click(screen.getAllByRole("button", { name: /^Rückgängig/ })[0]);
expect(screen.getByRole("button", { name: /^Rückgängig/ })).toHaveFocus();
});
it("hides a refusal once the ops changed", async () => {
vi.mocked(createInspection).mockRejectedValue(
new ApiError(403, "Forbidden", { code: "HIVE_FORBIDDEN" }),
);
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Test Rähmchen und Futter" }));
await save();
expect(await screen.findByRole("alert")).toHaveTextContent("Dir fehlt das Recht");
await userEvent.click(screen.getAllByRole("button", { name: /^Rückgängig/ })[0]);
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
it("hides the hint for 0 brood frames while the field is empty", async () => {
vi.mocked(fetchHive).mockResolvedValue(
setupView({ schemaVersion: 1, stack: [{ id: "r", partTypeId: "roof", state: {} }] } as never),
);
renderPage();
await screen.findByTestId("hive-card");
await waitFor(() => expect(fetchHive).toHaveBeenCalled());
expect(screen.queryByText(/Laut Beute/)).not.toBeInTheDocument();
});
it("drops hiveChange again when the last entry is undone", async () => {
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Test Futter" }));
await userEvent.click(screen.getByRole("button", { name: /^Rückgängig/ }));
expect(screen.queryByText(/Änderungen an der Beute/)).not.toBeInTheDocument();
await save();
expect(sentInput()).not.toHaveProperty("hiveChange");
});
it("keeps an entry and says why when the rest would no longer apply", async () => {
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Test neue Zarge" }));
await userEvent.click(screen.getAllByRole("button", { name: /^Rückgängig/ })[0]);
expect(screen.getByRole("heading", { name: "Änderungen an der Beute (2)" })).toBeInTheDocument();
expect(
screen.getByText(/Ein Teil, auf das sich die Änderung bezieht, gibt es nicht mehr\./),
).toBeInTheDocument();
});
it("offers the brood frames of the hive but never fills them by itself", async () => {
renderPage();
expect(await screen.findByText(/Laut Beute: 6 Brutwaben/)).toBeInTheDocument();
await save();
expect(sentInput()).toMatchObject({ broodFrames: null });
});
it("takes the hive's brood frames over only after Übernehmen", async () => {
renderPage();
const hint = (await screen.findByText(/Laut Beute: 6 Brutwaben/)).closest("p") as HTMLElement;
await userEvent.click(within(hint).getByRole("button", { name: /übernehmen/ }));
await waitFor(() => expect(screen.queryByText(/Laut Beute/)).not.toBeInTheDocument());
await save();
expect(sentInput()).toMatchObject({ broodFrames: 6 });
});
it("follows the ops: a brood frame in the draft raises the hint", async () => {
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Test Rähmchen und Futter" }));
expect(screen.getByText(/Laut Beute: 7 Brutwaben/)).toBeInTheDocument();
});
it("suggests feeding and treatment for the saved batch", async () => {
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Test Futter" }));
await save();
await waitFor(() => expect(showHiveFollowUps).toHaveBeenCalledTimes(1));
const [ops, before, after, , context] = vi.mocked(showHiveFollowUps).mock.calls[0];
expect(ops).toEqual([FILL]);
expect(before).toEqual(sampleState());
expect(after).not.toEqual(before);
expect(context).toMatchObject({ colonyId: "colony-1", hasOpenTreatment: false });
});
it("suggests nothing when no hive change was sent", async () => {
renderPage();
await screen.findByTestId("hive-card");
await save();
await waitFor(() => expect(toast.success).toHaveBeenCalled());
expect(showHiveFollowUps).not.toHaveBeenCalled();
});
it("passes hiveChange untouched into the offline queue", async () => {
vi.mocked(createInspection).mockRejectedValue(new TypeError("Failed to fetch"));
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Test Futter" }));
await save();
await waitFor(() => expect(queueInspectionWithPhotos).toHaveBeenCalled());
expect(vi.mocked(queueInspectionWithPhotos).mock.calls[0][1]).toMatchObject({
hiveChange: { ops: [FILL] },
});
expect(showHiveFollowUps).not.toHaveBeenCalled();
});
it("explains a 409 HIVE_CONFLICT inline and keeps the ops", async () => {
vi.mocked(createInspection).mockRejectedValue(
new ApiError(409, "Conflict", {
code: "HIVE_CONFLICT",
opIndex: 0,
reason: "MISSING_ID: no part",
current: { version: 5, state: sampleState() },
}),
);
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Test Futter" }));
await save();
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("Die Beute hat sich inzwischen geändert.");
expect(alert).toHaveTextContent("Ein Teil, auf das sich die Änderung bezieht, gibt es nicht mehr.");
expect(screen.getByRole("heading", { name: "Änderungen an der Beute (1)" })).toBeInTheDocument();
expect(screen.getAllByRole("alert")).toHaveLength(1);
});
it("explains a 403 HIVE_FORBIDDEN inline", async () => {
vi.mocked(createInspection).mockRejectedValue(
new ApiError(403, "Forbidden", { code: "HIVE_FORBIDDEN" }),
);
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Test Futter" }));
await save();
expect(await screen.findByRole("alert")).toHaveTextContent("Dir fehlt das Recht, die Beute zu ändern.");
});
});
@@ -48,6 +48,13 @@ vi.mock("@/lib/inspections/api", () => ({
})); }));
vi.mock("@/lib/weather/api", () => ({ fetchApiaryWeather: vi.fn() })); vi.mock("@/lib/weather/api", () => ({ fetchApiaryWeather: vi.fn() }));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } })); vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }));
// The Beute card and its data have their own specs (page-hive.spec.tsx); here the section stays inert.
vi.mock("@/components/hive/hive-card", () => ({ HiveCard: () => null }));
vi.mock("@/lib/hive/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/hive/api")>()),
fetchHive: vi.fn().mockRejectedValue(new Error("no hive")),
fetchHiveSystems: vi.fn().mockResolvedValue([]),
}));
vi.mock("@/lib/offline/sync", () => ({ syncPendingEntries: vi.fn() })); vi.mock("@/lib/offline/sync", () => ({ syncPendingEntries: vi.fn() }));
vi.mock("@/lib/photos/store", () => ({ vi.mock("@/lib/photos/store", () => ({
PHOTOS_PER_INSPECTION_MAX: 5, PHOTOS_PER_INSPECTION_MAX: 5,
@@ -1110,4 +1117,76 @@ describe("NewInspectionPage", () => {
expect(discardSessionPhotos).not.toHaveBeenCalled(); expect(discardSessionPhotos).not.toHaveBeenCalled();
}); });
}); });
describe("dictation", () => {
class FakeRecognition {
static instances: FakeRecognition[] = [];
static available = vi.fn(async () => "available");
lang = "";
continuous = false;
interimResults = false;
maxAlternatives = 1;
handlers: Record<string, (event: unknown) => void> = {};
start = vi.fn();
stop = vi.fn();
abort = vi.fn();
constructor() {
FakeRecognition.instances.push(this);
}
set onresult(fn: (event: unknown) => void) {
this.handlers.result = fn;
}
set onend(fn: (event: unknown) => void) {
this.handlers.end = fn;
}
set onerror(fn: (event: unknown) => void) {
this.handlers.error = fn;
}
set onaudiostart(fn: (event: unknown) => void) {
this.handlers.audiostart = fn;
}
}
beforeEach(() => {
FakeRecognition.instances = [];
(window as unknown as Record<string, unknown>).SpeechRecognition = FakeRecognition;
});
afterEach(() => {
delete (window as unknown as Record<string, unknown>).SpeechRecognition;
});
async function openMoreFields() {
await userEvent.click(screen.getByRole("button", { name: /Weitere Angaben/ }));
}
it("puts a dictation button next to notes, health, varroa and measures", async () => {
renderPage();
await openMoreFields();
for (const field of ["Notizen", "Gesundheitsauffälligkeiten", "Varroa-Befund", "Maßnahmen"]) {
expect(
await screen.findByRole("button", { name: `Diktieren: ${field}` }),
).toBeInTheDocument();
}
});
it("appends the dictated text to a typed note and saves it", async () => {
vi.mocked(createInspection).mockResolvedValue({ id: "i-1", colonyId: "colony-1" } as never);
renderPage();
await openMoreFields();
await userEvent.type(screen.getByRole("textbox", { name: /^Notizen/ }), "Alt");
await userEvent.click(await screen.findByRole("button", { name: "Diktieren: Notizen" }));
const engine = FakeRecognition.instances[0];
act(() =>
engine.handlers.result({
resultIndex: 0,
results: [Object.assign([{ transcript: "neu" }], { isFinal: true })],
}),
);
expect(screen.getByRole("textbox", { name: /^Notizen/ })).toHaveValue("Alt neu");
await userEvent.click(screen.getByRole("button", { name: "Durchsicht speichern" }));
expect(vi.mocked(createInspection).mock.calls[0][0]).toMatchObject({ notes: "Alt neu" });
});
});
}); });
@@ -20,6 +20,8 @@ import {
} from "@/lib/inspections/takeover"; } from "@/lib/inspections/takeover";
import { type LastInspection, useLastInspection } from "@/lib/inspections/use-last-inspection"; import { type LastInspection, useLastInspection } from "@/lib/inspections/use-last-inspection";
import type { ColonyTakeoverField, CreateInspectionInput } from "@/lib/inspections/types"; import type { ColonyTakeoverField, CreateInspectionInput } from "@/lib/inspections/types";
import { hiveKeys } from "@/lib/hive/api";
import type { HiveOp } from "@/lib/hive/types";
import { useAuth, useCurrentUserId } from "@/lib/auth/auth-context"; import { useAuth, useCurrentUserId } from "@/lib/auth/auth-context";
import { MAX_OCCUPIED_FRAMES } from "@/lib/colonies/labels"; import { MAX_OCCUPIED_FRAMES } from "@/lib/colonies/labels";
import { photosNotTakenOver } from "@/lib/offline/queue-texts"; import { photosNotTakenOver } from "@/lib/offline/queue-texts";
@@ -40,6 +42,8 @@ import type { StandRound } from "@/lib/rounds/round-state";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { DictationRow } from "@/components/voice/dictation-button";
import { appendDictation } from "@/lib/voice/append-text";
import { FormBackLink } from "@/components/form/form-back-link"; import { FormBackLink } from "@/components/form/form-back-link";
import { RoundNextPanel } from "@/components/rounds/round-next-panel"; import { RoundNextPanel } from "@/components/rounds/round-next-panel";
import { PageHeader } from "@/components/page-header"; import { PageHeader } from "@/components/page-header";
@@ -66,6 +70,12 @@ import {
syncTakeoverSelection, syncTakeoverSelection,
toCreateInspectionInput, toCreateInspectionInput,
} from "./inspection-form-values"; } from "./inspection-form-values";
import { broodHintFor, broodHintLabel, hiveErrorText } from "./hive-change";
import { suggestAfterInspection } from "./hive-save-follow-ups";
import { InspectionHiveSection, useInspectionHive } from "./hive-section";
/** Free-text fields that take dictation. */
type DictatedField = "notes" | "healthIssues" | "varroaIssues" | "measures";
const UNDO_TOAST_MS = 8000; const UNDO_TOAST_MS = 8000;
const WEATHER_UPDATED = "Wetter aktualisiert"; const WEATHER_UPDATED = "Wetter aktualisiert";
@@ -218,6 +228,13 @@ function InspectionCaptureForm({
const colonyCard = colonies?.find((colony) => colony.id === values.colonyId); const colonyCard = colonies?.find((colony) => colony.id === values.colonyId);
const stand = useStandRound(roundApiaryId, values.colonyId); const stand = useStandRound(roundApiaryId, values.colonyId);
const [roundSaved, setRoundSaved] = useState<RoundSaved | null>(null); const [roundSaved, setRoundSaved] = useState<RoundSaved | null>(null);
// Hive ops collected in the „Beute“ section; sent with the inspection, never drafted.
const [hiveOps, setHiveOps] = useState<HiveOp[]>([]);
const [hiveCardGeneration, setHiveCardGeneration] = useState(0);
// The ops of the last submit: a refusal only speaks for exactly those.
const [submittedHiveOps, setSubmittedHiveOps] = useState("");
const { base: hiveBase, catalog: hiveCatalog } = useInspectionHive(values.colonyId);
const broodFromHive = broodHintFor(hiveBase, hiveOps, hiveCatalog);
/** /**
* Every change of the answers also keeps the Volkskarte selection in step. * Every change of the answers also keeps the Volkskarte selection in step.
@@ -255,10 +272,33 @@ function InspectionCaptureForm({
{ touched: TOUCHED_TAKEOVER_FIELD[key] }, { touched: TOUCHED_TAKEOVER_FIELD[key] },
); );
setDirty(true); setDirty(true);
if (key === "colonyId") setColonyError(null); if (key === "colonyId") {
// The ops belong to the other colony's hive.
replaceHiveOps([]);
setColonyError(null);
}
if (key === "date") setDateError(null); if (key === "date") setDateError(null);
} }
/** Ops reported by the card as the beekeeper edits. */
function collectHiveOps(ops: HiveOp[]) {
setHiveOps(ops);
setDirty(true);
}
/** Ops changed from outside the card („Rückgängig“, new colony): the card is rebuilt from them. */
function replaceHiveOps(ops: HiveOp[]) {
setHiveOps(ops);
setHiveCardGeneration((generation) => generation + 1);
setDirty(true);
}
/** Dictation is added to the end of a free-text field and marks the form as changed. */
function appendDictated(key: DictatedField, text: string) {
update((current) => ({ ...current, [key]: appendDictation(current[key], text) }));
setDirty(true);
}
/** How many photos the form's photo section shows (none while it is hidden). */ /** How many photos the form's photo section shows (none while it is hidden). */
function shownPhotoCount(photoSessionId: string): number { function shownPhotoCount(photoSessionId: string): number {
return ( return (
@@ -336,6 +376,19 @@ function InspectionCaptureForm({
queryKey: ["colonies", outcome.result.colonyId, "inspections"], queryKey: ["colonies", outcome.result.colonyId, "inspections"],
}); });
toast.success("Durchsicht gespeichert"); toast.success("Durchsicht gespeichert");
if (input.hiveChange) {
void queryClient.invalidateQueries({ queryKey: hiveKeys.detail(input.colonyId) });
if (hiveBase && hiveCatalog) {
void suggestAfterInspection({
colonyId: input.colonyId,
occurredOn: toCalendarDay(input.date),
ops: input.hiveChange.ops,
base: hiveBase,
catalog: hiveCatalog,
navigate: (href) => router.push(href),
});
}
}
const takeover = outcome.result.colonyTakeover; const takeover = outcome.result.colonyTakeover;
if (takeover?.applied.length) { if (takeover?.applied.length) {
// The colony card changed: its page and lists must not show the old values. // The colony card changed: its page and lists must not show the old values.
@@ -367,7 +420,8 @@ function InspectionCaptureForm({
return; return;
} }
submittedPhotoSession.current = values.photoSessionId; submittedPhotoSession.current = values.photoSessionId;
capture.submit(toCreateInspectionInput(values, canUpdateColony)); setSubmittedHiveOps(JSON.stringify(hiveOps));
capture.submit(toCreateInspectionInput(values, canUpdateColony, hiveOps));
} }
/** /**
@@ -496,6 +550,10 @@ function InspectionCaptureForm({
// Same rule as the backend automation: only an explicit "Nein" counts. // Same rule as the backend automation: only an explicit "Nein" counts.
const noQueenSigns = values.queenSeen === false && values.eggsPresent === false; const noQueenSigns = values.queenSeen === false && values.eggsPresent === false;
const hiveError =
hiveOps.length > 0 && JSON.stringify(hiveOps) === submittedHiveOps
? hiveErrorText(capture.error)
: null;
return ( return (
<main className="flex flex-col gap-4 p-4 sm:p-6"> <main className="flex flex-col gap-4 p-4 sm:p-6">
@@ -609,12 +667,46 @@ function InspectionCaptureForm({
describedBy={describedBy} describedBy={describedBy}
/> />
{hintFor("broodFrames")} {hintFor("broodFrames")}
{broodFromHive !== null &&
values.broodFrames !== String(broodFromHive) &&
!(broodFromHive === 0 && values.broodFrames === "") ? (
<PreviousValueHint
prefix="Laut Beute"
fieldLabel="Brutwaben"
valueLabel={broodHintLabel(broodFromHive)}
onTakeOver={() => set("broodFrames", String(broodFromHive))}
/>
) : null}
</> </>
)} )}
</Field> </Field>
</FormSection> </FormSection>
<FormSection step={3} title="Stärke & Verhalten"> {colonyCard ? (
<FormSection
step={3}
title="Beute"
description={
canUpdateColony
? "Änderungen am Aufbau werden mit der Durchsicht gespeichert."
: "Nur ansehen – Änderungen am Aufbau brauchen das Recht, Völker zu bearbeiten."
}
>
<InspectionHiveSection
colony={colonyCard}
canEdit={canUpdateColony}
ops={hiveOps}
cardKey={`${colonyCard.id}:${hiveCardGeneration}`}
onOpsChange={collectHiveOps}
onOpsReplace={replaceHiveOps}
base={hiveBase}
catalog={hiveCatalog}
errorText={hiveError}
/>
</FormSection>
) : null}
<FormSection step={4} title="Stärke & Verhalten">
<Field label="Volksstärke" hint="Anzahl besetzter Waben"> <Field label="Volksstärke" hint="Anzahl besetzter Waben">
{({ id, describedBy }) => ( {({ id, describedBy }) => (
<> <>
@@ -680,7 +772,7 @@ function InspectionCaptureForm({
</Field> </Field>
</FormSection> </FormSection>
<FormSection step={4} title="Futter"> <FormSection step={5} title="Futter">
<Field label="Futterstatus" group> <Field label="Futterstatus" group>
{({ id, labelId }) => ( {({ id, labelId }) => (
<> <>
@@ -705,7 +797,7 @@ function InspectionCaptureForm({
</FormSection> </FormSection>
<FormSection <FormSection
step={5} step={6}
title="Wetter" title="Wetter"
description="Automatisch aus den Wetterdaten des Stands (Tageshöchstwert) – änderbar" description="Automatisch aus den Wetterdaten des Stands (Tageshöchstwert) – änderbar"
> >
@@ -743,7 +835,7 @@ function InspectionCaptureForm({
</FormSection> </FormSection>
<FormSection <FormSection
step={6} step={7}
title="Weitere Angaben" title="Weitere Angaben"
collapsible collapsible
defaultOpen={false} defaultOpen={false}
@@ -766,6 +858,10 @@ function InspectionCaptureForm({
</Field> </Field>
<Field label="Varroa-Befund"> <Field label="Varroa-Befund">
{({ id }) => ( {({ id }) => (
<DictationRow
fieldLabel="Varroa-Befund"
onAppend={(text) => appendDictated("varroaIssues", text)}
>
<Input <Input
id={id} id={id}
className="h-11" className="h-11"
@@ -774,10 +870,15 @@ function InspectionCaptureForm({
set("varroaIssues", event.target.value) set("varroaIssues", event.target.value)
} }
/> />
</DictationRow>
)} )}
</Field> </Field>
<Field label="Gesundheitsauffälligkeiten"> <Field label="Gesundheitsauffälligkeiten">
{({ id }) => ( {({ id }) => (
<DictationRow
fieldLabel="Gesundheitsauffälligkeiten"
onAppend={(text) => appendDictated("healthIssues", text)}
>
<Input <Input
id={id} id={id}
className="h-11" className="h-11"
@@ -786,30 +887,41 @@ function InspectionCaptureForm({
set("healthIssues", event.target.value) set("healthIssues", event.target.value)
} }
/> />
</DictationRow>
)} )}
</Field> </Field>
<Field label="Maßnahmen"> <Field label="Maßnahmen">
{({ id }) => ( {({ id }) => (
<DictationRow
fieldLabel="Maßnahmen"
onAppend={(text) => appendDictated("measures", text)}
>
<Textarea <Textarea
id={id} id={id}
value={values.measures} value={values.measures}
onChange={(event) => set("measures", event.target.value)} onChange={(event) => set("measures", event.target.value)}
/> />
</DictationRow>
)} )}
</Field> </Field>
<Field label="Notizen"> <Field label="Notizen">
{({ id }) => ( {({ id }) => (
<DictationRow
fieldLabel="Notizen"
onAppend={(text) => appendDictated("notes", text)}
>
<Textarea <Textarea
id={id} id={id}
value={values.notes} value={values.notes}
onChange={(event) => set("notes", event.target.value)} onChange={(event) => set("notes", event.target.value)}
/> />
</DictationRow>
)} )}
</Field> </Field>
</FormSection> </FormSection>
<PhotoCaptureField <PhotoCaptureField
step={7} step={8}
sessionId={values.photoSessionId} sessionId={values.photoSessionId}
date={values.date} date={values.date}
onChange={() => setDirty(true)} onChange={() => setDirty(true)}
@@ -833,7 +945,7 @@ function InspectionCaptureForm({
<StickySubmit <StickySubmit
note={ note={
capture.submitError ? ( capture.submitError && hiveErrorText(capture.error) === null ? (
<p <p
role="alert" role="alert"
className="text-sm font-medium text-destructive" className="text-sm font-medium text-destructive"
@@ -206,6 +206,21 @@ describe("AppLayout", () => {
expect(pushMock).toHaveBeenCalledWith("/login"); expect(pushMock).toHaveBeenCalledWith("/login");
}); });
it("links „Sprache & Datenschutz“ in the account menu for every user", async () => {
render(
<AppLayout>
<p>Seiteninhalt</p>
</AppLayout>,
);
await userEvent.click(screen.getByRole("button", { name: "Konto-Menü" }));
expect(await screen.findByRole("menuitem", { name: "Sprache & Datenschutz" })).toHaveAttribute(
"href",
"/settings/voice",
);
});
it("gives the band the page content wrapper as a stable focus target", () => { it("gives the band the page content wrapper as a stable focus target", () => {
render( render(
<AppLayout> <AppLayout>
@@ -116,6 +116,9 @@ export default function AppLayout({ children }: { children: ReactNode }) {
<DropdownMenuItem render={<Link href="/profile" />}> <DropdownMenuItem render={<Link href="/profile" />}>
Profil Profil
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem render={<Link href="/settings/voice" />}>
Sprache &amp; Datenschutz
</DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
variant="destructive" variant="destructive"
onClick={() => setIsLogoutOpen(true)} onClick={() => setIsLogoutOpen(true)}
@@ -15,6 +15,7 @@ import {
storedPack, storedPack,
} from "@/lib/apiaries/offline-pack.fixtures"; } from "@/lib/apiaries/offline-pack.fixtures";
import { stepFixture } from "@/lib/autopilot/step-fixtures"; import { stepFixture } from "@/lib/autopilot/step-fixtures";
import { setupView, systemDto } from "@/components/hive/test-fixtures";
import OfflineColonyPage from "./page"; import OfflineColonyPage from "./page";
let searchString = "id=colony-1"; let searchString = "id=colony-1";
@@ -262,4 +263,39 @@ describe("OfflineColonyPage", () => {
expect(await screen.findByText("Geladen vor 5 Minuten")).toBeInTheDocument(); expect(await screen.findByText("Geladen vor 5 Minuten")).toBeInTheDocument();
expect(screen.queryByText(/bei Netz neu laden/)).not.toBeInTheDocument(); expect(screen.queryByText(/bei Netz neu laden/)).not.toBeInTheDocument();
}); });
describe("hive", () => {
it("shows the hive read-only as SVG and outline from the pack", async () => {
await putPacks(
storedPack({
...fullPack(),
hives: { "colony-1": setupView(undefined, "colony-1") },
hiveCatalog: [systemDto()],
}),
);
renderPage();
const section = (await screen.findByRole("heading", { name: "Beute" })).closest(
"section, [role=region]",
) as HTMLElement;
expect(await within(section).findByRole("img")).toBeInTheDocument();
expect(within(section).getByRole("tree")).toBeInTheDocument();
expect(within(section).queryByRole("button", { name: /Übernehmen|Bearbeiten|Umbauen/ })).not.toBeInTheDocument();
});
it("says there is no hive yet for a colony the pack knows has none", async () => {
await putPacks(storedPack({ ...fullPack(), hives: {}, hiveCatalog: [systemDto()] }));
renderPage();
expect(await screen.findByText("Noch keine Beute angelegt")).toBeInTheDocument();
});
it("shows no hive section for a pack from before hives", async () => {
await putPacks(storedPack(fullPack()));
renderPage();
await screen.findByRole("heading", { name: "Volk" });
expect(screen.queryByRole("heading", { name: "Beute" })).not.toBeInTheDocument();
});
});
}); });
@@ -25,6 +25,7 @@ import { todayLocalIsoDate } from "@/lib/dates";
import type { StoredPack } from "@/lib/offline/db"; import type { StoredPack } from "@/lib/offline/db";
import { QUEEN_STATUS_LABELS } from "@/lib/queens/labels"; import { QUEEN_STATUS_LABELS } from "@/lib/queens/labels";
import { NextStepsView } from "@/components/autopilot/next-steps-card"; import { NextStepsView } from "@/components/autopilot/next-steps-card";
import { OfflineHive } from "@/components/hive/offline-hive-section";
import { StatusLightBadge } from "@/components/colonies/status-light-badge"; import { StatusLightBadge } from "@/components/colonies/status-light-badge";
import { DetailHeader, DetailStatus } from "@/components/detail-header"; import { DetailHeader, DetailStatus } from "@/components/detail-header";
import { PackAgeNote } from "@/components/offline/load-pack-button"; import { PackAgeNote } from "@/components/offline/load-pack-button";
@@ -162,6 +163,16 @@ function OfflineColonyView({ stored, colony, readAt, returnHref }: OfflineColony
/> />
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-2"> <div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-2">
<ColonyFacts colony={colony} /> <ColonyFacts colony={colony} />
{/* A pack from before hives says nothing about them: no section then. */}
{pack.hives ? (
<OfflineSection title="Beute">
<OfflineHive
colonyId={colony.id}
setup={pack.hives[colony.id] ?? null}
systems={pack.hiveCatalog ?? []}
/>
</OfflineSection>
) : null}
{pack.queens !== null ? <QueenFacts queen={queen} /> : null} {pack.queens !== null ? <QueenFacts queen={queen} /> : null}
{pack.inspections !== null ? ( {pack.inspections !== null ? (
<LastInspections inspections={pack.inspections} colonyId={colony.id} /> <LastInspections inspections={pack.inspections} colonyId={colony.id} />
@@ -3,8 +3,10 @@ import { render, screen } from "@testing-library/react";
import SettingsLayout from "./layout"; import SettingsLayout from "./layout";
const replaceMock = vi.fn(); const replaceMock = vi.fn();
let mockPathname = "/settings/system";
vi.mock("next/navigation", () => ({ vi.mock("next/navigation", () => ({
useRouter: () => ({ replace: replaceMock }), useRouter: () => ({ replace: replaceMock }),
usePathname: () => mockPathname,
})); }));
let mockUser: { permissions: string[] } | null = null; let mockUser: { permissions: string[] } | null = null;
@@ -16,6 +18,7 @@ describe("SettingsLayout", () => {
beforeEach(() => { beforeEach(() => {
replaceMock.mockReset(); replaceMock.mockReset();
mockUser = null; mockUser = null;
mockPathname = "/settings/system";
}); });
it("renders children when the user has a settings-area permission", () => { it("renders children when the user has a settings-area permission", () => {
@@ -67,4 +70,32 @@ describe("SettingsLayout", () => {
expect(screen.queryByText("Einstellungen-Inhalt")).not.toBeInTheDocument(); expect(screen.queryByText("Einstellungen-Inhalt")).not.toBeInTheDocument();
expect(replaceMock).toHaveBeenCalledWith("/dashboard"); expect(replaceMock).toHaveBeenCalledWith("/dashboard");
}); });
it("lets every signed-in user read the voice and privacy page", () => {
mockUser = { permissions: [] };
mockPathname = "/settings/voice";
render(
<SettingsLayout>
<p>Einstellungen-Inhalt</p>
</SettingsLayout>,
);
expect(screen.getByText("Einstellungen-Inhalt")).toBeInTheDocument();
expect(replaceMock).not.toHaveBeenCalled();
});
it("does not open other settings pages through the voice exception", () => {
mockUser = { permissions: [] };
mockPathname = "/settings/voice-admin";
render(
<SettingsLayout>
<p>Einstellungen-Inhalt</p>
</SettingsLayout>,
);
expect(screen.queryByText("Einstellungen-Inhalt")).not.toBeInTheDocument();
expect(replaceMock).toHaveBeenCalledWith("/dashboard");
});
}); });
@@ -1,7 +1,7 @@
"use client"; "use client";
import { ReactNode, useEffect } from "react"; import { ReactNode, useEffect } from "react";
import { useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { useAuth } from "@/lib/auth/auth-context"; import { useAuth } from "@/lib/auth/auth-context";
const SETTINGS_AREA_PERMISSIONS = [ const SETTINGS_AREA_PERMISSIONS = [
@@ -23,12 +23,19 @@ const SETTINGS_AREA_PERMISSIONS = [
"system:read", "system:read",
]; ];
/** Pages under /settings that every signed-in user may open (no admin permission). */
const OPEN_SETTINGS_PATHS = ["/settings/voice"];
export default function SettingsLayout({ children }: { children: ReactNode }) { export default function SettingsLayout({ children }: { children: ReactNode }) {
const router = useRouter(); const router = useRouter();
const pathname = usePathname();
const { user } = useAuth(); const { user } = useAuth();
const hasSettingsAccess = SETTINGS_AREA_PERMISSIONS.some((permission) => const isOpenPage = OPEN_SETTINGS_PATHS.some(
user?.permissions.includes(permission), (path) => pathname === path || pathname.startsWith(`${path}/`),
); );
const hasSettingsAccess =
(isOpenPage && Boolean(user)) ||
SETTINGS_AREA_PERMISSIONS.some((permission) => user?.permissions.includes(permission));
useEffect(() => { useEffect(() => {
if (!hasSettingsAccess) { if (!hasSettingsAccess) {
@@ -0,0 +1,125 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import axe from "axe-core";
import VoiceSettingsPage from "./page";
import { getCloudRecognitionConsent } from "@/lib/voice/consent";
import { fetchSiteSettings } from "@/lib/site-settings/api";
import type { SiteSettings } from "@/lib/site-settings/types";
let permissions = new Set<string>();
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({ hasPermission: (permission: string) => permissions.has(permission) }),
}));
vi.mock("@/lib/site-settings/api", () => ({ fetchSiteSettings: vi.fn() }));
function settings(overrides: Partial<SiteSettings> = {}): SiteSettings {
return {
id: "s-1",
youtubeUrl: null,
instagramUrl: null,
tiktokUrl: null,
websiteUrl: null,
holderName: "Imkerei Muster",
holderAddress: "Honigweg 1, 12345 Bienenstadt",
foodBusinessName: null,
foodBusinessAddress: null,
originCountry: "DE",
updatedAt: "2026-10-16T00:00:00.000Z",
...overrides,
};
}
function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<VoiceSettingsPage />
</QueryClientProvider>,
);
}
describe("VoiceSettingsPage", () => {
beforeEach(() => {
window.localStorage.clear();
permissions = new Set();
vi.mocked(fetchSiteSettings).mockReset().mockResolvedValue(settings());
});
it("the cloud switch is off by default and stores the consent", async () => {
const user = userEvent.setup();
renderPage();
const toggle = screen.getByRole("switch", { name: /Browser-Cloud-Erkennung erlauben/ });
expect(toggle).not.toBeChecked();
expect(getCloudRecognitionConsent()).toBe(false);
await user.click(toggle);
expect(toggle).toBeChecked();
expect(getCloudRecognitionConsent()).toBe(true);
expect(screen.getByRole("status")).toHaveTextContent("Erlaubt");
await user.click(toggle);
expect(getCloudRecognitionConsent()).toBe(false);
expect(screen.getByRole("status")).toHaveTextContent("Aus");
});
it("shows a stored consent as switched on", async () => {
window.localStorage.setItem("apicore.voice.cloud-consent", "1");
renderPage();
await waitFor(() =>
expect(screen.getByRole("switch", { name: /Browser-Cloud-Erkennung/ })).toBeChecked(),
);
});
it("names the retention periods in words", () => {
renderPage();
const text = document.body.textContent ?? "";
expect(text).toContain("24 Stunden");
expect(text).toContain("7 Tage");
expect(text).toContain("90 Tage");
expect(text).toContain("Anthropic");
expect(text).toContain("Google");
expect(text).toContain("Apple");
expect(text).toContain("im Cluster");
});
it("shows the holder name and address from the site settings", async () => {
permissions = new Set(["site-settings:read"]);
renderPage();
expect(await screen.findByText("Imkerei Muster")).toBeInTheDocument();
expect(screen.getByText("Honigweg 1, 12345 Bienenstadt")).toBeInTheDocument();
});
it("keeps a placeholder without the permission or while nothing is stored", async () => {
renderPage();
expect(fetchSiteSettings).not.toHaveBeenCalled();
expect(screen.getAllByText(/Noch nicht hinterlegt/)).toHaveLength(2);
permissions = new Set(["site-settings:read"]);
vi.mocked(fetchSiteSettings).mockResolvedValue(
settings({ holderName: null, holderAddress: null }),
);
renderPage();
await waitFor(() => expect(fetchSiteSettings).toHaveBeenCalled());
expect(screen.getAllByText(/Noch nicht hinterlegt/)).toHaveLength(4);
});
it("axe finds nothing", async () => {
permissions = new Set(["site-settings:read"]);
const { container } = renderPage();
await screen.findByText("Imkerei Muster");
// jsdom has no layout or colours: the contrast rule cannot run here.
const results = await axe.run(container, { rules: { "color-contrast": { enabled: false } } });
expect(results.violations).toEqual([]);
});
});
@@ -0,0 +1,115 @@
"use client";
import { useSyncExternalStore } from "react";
import { useQuery } from "@tanstack/react-query";
import { ShieldCheck } from "lucide-react";
import { useAuth } from "@/lib/auth/auth-context";
import { fetchSiteSettings } from "@/lib/site-settings/api";
import {
getCloudRecognitionConsent,
setCloudRecognitionConsent,
subscribeCloudRecognitionConsent,
} from "@/lib/voice/consent";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import {
RECORDING_NOTICE,
VOICE_PRIVACY_SECTIONS,
} from "./voice-privacy-text";
const NOT_STORED = "Noch nicht hinterlegt – der Inhaber trägt das unter Verwaltung ein.";
function CloudConsentCard() {
// localStorage does not exist on the server: it renders "off" and the client corrects it.
const allowed = useSyncExternalStore(
subscribeCloudRecognitionConsent,
getCloudRecognitionConsent,
() => false,
);
function toggle(next: boolean) {
setCloudRecognitionConsent(next);
}
return (
<Card>
<CardContent className="flex flex-col gap-3">
<h2 className="font-heading text-lg font-semibold">Browser-Cloud-Erkennung</h2>
<label
htmlFor="voice-cloud-consent"
className="flex min-h-14 cursor-pointer items-center justify-between gap-4 rounded-xl border border-input px-4 py-2"
>
<span className="text-sm font-medium">Browser-Cloud-Erkennung erlauben</span>
<input
id="voice-cloud-consent"
type="checkbox"
role="switch"
className="size-6 shrink-0 accent-[var(--primary)]"
checked={allowed}
onChange={(event) => toggle(event.target.checked)}
/>
</label>
<p role="status" className="text-sm font-medium">
{allowed ? "Erlaubt" : "Aus"}
</p>
<p className="text-sm text-muted-foreground">
Ohne Erlaubnis diktierst du nur, wenn dein Gerät die Sprache selbst erkennt. Du kannst
die Erlaubnis jederzeit hier zurücknehmen.
</p>
</CardContent>
</Card>
);
}
function HolderCard() {
const { hasPermission } = useAuth();
const canRead = hasPermission("site-settings:read");
const { data } = useQuery({
queryKey: ["site-settings"],
queryFn: fetchSiteSettings,
enabled: canRead,
});
return (
<Card>
<CardContent className="flex flex-col gap-1">
<h2 className="font-heading text-lg font-semibold">Verantwortlich</h2>
<p className="text-sm text-muted-foreground">Name und Anschrift:</p>
<p className="text-sm font-medium">{data?.holderName || NOT_STORED}</p>
<p className="text-sm font-medium">{data?.holderAddress || NOT_STORED}</p>
</CardContent>
</Card>
);
}
export default function VoiceSettingsPage() {
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={ShieldCheck}
title="Sprache & Datenschutz"
description="Was mit deiner Stimme und dem Text passiert."
/>
<CloudConsentCard />
{VOICE_PRIVACY_SECTIONS.map((section) => (
<Card key={section.id}>
<CardContent className="flex flex-col gap-2">
<h2 className="font-heading text-lg font-semibold">{section.title}</h2>
{section.paragraphs.map((paragraph) => (
<p key={paragraph} className="text-sm">
{paragraph}
</p>
))}
</CardContent>
</Card>
))}
<Card>
<CardContent className="flex flex-col gap-2">
<h2 className="font-heading text-lg font-semibold">Vor der ersten Aufnahme</h2>
<p className="text-sm">{RECORDING_NOTICE}</p>
</CardContent>
</Card>
<HolderCard />
</main>
);
}
@@ -0,0 +1,58 @@
/**
* What the app does with voice and text, in plain German (T7 spec 8.4 and 9.5).
* Kept apart from the page so the wording can be checked and changed in one
* place. Retention periods are spelled out; the server enforces them in SQL.
*/
export const VOICE_RETENTION = {
audio: "24 Stunden",
content: "7 Tage",
record: "90 Tage",
} as const;
export interface PrivacySection {
id: string;
title: string;
paragraphs: readonly string[];
}
/** Shown once before the first recording and kept here for reading later. */
export const RECORDING_NOTICE =
"Sprich nur selbst und nenne keine Namen anderer Personen. Der Ton bleibt in deinem Cluster und wird nach dem Abschreiben gelöscht. Der Text geht zur Auswertung an Anthropic.";
export const VOICE_PRIVACY_SECTIONS: readonly PrivacySection[] = [
{
id: "dictation",
title: "Diktat in den Textfeldern",
paragraphs: [
"Dein Browser wandelt deine Sprache in Text um. Die App speichert dabei keinen Ton.",
"Wenn dein Gerät das kann, läuft die Erkennung auf dem Gerät. Dann verlässt kein Ton das Gerät.",
"Wenn nicht, schickt der Browser den Ton an den Hersteller: bei Chrome an Google, bei Safari an Apple. Das passiert nur, wenn du es unten erlaubst.",
],
},
{
id: "notes",
title: "Sprachnotizen (in Vorbereitung)",
paragraphs: [
`Der Ton bleibt in deinem Cluster. Er wird nach dem Abschreiben gelöscht, spätestens nach ${VOICE_RETENTION.audio}.`,
`Der abgeschriebene Text geht zur Auswertung an Anthropic. Er bleibt, bis du ihn übernimmst oder verwirfst, höchstens ${VOICE_RETENTION.content}.`,
`Angaben wie Status und Zähler bleiben ${VOICE_RETENTION.record}. Danach löscht das System die Zeile.`,
],
},
{
id: "where",
title: "Wohin geht was",
paragraphs: [
"Ton von Sprachnotizen: bleibt im Cluster.",
"Text zur Auswertung: geht an Anthropic. Die Verarbeitung findet voraussichtlich in den USA statt.",
"Browser-Diktat mit Cloud-Erkennung: geht an Google oder Apple.",
],
},
{
id: "limits",
title: "Grenzen",
paragraphs: [
"Es spricht eine Person. Sprich nur selbst.",
"In den täglichen Sicherungen liegen gelöschte Einträge noch einige Tage verschlüsselt, bis die alte Sicherung ersetzt wird.",
],
},
];
@@ -0,0 +1,254 @@
"use client";
import { useId, useState } from "react";
import type { ReactNode } from "react";
import { ChoiceGroup } from "@/components/form/choice-group";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { feederRange, freeFeederStarts, rangeText } from "@/lib/hive/feeder-slots";
import { PART_KIND_LABELS } from "@/lib/hive/labels";
import { partDisplayName } from "@/lib/hive/names";
import { hiveReasonText } from "@/lib/hive/reason-text";
import { newIdempotencyKey } from "@/lib/offline/idempotency";
import type {
CatalogPartType,
HiveCatalog,
HiveFrame,
HiveOp,
HivePart,
HivePartKind,
HiveState,
} from "@/lib/hive/types";
import { frameFeederOps } from "../frame-feeder-picker";
import { areDraftFieldsValid, draftOf, draftValue, StateFieldControl } from "../state-field-control";
import type { FieldDraft } from "../state-field-control";
import type { DraftApplyResult } from "./use-hive-draft";
/** Host kind per insert kind (mirrors INSERT_KINDS of the validation). */
const INSERT_HOST_KIND: Partial<Record<HivePartKind, HivePartKind>> = {
FLOOR_INSERT: "FLOOR",
EVAPORATOR: "BOX",
FRAME_FEEDER: "BOX",
};
/** Inserts that a host can hold only once. */
const SINGLE_INSERTS: readonly HivePartKind[] = ["FLOOR_INSERT", "EVAPORATOR"];
const SELECT_CLASS =
"h-11 w-full rounded-xl border border-input bg-card px-3 text-base shadow-xs outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 md:text-sm";
export interface AddPartDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
type: CatalogPartType | null;
state: HiveState;
catalog: HiveCatalog;
/** Takes the ops as one draft step; a refusal is shown inline and the dialog stays open. */
onAdd: (ops: HiveOp[]) => DraftApplyResult;
}
/** „Hinzufügen …“ (spec 9.5): the keyboard and screen reader way to place a part. */
export function AddPartDialog({ open, onOpenChange, type, state, catalog, onAdd }: AddPartDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
{type ? (
<AddPartForm
key={type.id}
type={type}
state={state}
catalog={catalog}
onAdd={onAdd}
onClose={() => onOpenChange(false)}
/>
) : null}
</DialogContent>
</Dialog>
);
}
interface AddPartFormProps {
type: CatalogPartType;
state: HiveState;
catalog: HiveCatalog;
onAdd: (ops: HiveOp[]) => DraftApplyResult;
onClose: () => void;
}
function kindOf(catalog: HiveCatalog, part: HivePart): HivePartKind | undefined {
return catalog.partTypes.get(part.partTypeId)?.kind;
}
/** Stack index a new part starts at: floors at the bottom, the rest below a roof. */
function defaultPosition(type: CatalogPartType, state: HiveState, catalog: HiveCatalog): number {
const top = state.stack.length;
if (type.kind === "FLOOR") return 0;
const last = state.stack[top - 1];
return last && type.kind !== "ROOF" && kindOf(catalog, last) === "ROOF" ? top - 1 : top;
}
/** Boxes and floors that can still take this insert. */
function hostsFor(type: CatalogPartType, state: HiveState, catalog: HiveCatalog): HivePart[] {
const hostKind = INSERT_HOST_KIND[type.kind];
return state.stack.filter((part) => {
if (kindOf(catalog, part) !== hostKind) return false;
if (SINGLE_INSERTS.includes(type.kind)) {
return !(part.inserts ?? []).some((insert) => kindOf(catalog, insert) === type.kind);
}
const hostType = catalog.partTypes.get(part.partTypeId);
return hostType ? freeFeederStarts(part, hostType, type, catalog).length > 0 : false;
});
}
function Field({ label, htmlFor, children }: { label: string; htmlFor: string; children: ReactNode }) {
return (
<div className="flex flex-col gap-1.5">
<label htmlFor={htmlFor} className="text-sm font-medium">
{label}
</label>
{children}
</div>
);
}
function newBoxFrames(type: CatalogPartType): HiveFrame[] {
return Array.from({ length: type.frameSlots ?? 0 }, (_, index) => ({ slot: index + 1, type: "FOUNDATION" }));
}
function AddPartForm({ type, state, catalog, onAdd, onClose }: AddPartFormProps) {
const formId = useId();
const [newId] = useState(newIdempotencyKey);
const isInsert = type.kind in INSERT_HOST_KIND;
const hosts = isInsert ? hostsFor(type, state, catalog) : [];
const [position, setPosition] = useState(() => String(defaultPosition(type, state, catalog)));
const [hostId, setHostId] = useState(() => hosts[0]?.id ?? "");
const [start, setStart] = useState<number | null>(null);
const [draft, setDraft] = useState<FieldDraft>(() => draftOf(type, {}));
const [error, setError] = useState<string | null>(null);
const host = hosts.find((candidate) => candidate.id === hostId) ?? hosts[0];
const hostType = host ? catalog.partTypes.get(host.partTypeId) : undefined;
const starts =
type.kind === "FRAME_FEEDER" && host && hostType ? freeFeederStarts(host, hostType, type, catalog) : [];
const chosenStart = start !== null && starts.includes(start) ? start : (starts[0] ?? null);
const noPlace = isInsert && (!host || (type.kind === "FRAME_FEEDER" && chosenStart === null));
function buildOps(): HiveOp[] {
const partState: Record<string, number | string> = {};
for (const field of type.stateSchema) partState[field.key] = draftValue(field, draft[field.key] ?? "");
const fresh: HivePart = { id: newId, partTypeId: type.id, state: partState };
if (type.kind === "BOX") fresh.frames = newBoxFrames(type);
if (!isInsert) return [{ op: "addPart", part: fresh, at: Number(position) }];
if (type.kind === "FRAME_FEEDER" && host && chosenStart !== null) {
return frameFeederOps(host.id, host.frames ?? [], type, chosenStart, fresh);
}
return [{ op: "addInsert", hostId: host?.id ?? "", part: fresh }];
}
function submit() {
const outcome = onAdd(buildOps());
if (outcome.ok) onClose();
else setError(hiveReasonText(outcome.reason));
}
const positions = Array.from({ length: state.stack.length + 1 }, (_, index) => ({
value: String(index),
label: index === 0 ? "ganz unten" : `über ${partDisplayName(state, catalog, state.stack[index - 1].id)}`,
}));
const startOptions = starts.map((from) => {
const range = feederRange({ id: "", partTypeId: type.id, state: {}, slot: from }, type);
const noun = range && range.from !== range.to ? "Plätze" : "Platz";
return { value: from, label: `${noun} ${range ? rangeText(range) : from}` };
});
return (
<>
<DialogHeader>
<DialogTitle>{type.name} hinzufügen</DialogTitle>
<DialogDescription>
{PART_KIND_LABELS[type.kind]}
{isInsert ? ": Wähle, wohin das Teil kommt." : ": Wähle die Stelle im Aufbau."}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
{isInsert ? (
host ? (
<Field label="In" htmlFor={`${formId}-host`}>
<select
id={`${formId}-host`}
className={SELECT_CLASS}
value={host.id}
onChange={(event) => setHostId(event.target.value)}
>
{hosts.map((candidate) => (
<option key={candidate.id} value={candidate.id}>
{partDisplayName(state, catalog, candidate.id)}
</option>
))}
</select>
</Field>
) : (
<p className="text-sm text-muted-foreground">Dafür gibt es gerade keinen passenden Platz.</p>
)
) : (
<Field label="Position" htmlFor={`${formId}-position`}>
<select
id={`${formId}-position`}
className={SELECT_CLASS}
value={position}
onChange={(event) => setPosition(event.target.value)}
>
{positions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</Field>
)}
{type.kind === "FRAME_FEEDER" && host && chosenStart !== null ? (
<div className="flex flex-col gap-1.5">
<span id={`${formId}-slot`} className="text-sm font-medium">
Plätze in der Zarge
</span>
<ChoiceGroup
options={startOptions}
value={chosenStart}
onChange={setStart}
labelledBy={`${formId}-slot`}
className="grid-cols-3"
/>
</div>
) : null}
{type.stateSchema.map((field) => (
<StateFieldControl
key={field.key}
field={field}
idPrefix={`${formId}-new`}
value={draft[field.key] ?? ""}
onChange={(value) => setDraft({ ...draft, [field.key]: value })}
/>
))}
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
) : null}
</div>
<DialogFooter>
<Button type="button" variant="outline" size="touch" onClick={onClose}>
Abbrechen
</Button>
<Button type="button" size="touch" disabled={noPlace || !areDraftFieldsValid(type, draft)} onClick={submit}>
Hinzufügen
</Button>
</DialogFooter>
</>
);
}
@@ -0,0 +1,59 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
export interface ConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description: string;
confirmLabel: string;
cancelLabel: string;
onConfirm: () => void;
}
/** A two-button question; the safe answer (cancel) comes first and takes the initial focus order. */
export function ConfirmDialog({
open,
onOpenChange,
title,
description,
confirmLabel,
cancelLabel,
onConfirm,
}: ConfirmDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" size="touch" onClick={() => onOpenChange(false)}>
{cancelLabel}
</Button>
<Button
type="button"
variant="outline"
size="touch"
onClick={() => {
onOpenChange(false);
onConfirm();
}}
>
{confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,27 @@
import type { ReactNode } from "react";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { GLASS_BUTTON, GLASS_SURFACE } from "../hive-glass";
export interface EditorNoticeProps {
title: string;
/** Where „Zurück zum Volk“ leads. */
backHref: string;
children?: ReactNode;
}
/** Full-screen message of the editor route (offline, no right, no hive): the world stays, the way back is clear. */
export function EditorNotice({ title, backHref, children }: EditorNoticeProps) {
return (
<main className="fixed inset-0 z-40 flex items-center justify-center bg-holo-bg p-4 text-holo-label-fg">
<div className={`flex max-w-md flex-col items-center gap-4 rounded-2xl p-6 text-center ${GLASS_SURFACE}`}>
<h1 className="text-lg font-semibold">{title}</h1>
{children}
<Link href={backHref} className={GLASS_BUTTON}>
<ArrowLeft aria-hidden />
Zurück zum Volk
</Link>
</div>
</main>
);
}
@@ -0,0 +1,97 @@
"use client";
import { ArrowDown, ArrowUp, Trash2 } from "lucide-react";
import { partDisplayName } from "@/lib/hive/names";
import type { HiveCatalog, HiveOp, HivePart, HiveState } from "@/lib/hive/types";
import { GLASS_BUTTON } from "../hive-glass";
import type { DraftApplyResult } from "./use-hive-draft";
const ICON_BUTTON = `${GLASS_BUTTON} w-11 px-0 sm:px-0`;
export interface EditorStackListProps {
state: HiveState;
catalog: HiveCatalog;
selectedId: string | null;
onSelect: (id: string) => void;
onApply: (op: HiveOp) => DraftApplyResult;
}
interface RowProps {
part: HivePart;
name: string;
isSelected: boolean;
onSelect: () => void;
moveUp?: () => void;
moveDown?: () => void;
remove: () => void;
}
function Row({ part, name, isSelected, onSelect, moveUp, moveDown, remove }: RowProps) {
return (
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={onSelect}
aria-current={isSelected ? "true" : undefined}
className="min-h-11 min-w-0 flex-1 truncate rounded-lg px-2 text-left text-sm focus-visible:outline-2 focus-visible:outline-holo-edge-strong aria-[current=true]:bg-holo-edge/25 aria-[current=true]:font-semibold"
data-part-id={part.id}
>
{name}
</button>
{moveUp || moveDown ? (
<>
<button type="button" aria-label={`Nach oben: ${name}`} title="Nach oben" disabled={!moveUp} onClick={moveUp} className={ICON_BUTTON}>
<ArrowUp aria-hidden />
</button>
<button type="button" aria-label={`Nach unten: ${name}`} title="Nach unten" disabled={!moveDown} onClick={moveDown} className={ICON_BUTTON}>
<ArrowDown aria-hidden />
</button>
</>
) : null}
<button type="button" aria-label={`Entfernen: ${name}`} title="Entfernen" onClick={remove} className={ICON_BUTTON}>
<Trash2 aria-hidden />
</button>
</div>
);
}
/**
* The stack as a list from top to bottom (spec 9.5/9.6): „Nach oben“, „Nach unten“
* and „Entfernen“ per part, the way to rearrange without dragging.
*/
export function EditorStackList({ state, catalog, selectedId, onSelect, onApply }: EditorStackListProps) {
const top = state.stack.length - 1;
const rows = state.stack.map((part, index) => ({ part, index })).reverse();
return (
<ul aria-label="Aufbau" className="flex flex-col gap-1">
{rows.map(({ part, index }) => (
<li key={part.id} className="flex flex-col gap-1">
<Row
part={part}
name={partDisplayName(state, catalog, part.id)}
isSelected={selectedId === part.id}
onSelect={() => onSelect(part.id)}
moveUp={index < top ? () => onApply({ op: "movePart", id: part.id, to: index + 1 }) : undefined}
moveDown={index > 0 ? () => onApply({ op: "movePart", id: part.id, to: index - 1 }) : undefined}
remove={() => onApply({ op: "removePart", id: part.id })}
/>
{(part.inserts ?? []).length > 0 ? (
<ul className="ml-4 flex flex-col gap-1 border-l border-white/15 pl-2">
{(part.inserts ?? []).map((insert) => (
<li key={insert.id}>
<Row
part={insert}
name={partDisplayName(state, catalog, insert.id)}
isSelected={selectedId === insert.id}
onSelect={() => onSelect(insert.id)}
remove={() => onApply({ op: "removePart", id: insert.id })}
/>
</li>
))}
</ul>
) : null}
</li>
))}
</ul>
);
}
@@ -0,0 +1,87 @@
"use client";
import type { ReactNode } from "react";
import { LayoutTemplate, Redo2, Save, Trash2, Undo2 } from "lucide-react";
import { GLASS_BUTTON, GLASS_SURFACE } from "../hive-glass";
export interface EditorToolbarProps {
/** The „Zurück“ link (guarded while ops are open). */
back: ReactNode;
title: string;
canUndo: boolean;
canRedo: boolean;
onUndo: () => void;
onRedo: () => void;
date: string;
/** Berlin today; later days are not accepted. */
today: string;
onDateChange: (date: string) => void;
hasOps: boolean;
saving: boolean;
onSave: () => void;
onDiscard: () => void;
/** Only set for users with hive-systems:manage. */
onSaveTemplate?: () => void;
templateSaving?: boolean;
}
/** Top bar of the editor: back, title, undo/redo, date and the three ways to finish. */
export function EditorToolbar(props: EditorToolbarProps) {
const { date, today, hasOps, saving } = props;
const dateValid = /^\d{4}-\d{2}-\d{2}$/.test(date) && date <= today;
return (
<header className={`flex flex-wrap items-center gap-2 px-3 py-2 ${GLASS_SURFACE}`}>
{props.back}
<h1 className="mr-auto text-lg font-semibold tracking-tight">{props.title}</h1>
<button type="button" onClick={props.onUndo} disabled={!props.canUndo} className={GLASS_BUTTON} title="Strg+Z">
<Undo2 aria-hidden />
Rückgängig
</button>
<button
type="button"
onClick={props.onRedo}
disabled={!props.canRedo}
className={GLASS_BUTTON}
title="Strg+Umschalt+Z"
>
<Redo2 aria-hidden />
Wiederholen
</button>
<label className="flex items-center gap-2 text-sm">
Datum
<input
type="date"
value={date}
max={today}
required
onChange={(event) => props.onDateChange(event.target.value)}
className="h-11 rounded-xl border border-white/20 bg-holo-label-bg/70 px-2 text-sm text-holo-label-fg [color-scheme:dark]"
/>
</label>
{props.onSaveTemplate ? (
<button
type="button"
onClick={props.onSaveTemplate}
disabled={props.templateSaving}
className={GLASS_BUTTON}
>
<LayoutTemplate aria-hidden />
Als Vorlage speichern
</button>
) : null}
<button type="button" onClick={props.onDiscard} disabled={saving} className={GLASS_BUTTON}>
<Trash2 aria-hidden />
Verwerfen
</button>
<button
type="button"
onClick={props.onSave}
disabled={!hasOps || !dateValid || saving}
className={GLASS_BUTTON}
>
<Save aria-hidden />
{saving ? "Speichert …" : "Speichern"}
</button>
</header>
);
}
@@ -0,0 +1,314 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { MouseEvent } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft } from "lucide-react";
import { toast } from "sonner";
import { berlinCalendarDay } from "@/lib/dates";
import { hiveConflictOf, hiveKeys } from "@/lib/hive/api";
import { updateHiveSystem } from "@/lib/hive/catalog-api";
import { describeHiveOps } from "@/lib/hive/describe";
import { hiveLayout } from "@/lib/hive/layout";
import { partDisplayName } from "@/lib/hive/names";
import { hiveReasonText } from "@/lib/hive/reason-text";
import type { CatalogPartType, HiveCatalog, HiveOp, HiveSetupView, HiveState } from "@/lib/hive/types";
import { HiveConflictSheet } from "../hive-conflict-sheet";
import { GLASS_BUTTON, GLASS_SURFACE } from "../hive-glass";
import { HiveVisualSlot } from "../hive-visual-slot";
import { useHiveMutation } from "../use-hive-mutation";
import { createHiveViewStore, HiveViewProvider } from "../use-hive-view";
import { SAVE_FAILED_TEXT } from "../use-sheet-submit";
import { AddPartDialog } from "./add-part-dialog";
import { ConfirmDialog } from "./confirm-dialog";
import { EditorStackList } from "./editor-stack-list";
import { EditorToolbar } from "./editor-toolbar";
import { templateFromState } from "./hive-template";
import { PartPalette } from "./part-palette";
import { useHiveDraft } from "./use-hive-draft";
import type { DraftApplyResult, DraftStepInfo } from "./use-hive-draft";
export interface HiveEditorProps {
colonyId: string;
setup: HiveSetupView;
catalog: HiveCatalog;
/** hive-systems:manage: may store the build as the system template. */
canManageTemplates: boolean;
}
const noop = () => {};
const STALE_HINT =
"Die Beute wurde inzwischen geändert. Beim Speichern werden deine Schritte auf den aktuellen Stand angewendet.";
function hasPart(state: HiveState, id: string): boolean {
return state.stack.some((part) => part.id === id || (part.inserts ?? []).some((insert) => insert.id === id));
}
/** True when the key event belongs to a text control or a dialog, where Ctrl+Z means something else. */
function isTypingTarget(target: EventTarget | null): boolean {
return target instanceof Element && target.closest('input, textarea, select, [contenteditable="true"], [role="dialog"]') !== null;
}
/**
* Full-screen „Umbauen“ (spec 9.5): the same holo world as the card, with the
* parts bar and the stack list. Changes pile up in a local draft and are sent
* as one EDITOR change.
*/
export function HiveEditor(props: HiveEditorProps) {
const [store] = useState(createHiveViewStore);
return (
<HiveViewProvider store={store}>
<HiveEditorBody {...props} />
</HiveViewProvider>
);
}
function HiveEditorBody({ colonyId, setup, catalog, canManageTemplates }: HiveEditorProps) {
const router = useRouter();
const queryClient = useQueryClient();
const draft = useHiveDraft(setup.state, catalog);
const mutation = useHiveMutation(colonyId, catalog);
const backHref = `/colonies/${encodeURIComponent(colonyId)}`;
const today = berlinCalendarDay(new Date().toISOString());
const [date, setDate] = useState(today);
const [addType, setAddType] = useState<CatalogPartType | null>(null);
const [addOpen, setAddOpen] = useState(false);
const [pickedId, setPickedId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [announcement, setAnnouncement] = useState({ text: "", nonce: 0 });
const announce = useCallback(
(text: string) => setAnnouncement((current) => ({ text, nonce: current.nonce + 1 })),
[],
);
const [confirm, setConfirm] = useState<"discard" | "leave" | "template" | null>(null);
const hasOps = draft.ops.length > 0;
// A selection ends with its part (removed or undone).
const selectedId = pickedId !== null && hasPart(draft.state, pickedId) ? pickedId : null;
// Forget a vanished selection for good, so a redo does not bring it back.
if (pickedId !== null && selectedId === null) setPickedId(null);
const layout = useMemo(() => hiveLayout(draft.state, catalog), [draft.state, catalog]);
const partName = useCallback((id: string) => partDisplayName(draft.state, catalog, id), [draft.state, catalog]);
/** `inline`: the caller (the add dialog) shows a refusal itself; otherwise it lands in the banner. */
const applyStep = useCallback(
(op: HiveOp | HiveOp[], inline: boolean): DraftApplyResult => {
const ops = Array.isArray(op) ? op : [op];
const before = draft.state;
const outcome = draft.apply(op);
if (outcome.ok) {
setError(null);
announce(`${describeHiveOps(ops, before, catalog)}.`);
} else if (!inline) {
setError(hiveReasonText(outcome.reason));
}
return outcome;
},
[draft, catalog, announce],
);
const apply = useCallback((op: HiveOp | HiveOp[]) => applyStep(op, false), [applyStep]);
const applyInDialog = useCallback((ops: HiveOp[]) => applyStep(ops, true), [applyStep]);
const { undo: undoDraft, redo: redoDraft } = draft;
const describeStep = useCallback(
(prefix: string, step: DraftStepInfo) => `${prefix}: ${describeHiveOps(step.ops, step.before, catalog)}.`,
[catalog],
);
const undo = useCallback(() => {
const step = undoDraft();
if (step) announce(describeStep("Rückgängig", step));
}, [undoDraft, announce, describeStep]);
const redo = useCallback(() => {
const step = redoDraft();
if (step) announce(describeStep("Wiederholt", step));
}, [redoDraft, announce, describeStep]);
// Leaving the page (reload, close, outside link) with open changes asks first.
useEffect(() => {
if (!hasOps) return;
const warn = (event: BeforeUnloadEvent) => event.preventDefault();
window.addEventListener("beforeunload", warn);
return () => window.removeEventListener("beforeunload", warn);
}, [hasOps]);
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (!(event.ctrlKey || event.metaKey) || event.altKey || event.key.toLowerCase() !== "z") return;
if (isTypingTarget(event.target)) return;
event.preventDefault();
if (event.shiftKey) redo();
else undo();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [undo, redo]);
async function save() {
setError(null);
try {
await mutation.submit(draft.ops, "EDITOR", date);
} catch (error) {
// A conflict opens its own sheet (draft stays); anything else is reported here.
if (!hiveConflictOf(error)) setError(SAVE_FAILED_TEXT);
return;
}
draft.reset();
toast.success("Umbau gespeichert");
router.push(backHref);
}
async function resolveConflict(action: "retry" | "discard") {
const conflictVersion = mutation.conflict?.current.version ?? 0;
await mutation.resolveConflict(action);
if (action === "discard") {
draft.reset();
setError(null);
return;
}
const saved = queryClient.getQueryData<HiveSetupView>(hiveKeys.detail(colonyId));
if (action === "retry" && saved && saved.version > conflictVersion) {
draft.reset();
toast.success("Umbau gespeichert");
router.push(backHref);
}
}
const templateMutation = useMutation({
mutationFn: () => updateHiveSystem(setup.systemId, { template: templateFromState(draft.state, catalog) }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: hiveKeys.systems });
toast.success("Vorlage gespeichert");
},
onError: () => toast.error("Vorlage konnte nicht gespeichert werden."),
});
function openAdd(type: CatalogPartType) {
setAddType(type);
setAddOpen(true);
}
function onBackClick(event: MouseEvent<HTMLAnchorElement>) {
const plain = event.button === 0 && !(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey);
if (!hasOps || !plain) return;
event.preventDefault();
setConfirm("leave");
}
return (
<div className="fixed inset-0 z-40 flex flex-col gap-2 bg-holo-bg p-2 text-holo-label-fg">
<EditorToolbar
back={
<Link href={backHref} onClick={onBackClick} className={GLASS_BUTTON}>
<ArrowLeft aria-hidden />
Zurück
</Link>
}
title="Umbauen"
canUndo={draft.canUndo}
canRedo={draft.canRedo}
onUndo={undo}
onRedo={redo}
date={date}
today={today}
onDateChange={setDate}
hasOps={hasOps}
saving={mutation.isPending}
onSave={() => void save()}
onDiscard={() => (hasOps ? setConfirm("discard") : draft.reset())}
onSaveTemplate={canManageTemplates ? () => setConfirm("template") : undefined}
templateSaving={templateMutation.isPending}
/>
<div className="flex min-h-0 flex-1 flex-col gap-2 lg:flex-row">
<div className="relative min-h-[35svh] flex-1 overflow-hidden rounded-2xl">
<HiveVisualSlot
layout={layout}
state={draft.state}
catalog={catalog}
partName={partName}
onSelectPart={setPickedId}
onSelectFrame={noop}
/>
</div>
<aside
aria-label="Teile und Aufbau"
className={`flex max-h-[45svh] w-full flex-col gap-5 overflow-y-auto rounded-2xl p-3 lg:order-first lg:max-h-none lg:w-96 ${GLASS_SURFACE}`}
>
{draft.isBaseStale && !mutation.isPending ? (
<p role="note" className="rounded-lg bg-holo-edge/20 px-3 py-2 text-sm">
{STALE_HINT}
</p>
) : null}
{error ? (
<p role="alert" className="rounded-lg bg-destructive/20 px-3 py-2 text-sm">
{error}
</p>
) : null}
<section aria-labelledby="editor-parts" className="flex flex-col gap-3">
<h2 id="editor-parts" className="text-base font-semibold">
Teile
</h2>
<PartPalette catalog={catalog} onAdd={openAdd} />
</section>
<section aria-labelledby="editor-stack" className="flex flex-col gap-3">
<h2 id="editor-stack" className="text-base font-semibold">
Aufbau
</h2>
<EditorStackList
state={draft.state}
catalog={catalog}
selectedId={selectedId}
onSelect={setPickedId}
onApply={apply}
/>
</section>
</aside>
</div>
<div role="status" aria-live="polite" className="sr-only">
<span key={announcement.nonce}>{announcement.text}</span>
</div>
<AddPartDialog
open={addOpen}
onOpenChange={setAddOpen}
type={addType}
state={draft.state}
catalog={catalog}
onAdd={applyInDialog}
/>
<ConfirmDialog
open={confirm === "discard"}
onOpenChange={(open) => !open && setConfirm(null)}
title="Änderungen verwerfen?"
description="Alle Änderungen an diesem Umbau gehen verloren."
cancelLabel="Weiter umbauen"
confirmLabel="Verwerfen bestätigen"
onConfirm={() => {
draft.reset();
setError(null);
}}
/>
<ConfirmDialog
open={confirm === "leave"}
onOpenChange={(open) => !open && setConfirm(null)}
title="Umbau verlassen?"
description="Nicht gespeicherte Änderungen gehen verloren."
cancelLabel="Weiter umbauen"
confirmLabel="Verlassen"
onConfirm={() => router.push(backHref)}
/>
<ConfirmDialog
open={confirm === "template"}
onOpenChange={(open) => !open && setConfirm(null)}
title="Als Vorlage speichern?"
description="Der Aufbau ersetzt die Vorlage des Beutensystems für neue Völker. Namen und Füllstände werden nicht übernommen."
cancelLabel="Abbrechen"
confirmLabel="Vorlage ersetzen"
onConfirm={() => templateMutation.mutate()}
/>
<HiveConflictSheet conflict={mutation.conflict} catalog={catalog} onResolve={resolveConflict} />
</div>
);
}
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { frames, inlineCatalog, part } from "@/lib/hive/test-utils";
import type { HiveState } from "@/lib/hive/types";
import { templateFromState } from "./hive-template";
describe("templateFromState", () => {
it("strips ids, labels, frame names and levels but keeps the structure and choices", () => {
const state: HiveState = {
schemaVersion: 1,
stack: [
part("f", "floor", { inserts: [part("fi", "floor-insert")] }),
part("b", "box", {
label: "Brut",
frames: frames(2).map((frame) => ({ ...frame, type: "BROOD" as const, name: "Königin" })),
inserts: [part("ff", "frame-feeder", { slot: 1, state: { syrupLiters: 1.5 } })],
}),
part("fe", "feeder", { state: { syrupLiters: 3, fondantKg: 1 } }),
part("l", "inner-lid", { state: { feedHole: "CLOSED" } }),
],
};
const template = templateFromState(state, inlineCatalog());
expect(JSON.stringify(template)).not.toMatch(/"id"|label|Königin|Brut"/);
expect(template.stack[1].frames).toEqual([
{ slot: 1, type: "BROOD" },
{ slot: 2, type: "BROOD" },
]);
expect(template.stack[1].inserts?.[0]).toMatchObject({ partTypeId: "frame-feeder", slot: 1, state: { syrupLiters: 0 } });
expect(template.stack[2].state).toEqual({ syrupLiters: 0, fondantKg: 0 });
expect(template.stack[3].state).toEqual({ feedHole: "CLOSED" });
expect(state.stack[1].label).toBe("Brut");
});
});
@@ -0,0 +1,20 @@
import type { HiveCatalog, HivePart, HiveState, HiveTemplate, HiveTemplatePart } from "@/lib/hive/types";
function templatePart(part: HivePart, catalog: HiveCatalog): HiveTemplatePart {
const type = catalog.partTypes.get(part.partTypeId);
const levelKeys = new Set(type?.stateSchema.filter((field) => field.kind === "level").map((field) => field.key));
// Levels start empty on a new hive; choices (open/closed ...) stay as built.
const state = Object.fromEntries(
Object.entries(part.state).map(([key, value]) => [key, levelKeys.has(key) ? 0 : value]),
);
const copy: HiveTemplatePart = { partTypeId: part.partTypeId, state };
if (part.frames) copy.frames = part.frames.map((frame) => ({ slot: frame.slot, type: frame.type }));
if (part.slot !== undefined) copy.slot = part.slot;
if (part.inserts) copy.inserts = part.inserts.map((insert) => templatePart(insert, catalog));
return copy;
}
/** The editor's state as a system template: without ids, labels, frame names and fill levels. */
export function templateFromState(state: HiveState, catalog: HiveCatalog): HiveTemplate {
return { schemaVersion: 1, stack: state.stack.map((part) => templatePart(part, catalog)) };
}
@@ -0,0 +1,36 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { inlineCatalog } from "@/lib/hive/test-utils";
import { PartPalette } from "./part-palette";
describe("PartPalette", () => {
it("groups the active part types in the four palette groups", () => {
const catalog = inlineCatalog();
catalog.partTypes.set("inner-lid", { ...catalog.partTypes.get("inner-lid")!, active: false });
render(<PartPalette catalog={catalog} onAdd={vi.fn()} />);
const groups = screen.getAllByRole("group").map((group) => group.getAttribute("aria-label"));
expect(groups).toEqual([
"Zargen",
"Gitter und Böden",
"Fütterung und Behandlung",
"Abdeckung und Deckel",
]);
const feeding = screen.getByRole("group", { name: "Fütterung und Behandlung" });
expect(within(feeding).getAllByRole("button")).toHaveLength(3);
expect(screen.queryByRole("button", { name: /inner-lid/i })).not.toBeInTheDocument();
const cover = screen.getByRole("group", { name: "Abdeckung und Deckel" });
expect(within(cover).getAllByRole("button")).toHaveLength(3);
});
it("reports the chosen part type from „Hinzufügen …“", async () => {
const onAdd = vi.fn();
render(<PartPalette catalog={inlineCatalog()} onAdd={onAdd} />);
const zargen = screen.getByRole("group", { name: "Zargen" });
await userEvent.click(within(zargen).getByRole("button", { name: /Hinzufügen …/ }));
expect(onAdd).toHaveBeenCalledWith(expect.objectContaining({ id: "box", kind: "BOX" }));
});
});
@@ -0,0 +1,89 @@
"use client";
import { PART_KIND_LABELS } from "@/lib/hive/labels";
import type { CatalogPartType, HiveCatalog, HivePartKind } from "@/lib/hive/types";
import { GLASS_BUTTON } from "../hive-glass";
interface PaletteGroup {
label: string;
kinds: readonly HivePartKind[];
}
const GROUPS: readonly PaletteGroup[] = [
{ label: "Zargen", kinds: ["BOX"] },
{ label: "Gitter und Böden", kinds: ["FLOOR", "DIVIDER_BOARD", "QUEEN_EXCLUDER", "PROPOLIS_SCREEN", "FLOOR_INSERT"] },
{ label: "Fütterung und Behandlung", kinds: ["FEEDER", "FRAME_FEEDER", "EVAPORATOR"] },
{ label: "Abdeckung und Deckel", kinds: ["COVER_SHEET", "INNER_LID", "INSULATION", "ROOF"] },
];
const SYMBOL_WIDTH = 32;
const SYMBOL_HEIGHT = 22;
const SYMBOL_MAX_LENGTH_MM = 600;
const SYMBOL_MAX_HEIGHT_MM = 260;
/** Small side view of a part in its real proportions (the same sizes hiveLayout uses). */
function PartSymbol({ type }: { type: CatalogPartType }) {
const width = Math.max(8, (type.lengthMm / SYMBOL_MAX_LENGTH_MM) * (SYMBOL_WIDTH - 2));
const height = Math.max(3, (type.heightMm / SYMBOL_MAX_HEIGHT_MM) * (SYMBOL_HEIGHT - 2));
return (
<svg
aria-hidden
viewBox={`0 0 ${SYMBOL_WIDTH} ${SYMBOL_HEIGHT}`}
className="h-6 w-8 shrink-0 text-holo-edge"
>
<rect
x={(SYMBOL_WIDTH - width) / 2}
y={SYMBOL_HEIGHT - 1 - height}
width={width}
height={height}
rx={1.5}
fill="currentColor"
fillOpacity={0.18}
stroke="currentColor"
strokeWidth={1.2}
/>
</svg>
);
}
export interface PartPaletteProps {
catalog: HiveCatalog;
/** „Hinzufügen …“ of one part type: opens the dialog that asks for the position. */
onAdd: (type: CatalogPartType) => void;
}
/** The parts bar of the editor (spec 9.5): active part types in four groups. */
export function PartPalette({ catalog, onAdd }: PartPaletteProps) {
const active = [...catalog.partTypes.values()].filter((type) => type.active);
return (
<div className="flex flex-col gap-4">
{GROUPS.map((group) => {
const types = active
.filter((type) => group.kinds.includes(type.kind))
.sort((a, b) => group.kinds.indexOf(a.kind) - group.kinds.indexOf(b.kind) || a.name.localeCompare(b.name, "de"));
if (types.length === 0) return null;
return (
<div key={group.label} role="group" aria-label={group.label} className="flex flex-col gap-1.5">
<h3 aria-hidden className="text-xs font-medium uppercase tracking-wide text-holo-label-fg/70">
{group.label}
</h3>
<ul className="flex flex-col gap-1.5">
{types.map((type) => (
<li key={type.id} className="flex items-center gap-2">
<PartSymbol type={type} />
<span className="min-w-0 flex-1 text-sm">
<span className="block truncate">{type.name}</span>
<span className="block text-xs text-holo-label-fg/70">{PART_KIND_LABELS[type.kind]}</span>
</span>
<button type="button" onClick={() => onAdd(type)} className={GLASS_BUTTON}>
Hinzufügen … <span className="sr-only">{type.name}</span>
</button>
</li>
))}
</ul>
</div>
);
})}
</div>
);
}
@@ -0,0 +1,144 @@
import { describe, expect, it } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { frames, inlineCatalog, part } from "@/lib/hive/test-utils";
import type { HiveOp, HiveState } from "@/lib/hive/types";
import { useHiveDraft } from "./use-hive-draft";
const catalog = inlineCatalog();
const base: HiveState = {
schemaVersion: 1,
stack: [part("f", "floor"), part("b1", "box", { frames: frames(10) }), part("r", "roof")],
};
const addBox: HiveOp = { op: "addPart", at: 2, part: part("b2", "box", { frames: frames(10) }) };
describe("useHiveDraft", () => {
it("applies an op to the state without touching the base", () => {
const { result } = renderHook(() => useHiveDraft(base, catalog));
let outcome: ReturnType<typeof result.current.apply> | undefined;
act(() => {
outcome = result.current.apply(addBox);
});
expect(outcome).toEqual({ ok: true });
expect(result.current.ops).toEqual([addBox]);
expect(result.current.state.stack.map((p) => p.id)).toEqual(["f", "b1", "b2", "r"]);
expect(base.stack).toHaveLength(3);
});
it("refuses an op that breaks a rule and keeps the draft", () => {
const { result } = renderHook(() => useHiveDraft(base, catalog));
let outcome: ReturnType<typeof result.current.apply> | undefined;
act(() => {
outcome = result.current.apply({ op: "addPart", at: 1, part: part("f2", "floor") });
});
expect(outcome).toMatchObject({ ok: false });
expect(outcome && !outcome.ok && outcome.reason).toMatch(/^RULE_FLOOR_BOTTOM/);
expect(result.current.ops).toEqual([]);
expect(result.current.canUndo).toBe(false);
});
it("undoes and redoes step by step; a new op clears redo", () => {
const { result } = renderHook(() => useHiveDraft(base, catalog));
const label: HiveOp = { op: "setLabel", id: "b1", label: "Brut" };
act(() => void result.current.apply(addBox));
act(() => void result.current.apply(label));
expect(result.current.canUndo).toBe(true);
expect(result.current.canRedo).toBe(false);
act(() => result.current.undo());
expect(result.current.ops).toEqual([addBox]);
expect(result.current.canRedo).toBe(true);
act(() => result.current.undo());
expect(result.current.state).toEqual(base);
act(() => result.current.redo());
expect(result.current.ops).toEqual([addBox]);
act(() => void result.current.apply({ op: "setLabel", id: "b1", label: "Honig" }));
expect(result.current.canRedo).toBe(false);
});
it("treats a batch of ops as one undo step and applies it all or nothing", () => {
const { result } = renderHook(() => useHiveDraft(base, catalog));
const batch: HiveOp[] = [addBox, { op: "setLabel", id: "b2", label: "Neu" }];
act(() => void result.current.apply(batch));
expect(result.current.ops).toHaveLength(2);
act(() => result.current.undo());
expect(result.current.ops).toEqual([]);
let outcome: ReturnType<typeof result.current.apply> | undefined;
act(() => {
outcome = result.current.apply([addBox, { op: "removePart", id: "missing" }]);
});
expect(outcome).toMatchObject({ ok: false });
expect(result.current.ops).toEqual([]);
});
it("reset drops the draft and the history", () => {
const { result } = renderHook(() => useHiveDraft(base, catalog));
act(() => void result.current.apply(addBox));
act(() => result.current.reset());
expect(result.current.ops).toEqual([]);
expect(result.current.canUndo).toBe(false);
expect(result.current.canRedo).toBe(false);
});
it("keeps the base from the first op on and flags a changed base as stale", () => {
const { result, rerender } = renderHook(({ current }) => useHiveDraft(current, catalog), {
initialProps: { current: base },
});
expect(result.current.isBaseStale).toBe(false);
act(() => void result.current.apply(addBox));
const changed: HiveState = { ...base, stack: base.stack.filter((p) => p.id !== "b1") };
rerender({ current: changed });
expect(result.current.isBaseStale).toBe(true);
expect(result.current.state.stack.map((p) => p.id)).toEqual(["f", "b1", "b2", "r"]);
act(() => result.current.reset());
expect(result.current.isBaseStale).toBe(false);
expect(result.current.state).toBe(changed);
});
it("drops the snapshot when undo reaches zero steps and redo starts on the current base", () => {
const { result, rerender } = renderHook(({ current }) => useHiveDraft(current, catalog), {
initialProps: { current: base },
});
act(() => void result.current.apply(addBox));
const changed: HiveState = { ...base, stack: base.stack.map((p) => (p.id === "b1" ? { ...p, label: "neu" } : p)) };
rerender({ current: changed });
expect(result.current.isBaseStale).toBe(true);
act(() => void result.current.undo());
expect(result.current.isBaseStale).toBe(false);
expect(result.current.state).toBe(changed);
expect(result.current.canRedo).toBe(true);
act(() => void result.current.redo());
expect(result.current.isBaseStale).toBe(false);
expect(result.current.state.stack.map((p) => p.id)).toEqual(["f", "b1", "b2", "r"]);
expect(result.current.state.stack[1].label).toBe("neu");
act(() => result.current.undo());
act(() => void result.current.apply({ op: "setLabel", id: "b1", label: "x" }));
expect(result.current.canRedo).toBe(false);
});
it("reports the undone step with the state before it", () => {
const { result } = renderHook(() => useHiveDraft(base, catalog));
act(() => void result.current.apply(addBox));
let info: ReturnType<typeof result.current.undo> = null;
act(() => {
info = result.current.undo();
});
expect(info).toEqual({ ops: [addBox], before: base });
});
});
@@ -0,0 +1,124 @@
"use client";
import { useCallback, useMemo, useRef, useState } from "react";
import { applyHiveOps } from "@/lib/hive/ops";
import type { HiveCatalog, HiveOp, HiveState } from "@/lib/hive/types";
export type DraftApplyResult = { ok: true } | { ok: false; reason: string };
export interface DraftStepInfo {
ops: HiveOp[];
/** The state the step was applied to. */
before: HiveState;
}
export interface HiveDraft {
/** `applyHiveOps(snapshot, ops)`; the base itself until the first op. */
state: HiveState;
/** The server state changed after the draft started (the snapshot differs from the current base). */
isBaseStale: boolean;
/** All collected ops in order, ready to be sent as one change. */
ops: HiveOp[];
/** One undo step: a single op, or a batch that belongs together (all or nothing). */
apply(op: HiveOp | HiveOp[]): DraftApplyResult;
/** Returns the step that was taken back and the state before it (for announcements), or null. */
undo(): DraftStepInfo | null;
redo(): DraftStepInfo | null;
canUndo: boolean;
canRedo: boolean;
reset(): void;
}
interface History {
steps: HiveOp[][];
undone: HiveOp[][];
/** The base as it was when the first op was added; kept until reset. */
snapshot: HiveState | null;
}
const EMPTY: History = { steps: [], undone: [], snapshot: null };
/**
* Local draft of the editor (spec 9.5): ops pile up here and never touch the
* server until the caller sends `ops` in one change. Every step is checked
* against the rules before it is taken, so the draft is always valid.
*/
export function useHiveDraft(base: HiveState, catalog: HiveCatalog): HiveDraft {
const [history, setHistory] = useState<History>(EMPTY);
// Latest history for callers that apply twice before React re-renders.
const latest = useRef(history);
const commit = useCallback((next: History) => {
latest.current = next;
setHistory(next);
}, []);
const ops = useMemo(() => history.steps.flat(), [history.steps]);
// A refetch or an optimistic write must not change what the editor shows.
const baseline = history.snapshot ?? base;
const state = useMemo(() => {
if (ops.length === 0) return baseline;
const applied = applyHiveOps(baseline, ops, catalog);
return applied.ok ? applied.state : baseline;
}, [baseline, catalog, ops]);
const isBaseStale = history.snapshot !== null && JSON.stringify(history.snapshot) !== JSON.stringify(base);
const apply = useCallback(
(op: HiveOp | HiveOp[]): DraftApplyResult => {
const step = Array.isArray(op) ? op : [op];
const { steps, snapshot } = latest.current;
const start = snapshot ?? base;
const result = applyHiveOps(start, [...steps.flat(), ...step], catalog);
if (!result.ok) return { ok: false, reason: result.reason };
commit({ steps: [...steps, step], undone: [], snapshot: start });
return { ok: true };
},
[base, catalog, commit],
);
/** State after the given steps, applied to the snapshot. */
const stateAfter = useCallback(
(steps: HiveOp[][], snapshot: HiveState): HiveState => {
const applied = applyHiveOps(snapshot, steps.flat(), catalog);
return applied.ok ? applied.state : snapshot;
},
[catalog],
);
const undo = useCallback((): DraftStepInfo | null => {
const { steps, undone, snapshot } = latest.current;
if (steps.length === 0 || !snapshot) return null;
const step = steps[steps.length - 1];
const remaining = steps.slice(0, -1);
// Back at zero steps the draft is empty again: the next first op takes a fresh snapshot.
commit({ steps: remaining, undone: [...undone, step], snapshot: remaining.length === 0 ? null : snapshot });
return { ops: step, before: stateAfter(remaining, snapshot) };
}, [commit, stateAfter]);
const redo = useCallback((): DraftStepInfo | null => {
const { steps, undone, snapshot } = latest.current;
if (undone.length === 0) return null;
const step = undone[undone.length - 1];
// From zero steps the redo starts on the current base; it may no longer fit.
const start = snapshot ?? base;
if (!applyHiveOps(start, [...steps.flat(), ...step], catalog).ok) {
commit({ steps, undone: [], snapshot });
return null;
}
commit({ steps: [...steps, step], undone: undone.slice(0, -1), snapshot: start });
return { ops: step, before: stateAfter(steps, start) };
}, [base, catalog, commit, stateAfter]);
const reset = useCallback(() => commit(EMPTY), [commit]);
return {
state,
isBaseStale,
ops,
apply,
undo,
redo,
canUndo: history.steps.length > 0,
canRedo: history.undone.length > 0,
reset,
};
}
@@ -97,6 +97,19 @@ describe("FrameSheet", () => {
]); ]);
}); });
it("does not let its submit bubble into an enclosing form", async () => {
const outer = vi.fn((event: { preventDefault: () => void }) => event.preventDefault());
const user = userEvent.setup();
render(
<form onSubmit={outer}>
<Harness submit={vi.fn().mockResolvedValue(undefined)} />
</form>,
);
await user.click(screen.getByRole("radio", { name: FRAME_TYPE_LABELS.HONEY }));
await user.click(save());
expect(outer).not.toHaveBeenCalled();
});
it("sends name null when the name is emptied", async () => { it("sends name null when the name is emptied", async () => {
const submit = vi.fn().mockResolvedValue(undefined); const submit = vi.fn().mockResolvedValue(undefined);
const user = userEvent.setup(); const user = userEvent.setup();
@@ -56,7 +56,9 @@ function FrameForm({ initialType, initialName, boxId, slot, submit, onClose }: F
<form <form
className="flex flex-col gap-4 px-4 pb-4" className="flex flex-col gap-4 px-4 pb-4"
onSubmit={(event) => { onSubmit={(event) => {
// Portal forms bubble their submit through the React tree (e.g. into an enclosing form).
event.preventDefault(); event.preventDefault();
event.stopPropagation();
if (changed && !pending) void save(); if (changed && !pending) void save();
}} }}
> >
@@ -19,20 +19,32 @@ import { colonyFixture, historyItem, sampleState, setupView, systemDto } from ".
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) })); vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
vi.mock("sonner", () => ({ toast: vi.fn() })); vi.mock("sonner", () => ({ toast: vi.fn() }));
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn().mockResolvedValue([]) })); vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn().mockResolvedValue([]) }));
vi.mock("@/lib/hive/api", async (importOriginal) => ({ vi.mock("@/lib/hive/api", async (importOriginal) => {
const fetchHiveSystems = vi.fn();
return {
...(await importOriginal<typeof import("@/lib/hive/api")>()), ...(await importOriginal<typeof import("@/lib/hive/api")>()),
fetchHive: vi.fn(), fetchHive: vi.fn(),
fetchHiveSystems: vi.fn(), fetchHiveSystems,
// The offline-aware variant answers like the plain one here (no offline packs in this spec).
fetchHiveSystemsWithFallback: vi.fn(() => fetchHiveSystems()),
fetchHiveTemplate: vi.fn(), fetchHiveTemplate: vi.fn(),
fetchHiveHistory: vi.fn(), fetchHiveHistory: vi.fn(),
fetchHiveAt: vi.fn(), fetchHiveAt: vi.fn(),
postHiveChange: vi.fn(), postHiveChange: vi.fn(),
createHive: vi.fn(), createHive: vi.fn(),
};
});
const pendingHive = vi.hoisted(() => ({ ops: [] as unknown[], count: 0 }));
vi.mock("@/components/hive/use-pending-hive", async (importOriginal) => ({
...(await importOriginal<typeof import("@/components/hive/use-pending-hive")>()),
usePendingHiveOps: () => pendingHive.ops,
usePendingHiveCount: () => pendingHive.count,
})); }));
let permissions: string[] = []; let permissions: string[] = [];
vi.mock("@/lib/auth/auth-context", async (importOriginal) => ({ vi.mock("@/lib/auth/auth-context", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/auth/auth-context")>()), ...(await importOriginal<typeof import("@/lib/auth/auth-context")>()),
useAuth: () => ({ hasPermission: (key: string) => permissions.includes(key) }), useAuth: () => ({ hasPermission: (key: string) => permissions.includes(key) }),
useCurrentUserId: () => null,
})); }));
const connectivity = vi.hoisted(() => ({ value: "online" as "online" | "offline" })); const connectivity = vi.hoisted(() => ({ value: "online" as "online" | "offline" }));
vi.mock("@/lib/offline/connectivity", async (importOriginal) => ({ vi.mock("@/lib/offline/connectivity", async (importOriginal) => ({
@@ -64,6 +76,8 @@ describe("HiveCard", () => {
beforeEach(() => { beforeEach(() => {
permissions = ["colonies:read", "colonies:update"]; permissions = ["colonies:read", "colonies:update"];
connectivity.value = "online"; connectivity.value = "online";
pendingHive.ops = [];
pendingHive.count = 0;
vi.mocked(fetchHive).mockReset().mockResolvedValue(setupView()); vi.mocked(fetchHive).mockReset().mockResolvedValue(setupView());
vi.mocked(fetchHiveSystems).mockReset().mockResolvedValue([systemDto()]); vi.mocked(fetchHiveSystems).mockReset().mockResolvedValue([systemDto()]);
vi.mocked(fetchHiveTemplate).mockReset().mockResolvedValue(templateState()); vi.mocked(fetchHiveTemplate).mockReset().mockResolvedValue(templateState());
@@ -152,6 +166,14 @@ describe("HiveCard", () => {
}); });
}); });
it("shows changes that wait for network on top of the loaded hive, with a note", async () => {
pendingHive.ops = FRAME_EDIT_OPS;
pendingHive.count = 1;
renderCard();
expect(await screen.findByText(/7 Brutwaben/)).toBeInTheDocument();
expect(screen.getByText("1 Änderung wartet auf Netz")).toBeInTheDocument();
});
it("keeps the outline in the DOM but visually hidden until „Aufbau als Liste“", async () => { it("keeps the outline in the DOM but visually hidden until „Aufbau als Liste“", async () => {
renderCard(); renderCard();
const toggle = await screen.findByRole("button", { name: "Aufbau als Liste" }); const toggle = await screen.findByRole("button", { name: "Aufbau als Liste" });
@@ -239,6 +261,32 @@ describe("HiveCard", () => {
expect(screen.queryByRole("link", { name: "Umbauen" })).not.toBeInTheDocument(); expect(screen.queryByRole("link", { name: "Umbauen" })).not.toBeInTheDocument();
}); });
it("seeds the inspection draft from initialOps (after a remount)", async () => {
const onOpsChange = vi.fn();
renderCard({ mode: "inspection", onOpsChange, initialOps: FRAME_EDIT_OPS });
await screen.findByRole("img", { name: /Beute mit/ });
expect(await screen.findByText(/7 Brutwaben/)).toBeInTheDocument();
// The outline shows the seeded draft, and seeding is not reported as a new edit.
await userEvent.click(screen.getByRole("treeitem", { name: /Zarge 1/ }));
expect(screen.getByRole("treeitem", { name: /R5 · Brutwabe/ })).toBeInTheDocument();
expect(onOpsChange).not.toHaveBeenCalled();
});
it("ignores initialOps that do not apply to the loaded hive, and in view mode", async () => {
const broken: HiveOp[] = [{ op: "setFrame", boxId: "gone", slot: 5, type: "BROOD", name: null }];
const onOpsChange = vi.fn();
const first = renderCard({ mode: "inspection", initialOps: broken, onOpsChange });
await screen.findByRole("img", { name: /Beute mit/ });
expect(screen.getByText("2 Zargen · 6 Brutwaben · Futter 5 l · Bausperre drin")).toBeInTheDocument();
// The form learns that its stored ops were dropped, so its list matches the card.
expect(onOpsChange).toHaveBeenCalledWith([]);
first.unmount();
renderCard({ initialOps: FRAME_EDIT_OPS });
await screen.findByRole("img", { name: /Beute mit/ });
expect(screen.getByText("2 Zargen · 6 Brutwaben · Futter 5 l · Bausperre drin")).toBeInTheDocument();
});
it("opens the conflict sheet when the hive changed meanwhile", async () => { it("opens the conflict sheet when the hive changed meanwhile", async () => {
vi.mocked(postHiveChange).mockRejectedValue( vi.mocked(postHiveChange).mockRejectedValue(
new ApiError(409, "Konflikt", { new ApiError(409, "Konflikt", {
@@ -1,22 +1,22 @@
"use client"; "use client";
import { useId, useMemo, useRef, useState } from "react"; import { useEffect, useId, useMemo, useRef, useState } from "react";
import type { ReactNode } from "react"; import type { ReactNode, RefObject } from "react";
import Link from "next/link"; import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { Boxes, History, ListTree } from "lucide-react"; import { Boxes, History, ListTree } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardTitle } from "@/components/ui/card"; import { Card, CardTitle } from "@/components/ui/card";
import { useAuth } from "@/lib/auth/auth-context"; import { useAuth } from "@/lib/auth/auth-context";
import { isLivingColony } from "@/lib/colonies/status"; import { isLivingColony } from "@/lib/colonies/status";
import type { Colony } from "@/lib/colonies/types"; import type { Colony } from "@/lib/colonies/types";
import { fetchHive, hiveErrorCode, hiveKeys } from "@/lib/hive/api"; import { hiveErrorCode } from "@/lib/hive/api";
import { hiveReasonText } from "@/lib/hive/reason-text"; import { hiveReasonText } from "@/lib/hive/reason-text";
import { describeHiveOps } from "@/lib/hive/describe"; import { describeHiveOps } from "@/lib/hive/describe";
import { deriveHiveFacts } from "@/lib/hive/facts"; import { deriveHiveFacts } from "@/lib/hive/facts";
import { hiveLayout } from "@/lib/hive/layout"; import { hiveLayout } from "@/lib/hive/layout";
import { partDisplayName } from "@/lib/hive/names"; import { partDisplayName } from "@/lib/hive/names";
import { applyHiveOps } from "@/lib/hive/ops"; import { applyHiveOps } from "@/lib/hive/ops";
import { withPendingOps } from "@/lib/hive/pending";
import type { HiveCatalog, HiveOp, HiveSetupView, HiveState } from "@/lib/hive/types"; import type { HiveCatalog, HiveOp, HiveSetupView, HiveState } from "@/lib/hive/types";
import { useConnectivity } from "@/lib/offline/connectivity"; import { useConnectivity } from "@/lib/offline/connectivity";
import { FrameSheet } from "./frame-sheet"; import { FrameSheet } from "./frame-sheet";
@@ -27,17 +27,22 @@ import { GLASS_BUTTON, GLASS_SURFACE } from "./hive-glass";
import { HiveHistory, HiveVersionBar, useHiveHistory } from "./hive-history"; import { HiveHistory, HiveVersionBar, useHiveHistory } from "./hive-history";
import { HiveInit } from "./hive-init"; import { HiveInit } from "./hive-init";
import { HiveOutline } from "./hive-outline"; import { HiveOutline } from "./hive-outline";
import { HivePendingNote } from "./hive-pending-note";
import type { HiveOutlineHandle } from "./hive-outline"; import type { HiveOutlineHandle } from "./hive-outline";
import { HiveSheetShell } from "./hive-sheet-shell"; import { HiveSheetShell } from "./hive-sheet-shell";
import { HiveVisualSlot } from "./hive-visual-slot"; import { HiveVisualSlot } from "./hive-visual-slot";
import { PartSheet } from "./part-sheet"; import { PartSheet } from "./part-sheet";
import { useHiveMutation } from "./use-hive-mutation"; import { useHiveMutation } from "./use-hive-mutation";
import { useHiveSetup } from "./use-hive-setup";
import { usePendingHiveOps } from "./use-pending-hive";
import { useHiveSystems, useSystemCatalog } from "./use-hive-systems"; import { useHiveSystems, useSystemCatalog } from "./use-hive-systems";
import { createHiveViewStore, focusedIdOf, HiveViewProvider, useHiveView } from "./use-hive-view"; import { createHiveViewStore, focusedIdOf, HiveViewProvider, useHiveView } from "./use-hive-view";
import { HiveDraftError } from "./use-sheet-submit"; import { HiveDraftError } from "./use-sheet-submit";
import type { SubmitOps } from "./use-sheet-submit"; import type { SubmitOps } from "./use-sheet-submit";
const LOAD_FAILED_TEXT = "Beute konnte nicht geladen werden."; const LOAD_FAILED_TEXT = "Beute konnte nicht geladen werden.";
/** The pending note inside the frosted panels (light text on the stage). */
const PENDING_NOTE_CLASS = "mt-1 flex items-center gap-1.5 text-xs text-holo-label-fg/90";
const OFFLINE_REBUILD_HINT = "Umbauen geht nur mit Netz"; const OFFLINE_REBUILD_HINT = "Umbauen geht nur mit Netz";
export interface HiveCardProps { export interface HiveCardProps {
@@ -46,6 +51,12 @@ export interface HiveCardProps {
/** "inspection" keeps a local draft and reports the ops instead of saving (inspection form). */ /** "inspection" keeps a local draft and reports the ops instead of saving (inspection form). */
mode?: "view" | "inspection"; mode?: "view" | "inspection";
onOpsChange?: (ops: HiveOp[]) => void; onOpsChange?: (ops: HiveOp[]) => void;
/**
* Inspection mode only: ops of an earlier draft to start from (the inspection
* form remounts the card after „Rückgängig“). Ignored unless they apply to the
* loaded hive; ignored in view mode.
*/
initialOps?: HiveOp[];
} }
/** /**
@@ -75,11 +86,17 @@ function PlainCardBody({ children }: { children: ReactNode }) {
); );
} }
function HiveCardContent({ colonyId, colony, mode = "view", onOpsChange }: HiveCardProps) { function HiveCardContent({ colonyId, colony, mode = "view", onOpsChange, initialOps }: HiveCardProps) {
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
const hive = useQuery({ queryKey: hiveKeys.detail(colonyId), queryFn: () => fetchHive(colonyId) }); // Offline the colony's pack answers; changes still waiting for network are shown on top (spec 10).
const hive = useHiveSetup(colonyId);
const systems = useHiveSystems(); const systems = useHiveSystems();
const catalog = useSystemCatalog(systems.data, hive.data?.systemId); const catalog = useSystemCatalog(systems.data, hive.data?.systemId);
const pendingKey = JSON.stringify(usePendingHiveOps(colonyId));
const setup = useMemo(
() => (hive.data && catalog ? withPendingOps(hive.data, JSON.parse(pendingKey) as HiveOp[], catalog) : undefined),
[hive.data, catalog, pendingKey],
);
const canEdit = hasPermission("colonies:update") && isLivingColony(colony); const canEdit = hasPermission("colonies:update") && isLivingColony(colony);
if (hiveErrorCode(hive.error) === "HIVE_NOT_FOUND") { if (hiveErrorCode(hive.error) === "HIVE_NOT_FOUND") {
@@ -109,7 +126,7 @@ function HiveCardContent({ colonyId, colony, mode = "view", onOpsChange }: HiveC
</PlainCardBody> </PlainCardBody>
); );
} }
if (!hive.data || !catalog) { if (!setup || !catalog) {
return ( return (
<div <div
role="status" role="status"
@@ -121,15 +138,43 @@ function HiveCardContent({ colonyId, colony, mode = "view", onOpsChange }: HiveC
return ( return (
<HiveCardBody <HiveCardBody
colonyId={colonyId} colonyId={colonyId}
setup={hive.data} setup={setup}
catalog={catalog} catalog={catalog}
canEdit={canEdit} canEdit={canEdit}
mode={mode} mode={mode}
onOpsChange={onOpsChange} onOpsChange={onOpsChange}
initialOps={initialOps}
/> />
); );
} }
/** Top: title panel or focus bar (placed 12 px from the top). Bottom: the button column (8 px padding). */
const TOP_OVERLAY_OFFSET_PX = 12;
const BOTTOM_OVERLAY_OFFSET_PX = 8;
/** Heights of the overlays over the stage, so the 3D camera keeps the model clear of them. */
function useOverlayInsets(top: RefObject<HTMLElement | null>, bottom: RefObject<HTMLElement | null>) {
const [insets, setInsets] = useState({ top: 0, bottom: 0 });
useEffect(() => {
const topElement = top.current;
const bottomElement = bottom.current;
if (!topElement || !bottomElement || typeof ResizeObserver === "undefined") return;
const measure = () => {
const next = {
top: Math.round(topElement.offsetHeight + TOP_OVERLAY_OFFSET_PX),
bottom: Math.round(bottomElement.offsetHeight + BOTTOM_OVERLAY_OFFSET_PX),
};
setInsets((current) => (current.top === next.top && current.bottom === next.bottom ? current : next));
};
const observer = new ResizeObserver(measure);
observer.observe(topElement);
observer.observe(bottomElement);
measure();
return () => observer.disconnect();
}, [top, bottom]);
return insets;
}
interface FrameTarget { interface FrameTarget {
boxId: string; boxId: string;
slot: number; slot: number;
@@ -161,9 +206,16 @@ interface HiveCardBodyProps {
canEdit: boolean; canEdit: boolean;
mode: "view" | "inspection"; mode: "view" | "inspection";
onOpsChange?: (ops: HiveOp[]) => void; onOpsChange?: (ops: HiveOp[]) => void;
initialOps?: HiveOp[];
} }
function HiveCardBody({ colonyId, setup, catalog, canEdit, mode, onOpsChange }: HiveCardBodyProps) { /** The seed of the inspection draft: the given ops when they apply to the loaded hive, else none. */
function initialDraft(mode: "view" | "inspection", setup: HiveSetupView, catalog: HiveCatalog, ops?: HiveOp[]): HiveOp[] {
if (mode !== "inspection" || !ops || ops.length === 0) return [];
return applyHiveOps(setup.state, ops, catalog).ok ? ops : [];
}
function HiveCardBody({ colonyId, setup, catalog, canEdit, mode, onOpsChange, initialOps }: HiveCardBodyProps) {
const view = useHiveView(); const view = useHiveView();
const connectivity = useConnectivity(); const connectivity = useConnectivity();
const mutation = useHiveMutation(colonyId, catalog); const mutation = useHiveMutation(colonyId, catalog);
@@ -171,8 +223,18 @@ function HiveCardBody({ colonyId, setup, catalog, canEdit, mode, onOpsChange }:
const outlineRef = useRef<HiveOutlineHandle>(null); const outlineRef = useRef<HiveOutlineHandle>(null);
const historyButtonRef = useRef<HTMLButtonElement>(null); const historyButtonRef = useRef<HTMLButtonElement>(null);
const outlineId = useId(); const outlineId = useId();
const topOverlayRef = useRef<HTMLDivElement>(null);
const bottomOverlayRef = useRef<HTMLDivElement>(null);
const insets = useOverlayInsets(topOverlayRef, bottomOverlayRef);
const [draftOps, setDraftOps] = useState<HiveOp[]>([]); const [draftOps, setDraftOps] = useState<HiveOp[]>(() => initialDraft(mode, setup, catalog, initialOps));
// Seed ops that no longer apply were dropped: tell the form once, so its list matches the card.
const [seedDropped] = useState(() => mode === "inspection" && (initialOps?.length ?? 0) > 0 && draftOps.length === 0);
useEffect(() => {
if (seedDropped) onOpsChange?.([]);
// Once on mount: the form remounts the card when it changes the seed.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [seedDropped]);
const [past, setPast] = useState<PastState | null>(null); const [past, setPast] = useState<PastState | null>(null);
const [showOutline, setShowOutline] = useState(false); const [showOutline, setShowOutline] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false); const [historyOpen, setHistoryOpen] = useState(false);
@@ -256,8 +318,10 @@ function HiveCardBody({ colonyId, setup, catalog, canEdit, mode, onOpsChange }:
partName={partName} partName={partName}
onSelectPart={selectPart} onSelectPart={selectPart}
onSelectFrame={editFrame} onSelectFrame={editFrame}
insets={insets}
/> />
</div> </div>
<div ref={topOverlayRef} className="pointer-events-none absolute inset-x-3 top-3">
{focusedId ? ( {focusedId ? (
<HiveFocusBar <HiveFocusBar
focusedId={focusedId} focusedId={focusedId}
@@ -268,17 +332,17 @@ function HiveCardBody({ colonyId, setup, catalog, canEdit, mode, onOpsChange }:
onBack={leaveFocus} onBack={leaveFocus}
/> />
) : ( ) : (
<div <div className={`w-fit max-w-[min(26rem,100%)] rounded-2xl px-4 py-2.5 sm:py-3 ${GLASS_SURFACE}`}>
className={`pointer-events-none absolute start-3 top-3 max-w-[min(26rem,calc(100%-1.5rem))] rounded-2xl px-4 py-3 ${GLASS_SURFACE}`} <CardTitle className="text-lg font-semibold tracking-tight sm:text-xl">Beute</CardTitle>
>
<CardTitle className="text-xl font-semibold tracking-tight">Beute</CardTitle>
<HiveFactsLine facts={facts} lastChange={past ? null : lastChange} tone="stage" /> <HiveFactsLine facts={facts} lastChange={past ? null : lastChange} tone="stage" />
<HivePendingNote colonyId={colonyId} className={PENDING_NOTE_CLASS} />
</div> </div>
)} )}
</div>
{/* One column for the outline panel and the button bar: the panel always ends above the {/* One column for the outline panel and the button bar: the panel always ends above the
buttons, however they wrap. DOM order keeps the buttons before the tree (tab order). */} buttons, however they wrap. DOM order keeps the buttons before the tree (tab order). */}
<div className="pointer-events-none absolute inset-0 flex flex-col gap-2 p-2"> <div className="pointer-events-none absolute inset-0 flex flex-col gap-2 p-2">
<div className="order-2 flex flex-col items-center gap-2 p-1"> <div ref={bottomOverlayRef} className="order-2 flex flex-col items-center gap-2 p-1">
{past ? ( {past ? (
<div className="pointer-events-auto"> <div className="pointer-events-auto">
<HiveVersionBar <HiveVersionBar
@@ -295,6 +359,7 @@ function HiveCardBody({ colonyId, setup, catalog, canEdit, mode, onOpsChange }:
// Phones keep the room for the part; the focus chips carry its state there. // Phones keep the room for the part; the focus chips carry its state there.
<div className={`hidden max-w-full rounded-xl px-3 py-1.5 text-center sm:block ${GLASS_SURFACE}`}> <div className={`hidden max-w-full rounded-xl px-3 py-1.5 text-center sm:block ${GLASS_SURFACE}`}>
<HiveFactsLine facts={facts} lastChange={past ? null : lastChange} tone="stage" /> <HiveFactsLine facts={facts} lastChange={past ? null : lastChange} tone="stage" />
<HivePendingNote colonyId={colonyId} className={`justify-center ${PENDING_NOTE_CLASS}`} />
</div> </div>
) : null} ) : null}
<div className="pointer-events-auto flex max-w-full flex-wrap justify-center gap-1.5 sm:gap-2"> <div className="pointer-events-auto flex max-w-full flex-wrap justify-center gap-1.5 sm:gap-2">
@@ -79,7 +79,7 @@ export function HiveFocusBar({ focusedId, name, chips, canEdit, onEdit, onBack }
return ( return (
<div <div
key={focusedId} key={focusedId}
className="pointer-events-none absolute inset-x-3 top-3 flex flex-col items-start gap-2 duration-150 animate-in fade-in-0 motion-full:duration-300 motion-full:slide-in-from-top-2" className="pointer-events-none flex flex-col items-start gap-2 duration-150 animate-in fade-in-0 motion-full:duration-300 motion-full:slide-in-from-top-2"
> >
<div className="flex max-w-full items-center gap-3"> <div className="flex max-w-full items-center gap-3">
<button type="button" onClick={onBack} className={`pointer-events-auto ${GLASS_PILL}`}> <button type="button" onClick={onBack} className={`pointer-events-auto ${GLASS_PILL}`}>
@@ -0,0 +1,78 @@
import "fake-indexeddb/auto";
import { IDBFactory } from "fake-indexeddb";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("@/lib/auth/auth-context", () => ({ useCurrentUserId: () => "user-1" }));
type DbModule = typeof import("@/lib/offline/db");
let db: DbModule;
let HivePendingNote: typeof import("./hive-pending-note").HivePendingNote;
function renderNote() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<HivePendingNote colonyId="colony-1" />
</QueryClientProvider>,
);
}
const change = (colonyId: string) => ({
colonyId,
occurredOn: "2026-10-15",
source: "CARD" as const,
ops: [{ op: "setLabel" as const, id: "b", label: "x" }],
});
describe("HivePendingNote", () => {
beforeEach(async () => {
globalThis.indexedDB = new IDBFactory();
vi.resetModules();
db = await import("@/lib/offline/db");
HivePendingNote = (await import("./hive-pending-note")).HivePendingNote;
});
it("says nothing while no change waits", async () => {
await db.queueHiveChange(change("colony-2"), "k0", "user-1");
renderNote();
await new Promise((resolve) => setTimeout(resolve, 20));
expect(screen.queryByRole("status")).not.toBeInTheDocument();
});
it("names one waiting change", async () => {
await db.queueHiveChange(change("colony-1"), "k1", "user-1");
renderNote();
expect(await screen.findByText("1 Änderung wartet auf Netz")).toBeInTheDocument();
});
it("counts a queued inspection with a hive change too", async () => {
await db.queueHiveChange(change("colony-1"), "k1", "user-1");
await db.queueInspection(
{ colonyId: "colony-1", date: "2026-10-15T00:00:00.000Z", hiveChange: { ops: change("x").ops } },
"k2",
"user-1",
);
renderNote();
expect(await screen.findByText("2 Änderungen warten auf Netz")).toBeInTheDocument();
});
it("points to the queue when a change was refused, and opens the queue sheet", async () => {
await db.queueHiveChange(change("colony-1"), "k1", "user-1");
const [entry] = await db.getPendingEntries("pending-hive-changes");
await db.markFailed("pending-hive-changes", entry.id, {
status: 409,
message: "Konflikt",
details: { code: "HIVE_CONFLICT", reason: "MISSING_ID: x" },
at: "2026-10-15T10:00:00.000Z",
});
renderNote();
expect(await screen.findByText(/Eine Änderung an dieser Beute wurde abgelehnt/)).toBeInTheDocument();
expect(screen.queryByText(/wartet auf Netz/)).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Warteschlange öffnen" }));
expect(await screen.findByRole("dialog", { name: "Wartende Einträge" })).toBeInTheDocument();
});
});
@@ -0,0 +1,54 @@
"use client";
import { useState } from "react";
import { AlertTriangle, CloudOff } from "lucide-react";
import { PendingQueueSheet } from "@/components/offline/pending-queue-sheet";
import { Button } from "@/components/ui/button";
import { useCurrentUserId } from "@/lib/auth/auth-context";
import { useHasRejectedHiveChange, usePendingHiveCount } from "./use-pending-hive";
export function pendingHiveText(count: number): string {
return count === 1 ? "1 Änderung wartet auf Netz" : `${count} Änderungen warten auf Netz`;
}
export const REJECTED_HIVE_TEXT =
"Eine Änderung an dieser Beute wurde abgelehnt. Neue Änderungen warten, bis du sie in der Warteschlange klärst.";
/**
* „1 Änderung wartet auf Netz“; with a refused change in the queue instead the
* hint to resolve it there, with a button that opens the queue sheet. Nothing
* while no change of the colony's hive waits.
*/
export function HivePendingNote({ colonyId, className }: { colonyId: string; className?: string }) {
const count = usePendingHiveCount(colonyId);
const rejected = useHasRejectedHiveChange(colonyId);
const userId = useCurrentUserId();
const [sheetOpen, setSheetOpen] = useState(false);
if (rejected && userId) {
return (
<div role="status" className={className ?? "flex flex-col items-start gap-1.5 text-xs"}>
<p className="flex items-start gap-1.5">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" aria-hidden />
{REJECTED_HIVE_TEXT}
</p>
<Button type="button" variant="outline" size="touch" onClick={() => setSheetOpen(true)}>
Warteschlange öffnen
</Button>
<PendingQueueSheet
userId={userId}
foreignCount={0}
open={sheetOpen}
onOpenChange={setSheetOpen}
trigger={null}
/>
</div>
);
}
if (count === 0) return null;
return (
<p role="status" className={className ?? "flex items-center gap-1.5 text-xs"}>
<CloudOff className="size-3.5 shrink-0" aria-hidden />
{pendingHiveText(count)}
</p>
);
}
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { cameraBasis, cameraPosition, fitDistance, focusFrame, overlayMargin } from "./camera-fit"; import { cameraBasis, cameraPosition, fitDistance, focusFrame, insetView } from "./camera-fit";
describe("cameraBasis", () => { describe("cameraBasis", () => {
it("looks from 20° above and 30° to the side, with orthonormal axes", () => { it("looks from 20° above and 30° to the side, with orthonormal axes", () => {
@@ -35,12 +35,26 @@ describe("fitDistance", () => {
}); });
}); });
describe("overlayMargin", () => { describe("insetView", () => {
it("leaves more room on a short card than on a tall one, capped at 2", () => { it("fits into the band between the overlays and centres the model in it", () => {
expect(overlayMargin(1000, 150)).toBeCloseTo(1000 / 850, 9); const view = insetView(460, { top: 96, bottom: 56 });
expect(overlayMargin(460, 150)).toBeCloseTo(460 / 310, 9); // 12 px gap next to each overlay.
expect(overlayMargin(200, 150)).toBe(2); expect(view.fraction).toBeCloseTo((460 - 108 - 68) / 460, 9);
expect(overlayMargin(0)).toBe(1); // More covered at the top: the view moves the model down (negative offset).
expect(view.offsetY).toBeCloseTo(-(108 - 68) / 2, 9);
});
it("is the whole card without overlays and never less than 40 %", () => {
expect(insetView(800, { top: 0, bottom: 0 })).toEqual({ fraction: 1, offsetY: -0 });
expect(insetView(200, { top: 120, bottom: 60 }).fraction).toBeCloseTo(0.4, 9);
expect(insetView(0, { top: 10, bottom: 10 })).toEqual({ fraction: 1, offsetY: 0 });
});
});
describe("fitDistance with a vertical fraction", () => {
it("moves the camera back when only part of the height is free", () => {
const size: [number, number, number] = [5.25, 9, 4.3];
expect(fitDistance(size, 0.8, 1, undefined, undefined, 0.6)).toBeGreaterThan(fitDistance(size, 0.8, 1));
}); });
}); });
@@ -7,11 +7,11 @@ export const CAMERA_ELEVATION_DEG = 20;
export const CAMERA_AZIMUTH_DEG = 30; export const CAMERA_AZIMUTH_DEG = 30;
export const CAMERA_FOV_DEG = 30; export const CAMERA_FOV_DEG = 30;
/** Space around the model, as a factor on its projected size. */ /** Space around the model, as a factor on its projected size. */
export const FIT_MARGIN = 1.05; export const FIT_MARGIN = 1.08;
/** Focus mode: the frame is pulled toward the ghost hive (focusFrame), so less extra room. */ /** Focus mode: the frame is pulled toward the ghost hive (focusFrame), so less extra room. */
export const FOCUS_FIT_MARGIN = 0.95; export const FOCUS_FIT_MARGIN = 1.0;
/** Card pixels taken by the frosted overlays (title or focus bar on top, buttons below). */ /** Breathing room between the model and the overlays, in pixels. */
export const OVERLAY_RESERVE_PX = 150; export const OVERLAY_GAP_PX = 12;
/** Share of the way from the focused part toward the ghost hive the view centre moves (x, y, z). */ /** Share of the way from the focused part toward the ghost hive the view centre moves (x, y, z). */
export const FOCUS_PULL: Vec3 = [0.3, 0.42, 0.3]; export const FOCUS_PULL: Vec3 = [0.3, 0.42, 0.3];
@@ -45,13 +45,34 @@ export function cameraBasis(
return { back, right, up }; return { back, right, up };
} }
export interface OverlayInsets {
/** Pixels covered at the top (title panel or focus bar). */
top: number;
/** Pixels covered at the bottom (button bar). */
bottom: number;
}
export interface InsetView {
/** Share of the card height left free for the model (0.4 … 1). */
fraction: number;
/** Vertical view offset in pixels that centres the model in the free band (camera.setViewOffset). */
offsetY: number;
}
/** /**
* Extra margin so the model fits the part of the card the overlays leave free: * The band of the card the frosted overlays leave free: the camera fits the
* about 1.2 on a tall desktop card, about 1.5 on a phone. Never more than 2. * model into it and shifts the view so the model sits in its middle (so the
* facts panel never covers the roof on a phone).
*/ */
export function overlayMargin(heightPx: number, reservePx: number = OVERLAY_RESERVE_PX): number { export function insetView(heightPx: number, insets: OverlayInsets): InsetView {
if (!(heightPx > 0)) return 1; if (!(heightPx > 0)) return { fraction: 1, offsetY: 0 };
return heightPx / Math.max(heightPx - reservePx, heightPx / 2); const top = Math.max(0, insets.top) + (insets.top > 0 ? OVERLAY_GAP_PX : 0);
const bottom = Math.max(0, insets.bottom) + (insets.bottom > 0 ? OVERLAY_GAP_PX : 0);
const free = Math.max(heightPx - top - bottom, heightPx * 0.4);
const scale = free / Math.max(heightPx - top - bottom, 1e-6);
// When the overlays leave less than 40 %, shrink both proportionally.
const shift = scale > 1 ? (top - bottom) / (2 * scale) : (top - bottom) / 2;
return { fraction: free / heightPx, offsetY: -shift };
} }
/** Half extent of an axis-aligned box of `size` projected on a unit axis. */ /** Half extent of an axis-aligned box of `size` projected on a unit axis. */
@@ -66,13 +87,15 @@ export function fitDistance(
margin: number = FIT_MARGIN, margin: number = FIT_MARGIN,
fovDeg: number = CAMERA_FOV_DEG, fovDeg: number = CAMERA_FOV_DEG,
basis: CameraBasis = cameraBasis(), basis: CameraBasis = cameraBasis(),
verticalFraction = 1,
): number { ): number {
const tanHalf = Math.tan(toRad(fovDeg) / 2); const tanHalf = Math.tan(toRad(fovDeg) / 2);
const safeAspect = aspect > 0 && Number.isFinite(aspect) ? aspect : 1; const safeAspect = aspect > 0 && Number.isFinite(aspect) ? aspect : 1;
const halfWidth = halfExtent(size, basis.right); const halfWidth = halfExtent(size, basis.right);
const halfHeight = halfExtent(size, basis.up); const halfHeight = halfExtent(size, basis.up);
const halfDepth = halfExtent(size, basis.back); const halfDepth = halfExtent(size, basis.back);
const distance = Math.max(halfHeight / tanHalf, halfWidth / (tanHalf * safeAspect)) * margin; const fraction = verticalFraction > 0 ? verticalFraction : 1;
const distance = Math.max(halfHeight / (tanHalf * fraction), halfWidth / (tanHalf * safeAspect)) * margin;
// The near face of the box sits half its depth closer to the camera. // The near face of the box sits half its depth closer to the camera.
return Math.max(distance + halfDepth, 1); return Math.max(distance + halfDepth, 1);
} }
@@ -3,9 +3,10 @@
import { useEffect, useMemo, useRef } from "react"; import { useEffect, useMemo, useRef } from "react";
import { useFrame, useThree } from "@react-three/fiber"; import { useFrame, useThree } from "@react-three/fiber";
import type { HiveLayout, Vec3 } from "@/lib/hive/layout"; import type { HiveLayout, Vec3 } from "@/lib/hive/layout";
import { FIT_MARGIN, FOCUS_FIT_MARGIN, fitDistance, focusFrame, overlayMargin } from "./camera-fit"; import { FIT_MARGIN, FOCUS_FIT_MARGIN, fitDistance, focusFrame, insetView } from "./camera-fit";
import type { OverlayInsets } from "./camera-fit";
import { useSceneKit } from "./scene-kit"; import { useSceneKit } from "./scene-kit";
import { placeCamera } from "./scene-mutations"; import { offsetView, placeCamera, setFocusPoint } from "./scene-mutations";
import { MAX_FRAME_SECONDS } from "./use-animated-node"; import { MAX_FRAME_SECONDS } from "./use-animated-node";
import { restBounds, stepToward } from "./use-hive-motion"; import { restBounds, stepToward } from "./use-hive-motion";
import type { FocusInfo } from "./use-hive-motion"; import type { FocusInfo } from "./use-hive-motion";
@@ -19,10 +20,12 @@ export interface CameraRigProps {
focus: FocusInfo | null; focus: FocusInfo | null;
/** Receives the current look-at point every frame; the model rig turns around it. */ /** Receives the current look-at point every frame; the model rig turns around it. */
onPivot: (center: Vec3) => void; onPivot: (center: Vec3) => void;
/** Card pixels covered by the frosted overlays. */
insets: OverlayInsets;
} }
export function CameraRig({ layout, focus, onPivot }: CameraRigProps) { export function CameraRig({ layout, focus, onPivot, insets }: CameraRigProps) {
const { motion } = useSceneKit(); const { motion, signals } = useSceneKit();
const camera = useThree((state) => state.camera); const camera = useThree((state) => state.camera);
const width = useThree((state) => state.size.width); const width = useThree((state) => state.size.width);
const height = useThree((state) => state.size.height); const height = useThree((state) => state.size.height);
@@ -30,14 +33,15 @@ export function CameraRig({ layout, focus, onPivot }: CameraRigProps) {
const goal = useMemo(() => { const goal = useMemo(() => {
const aspect = height > 0 ? width / height : 1; const aspect = height > 0 ? width / height : 1;
const room = overlayMargin(height); const band = insetView(height, insets);
const bounds = restBounds(layout); const bounds = restBounds(layout);
const fit = (size: Vec3, margin: number) => fitDistance(size, aspect, margin, undefined, undefined, band.fraction);
if (focus) { if (focus) {
const frame = focusFrame(focus.center, focus.size, bounds.center); const frame = focusFrame(focus.center, focus.size, bounds.center);
return { center: frame.center, distance: fitDistance(frame.size, aspect, FOCUS_FIT_MARGIN * room) }; return { center: frame.center, distance: fit(frame.size, FOCUS_FIT_MARGIN), offsetY: band.offsetY };
} }
return { center: bounds.center, distance: fitDistance(bounds.size, aspect, FIT_MARGIN * room) }; return { center: bounds.center, distance: fit(bounds.size, FIT_MARGIN), offsetY: band.offsetY };
}, [focus, layout, width, height]); }, [focus, layout, width, height, insets]);
const current = useRef<{ center: Vec3; distance: number } | null>(null); const current = useRef<{ center: Vec3; distance: number } | null>(null);
useEffect(() => { useEffect(() => {
@@ -60,7 +64,9 @@ export function CameraRig({ layout, focus, onPivot }: CameraRigProps) {
} }
const { center, distance } = current.current; const { center, distance } = current.current;
onPivot(center); onPivot(center);
setFocusPoint(signals, focus ? focus.center : null);
placeCamera(camera, center, distance); placeCamera(camera, center, distance);
offsetView(camera, width, height, goal.offsetY);
const settled = const settled =
distance === goal.distance && center.every((value, axis) => value === goal.center[axis]); distance === goal.distance && center.every((value, axis) => value === goal.center[axis]);
if (!settled) invalidate(); if (!settled) invalidate();
@@ -2,10 +2,11 @@
import { useEffect, useMemo, useRef } from "react"; import { useEffect, useMemo, useRef } from "react";
import { useFrame, useThree } from "@react-three/fiber"; import { useFrame, useThree } from "@react-three/fiber";
import { BoxGeometry, CylinderGeometry, MeshStandardMaterial } from "three"; import { BoxGeometry, CanvasTexture, CylinderGeometry, MeshStandardMaterial, PlaneGeometry, RepeatWrapping, SRGBColorSpace } from "three";
import type { Mesh } from "three"; import { RoundedBoxGeometry } from "three/addons/geometries/RoundedBoxGeometry.js";
import type { Color, Mesh } from "three";
import type { Vec3 } from "@/lib/hive/layout"; import type { Vec3 } from "@/lib/hive/layout";
import { createLiquidMaterial } from "./materials"; import { createCausticMaterial, createLiquidMaterial } from "./materials";
import { MAX_FRAME_SECONDS } from "./use-animated-node"; import { MAX_FRAME_SECONDS } from "./use-animated-node";
import { stepToward } from "./use-hive-motion"; import { stepToward } from "./use-hive-motion";
import { noRaycast, useSceneKit } from "./scene-kit"; import { noRaycast, useSceneKit } from "./scene-kit";
@@ -19,6 +20,8 @@ const WOBBLE = 0.02;
export type FillKind = "honey" | "acid" | "fondant"; export type FillKind = "honey" | "acid" | "fondant";
type FillColor = Color;
export interface FillVolumeProps { export interface FillVolumeProps {
/** Centre and size of the full space (local to the part). */ /** Centre and size of the full space (local to the part). */
center: Vec3; center: Vec3;
@@ -35,31 +38,35 @@ export function FillVolume({ center, size, ratio, kind, shape = "box" }: FillVol
const mesh = useRef<Mesh>(null); const mesh = useRef<Mesh>(null);
const shown = useRef(ratio); const shown = useRef(ratio);
const geometry = useMemo( const geometry = useMemo(() => {
() => (shape === "cylinder" ? new CylinderGeometry(0.5, 0.5, 1, 40, 1) : new BoxGeometry(1, 1, 1, 24, 1, 12)), if (kind === "fondant") return new RoundedBoxGeometry(1, 1, 1, 3, 0.1);
[shape], return shape === "cylinder" ? new CylinderGeometry(0.5, 0.5, 1, 40, 1) : new BoxGeometry(1, 1, 1, 24, 1, 12);
); }, [shape, kind]);
const surface = useMemo(() => (kind === "fondant" ? fondantTexture() : null), [kind]);
const material = useMemo(() => { const material = useMemo(() => {
if (kind === "fondant") { if (kind === "fondant") {
// Feed paste: a soft sugary block with a fine grain (map and relief from one noise texture).
return new MeshStandardMaterial({ return new MeshStandardMaterial({
color: theme.fondant, color: theme.fondant,
roughness: 0.55, map: surface,
bumpMap: surface,
bumpScale: 2.5,
roughness: 0.62,
emissive: theme.fondant, emissive: theme.fondant,
emissiveIntensity: 0.18, emissiveIntensity: 0.16,
transparent: true,
opacity: 0.94,
}); });
} }
return kind === "acid" return kind === "acid"
? createLiquidMaterial({ color: theme.acid, deep: theme.rim, glow: 1.05, alpha: 0.55 }) ? createLiquidMaterial({ color: theme.acid, deep: theme.rim, glow: 1.05, alpha: 0.55 })
: createLiquidMaterial({ color: theme.honey, deep: theme.honeyDeep, glow: 1.2, alpha: 0.78 }); : createLiquidMaterial({ color: theme.honey, deep: theme.honeyDeep, glow: 1.2, alpha: 0.78 });
}, [kind, theme]); }, [kind, theme, surface]);
useEffect( useEffect(
() => () => { () => () => {
geometry.dispose(); geometry.dispose();
material.dispose(); material.dispose();
surface?.dispose();
}, },
[geometry, material], [geometry, material, surface],
); );
useEffect(() => { useEffect(() => {
invalidate(); invalidate();
@@ -79,5 +86,72 @@ export function FillVolume({ center, size, ratio, kind, shape = "box" }: FillVol
if (shown.current !== ratio) invalidate(); if (shown.current !== ratio) invalidate();
}); });
return <mesh ref={mesh} geometry={geometry} material={material} raycast={noRaycast} renderOrder={3} />; return (
<>
<mesh ref={mesh} geometry={geometry} material={material} raycast={noRaycast} renderOrder={3} />
{kind !== "fondant" && shape === "box" && ratio > 0 ? (
<Caustics center={center} size={size} color={kind === "acid" ? theme.acid : theme.honey} />
) : null}
</>
);
}
/** Fine sugary grain for the feed paste; null without a 2D canvas (tests). */
function fondantTexture(): CanvasTexture | null {
if (typeof document === "undefined") return null;
const canvas = document.createElement("canvas");
canvas.width = 128;
canvas.height = 128;
const context = canvas.getContext("2d");
if (!context) return null;
let seed = 97;
const next = () => {
seed = (seed * 16807) % 2147483647;
return seed / 2147483647;
};
context.fillStyle = "#d8d8d8";
context.fillRect(0, 0, 128, 128);
for (let dot = 0; dot < 900; dot += 1) {
const light = Math.round(180 + next() * 75);
context.fillStyle = `rgb(${light}, ${light}, ${light})`;
context.beginPath();
context.arc(next() * 128, next() * 128, 0.6 + next() * 1.6, 0, Math.PI * 2);
context.fill();
}
const texture = new CanvasTexture(canvas);
texture.colorSpace = SRGBColorSpace;
texture.wrapS = RepeatWrapping;
texture.wrapT = RepeatWrapping;
texture.repeat.set(2, 2);
return texture;
}
/** Soft caustics on the floor of the space a liquid fills, moving only at `full`. */
function Caustics({ center, size, color }: { center: Vec3; size: Vec3; color: FillColor }) {
const { motion } = useSceneKit();
const geometry = useMemo(() => {
const plane = new PlaneGeometry(size[0] * 0.98, size[2] * 0.98);
plane.rotateX(-Math.PI / 2);
return plane;
}, [size]);
const material = useMemo(() => createCausticMaterial(color, [size[0] * 3, size[2] * 3]), [color, size]);
useEffect(
() => () => {
geometry.dispose();
material.dispose();
},
[geometry, material],
);
useFrame((state) => {
if (motion === "full") setUniform(material, "uTime", state.clock.elapsedTime);
});
return (
<mesh
geometry={geometry}
material={material}
position={[center[0], center[1] - size[1] / 2 + 0.006, center[2]]}
raycast={noRaycast}
renderOrder={2}
/>
);
} }
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useEffect, useMemo, useRef } from "react"; import { useEffect, useMemo, useRef } from "react";
import { MeshStandardMaterial } from "three"; import { Color, MeshStandardMaterial } from "three";
import type { Group, ShaderMaterial } from "three"; import type { Group, ShaderMaterial } from "three";
import type { FrameBox, Vec3 } from "@/lib/hive/layout"; import type { FrameBox, Vec3 } from "@/lib/hive/layout";
import { HoloLabel } from "./hive-labels"; import { HoloLabel } from "./hive-labels";
@@ -9,7 +9,9 @@ import { createEdgeMaterial, createGlassMaterial } from "./materials";
import { buildEdgeGeometry, buildGlassGeometry, frameHintSpec, frameShapeSpec } from "./part-geometry"; import { buildEdgeGeometry, buildGlassGeometry, frameHintSpec, frameShapeSpec } from "./part-geometry";
import type { PartShapeSpec } from "./part-geometry"; import type { PartShapeSpec } from "./part-geometry";
import { meshRaycast, noRaycast, useSceneKit } from "./scene-kit"; import { meshRaycast, noRaycast, useSceneKit } from "./scene-kit";
import { setMaterialOpacity, setUniform } from "./scene-mutations"; import { setMaterialOpacity, setUniform, setUniformMix } from "./scene-mutations";
import { useLivingEdges } from "./use-scene-effects";
import { frameRimColor } from "./frame-textures";
import type { SceneLabel } from "./scene-model"; import type { SceneLabel } from "./scene-model";
import type { NodeHandlers } from "./stack-part"; import type { NodeHandlers } from "./stack-part";
import { useAnimatedNode } from "./use-animated-node"; import { useAnimatedNode } from "./use-animated-node";
@@ -52,7 +54,7 @@ export function FrameHints({
); );
return ( return (
<> <>
<mesh geometry={geometries.glass} material={glass} raycast={noRaycast} renderOrder={1} /> <mesh name={`frame-hints:${frames[0]?.boxId ?? ""}`} geometry={geometries.glass} material={glass} raycast={noRaycast} renderOrder={1} />
<mesh geometry={geometries.edges} material={edges} raycast={noRaycast} renderOrder={4} /> <mesh geometry={geometries.edges} material={edges} raycast={noRaycast} renderOrder={4} />
</> </>
); );
@@ -69,7 +71,7 @@ export interface FramePlateProps {
const COMB_FROST: Partial<Record<FrameBox["type"], number>> = { DIVIDER: 0.75, FOUNDATION: 0.25 }; const COMB_FROST: Partial<Record<FrameBox["type"], number>> = { DIVIDER: 0.75, FOUNDATION: 0.25 };
export function FramePlate({ frame, hostCenter, target, handlers, label }: FramePlateProps) { export function FramePlate({ frame, hostCenter, target, handlers, label }: FramePlateProps) {
const { theme, geometries, textures, motion } = useSceneKit(); const { theme, geometries, textures, motion, env, tier } = useSceneKit();
const group = useRef<Group>(null); const group = useRef<Group>(null);
const spec = useMemo(() => frameShapeSpec(frame.size, frame.type), [frame.size, frame.type]); const spec = useMemo(() => frameShapeSpec(frame.size, frame.type), [frame.size, frame.type]);
const sizeKey = frame.size.map((value) => value.toFixed(4)).join("x"); const sizeKey = frame.size.map((value) => value.toFixed(4)).join("x");
@@ -88,18 +90,23 @@ export function FramePlate({ frame, hostCenter, target, handlers, label }: Frame
})) }))
: null; : null;
const texture = textures.get(frame.type); const texture = textures.get(frame.type);
const rimHex = frameRimColor(frame.type);
const rim = useMemo(() => (rimHex ? new Color(rimHex) : theme.edgeStrong), [rimHex, theme]);
const materials = useMemo( const materials = useMemo(
() => ({ () => ({
bars: createGlassMaterial(theme, { frost: 0.28 }), bars: createGlassMaterial(theme, { frost: 0.28, env, dispersion: tier === "high" ? 0.03 : 0 }),
comb: createGlassMaterial(theme, { frost: COMB_FROST[frame.type] ?? 0.38 }), comb: createGlassMaterial(theme, { frost: COMB_FROST[frame.type] ?? 0.38, env, dispersion: 0 }),
edges: createEdgeMaterial(theme), edges: createEdgeMaterial(theme),
typed: texture typed: texture
? new MeshStandardMaterial({ ? new MeshStandardMaterial({
map: texture, map: texture,
// The comb pattern as relief: cells read as depth under the studio light.
bumpMap: texture,
bumpScale: 4,
emissiveMap: texture, emissiveMap: texture,
emissive: "#ffffff", emissive: "#ffffff",
emissiveIntensity: 0.95, emissiveIntensity: 0.8,
roughness: frame.type === "HONEY" ? 0.2 : 0.45, roughness: frame.type === "HONEY" ? 0.2 : 0.45,
metalness: 0.05, metalness: 0.05,
transparent: true, transparent: true,
@@ -107,7 +114,7 @@ export function FramePlate({ frame, hostCenter, target, handlers, label }: Frame
}) })
: null, : null,
}), }),
[theme, texture, frame.type], [theme, texture, frame.type, env, tier],
); );
useEffect( useEffect(
() => () => { () => () => {
@@ -128,6 +135,8 @@ export function FramePlate({ frame, hostCenter, target, handlers, label }: Frame
setUniform(materials.comb, "uGlow", glow); setUniform(materials.comb, "uGlow", glow);
setUniform(materials.edges, "uOpacity", opacity); setUniform(materials.edges, "uOpacity", opacity);
setUniform(materials.edges, "uIntensity", 1.3 * glow); setUniform(materials.edges, "uIntensity", 1.3 * glow);
// Lifted, the rim takes the colour of the frame type.
setUniformMix(materials.edges, "uCyan", theme.edge, rim, look);
if (materials.typed) setMaterialOpacity(materials.typed, opacity * look); if (materials.typed) setMaterialOpacity(materials.typed, opacity * look);
}; };
useAnimatedNode( useAnimatedNode(
@@ -136,13 +145,14 @@ export function FramePlate({ frame, hostCenter, target, handlers, label }: Frame
motion, motion,
apply, apply,
); );
useLivingEdges([materials.edges]);
const restY = frame.center[1] - hostCenter[1]; const restY = frame.center[1] - hostCenter[1];
const rise = Math.max(0, target.position[1] - restY); const rise = Math.max(0, target.position[1] - restY);
const [fx, fy, fz] = frame.size; const [fx, fy, fz] = frame.size;
return ( return (
<group ref={group}> <group ref={group} visible={target.look === "glass" || target.look === "type"}>
<mesh geometry={bars.glass} material={materials.bars} raycast={noRaycast} renderOrder={1} dispose={null} /> <mesh geometry={bars.glass} material={materials.bars} raycast={noRaycast} renderOrder={1} dispose={null} />
{comb ? ( {comb ? (
<mesh geometry={comb.glass} material={materials.comb} raycast={noRaycast} renderOrder={1} dispose={null} /> <mesh geometry={comb.glass} material={materials.comb} raycast={noRaycast} renderOrder={1} dispose={null} />
@@ -154,6 +164,7 @@ export function FramePlate({ frame, hostCenter, target, handlers, label }: Frame
{/* The hit box reaches down to the rest place, so a lifted frame does not slip from under the pointer. */} {/* The hit box reaches down to the rest place, so a lifted frame does not slip from under the pointer. */}
<mesh <mesh
position={[0, -rise / 2, 0]} position={[0, -rise / 2, 0]}
scale={[fx, fy + rise, fz]}
raycast={target.interactive ? meshRaycast : noRaycast} raycast={target.interactive ? meshRaycast : noRaycast}
onPointerOver={handlers.onPointerOver} onPointerOver={handlers.onPointerOver}
onPointerOut={handlers.onPointerOut} onPointerOut={handlers.onPointerOut}
@@ -161,7 +172,7 @@ export function FramePlate({ frame, hostCenter, target, handlers, label }: Frame
onClick={handlers.onClick} onClick={handlers.onClick}
userData={{ hit: `${frame.boxId}#${frame.slot}` }} userData={{ hit: `${frame.boxId}#${frame.slot}` }}
> >
<boxGeometry args={[fx, fy + rise, fz]} /> <boxGeometry args={[1, 1, 1]} />
<meshBasicMaterial visible={false} /> <meshBasicMaterial visible={false} />
</mesh> </mesh>
{label ? <HoloLabel position={[0, fy / 2 + 0.12, 0]} text={label.text} tone="frame" /> : null} {label ? <HoloLabel position={[0, fy / 2 + 0.12, 0]} text={label.text} tone="frame" /> : null}
@@ -1,7 +1,7 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { FRAME_TYPES } from "@/lib/hive/types"; import { FRAME_TYPES } from "@/lib/hive/types";
import type { FrameType } from "@/lib/hive/types"; import type { FrameType } from "@/lib/hive/types";
import { createFrameTextureCache, framePatternSpec } from "./frame-textures"; import { createFrameTextureCache, framePatternSpec, frameRimColor } from "./frame-textures";
/** A canvas whose 2D context records every fill colour and shape drawn. */ /** A canvas whose 2D context records every fill colour and shape drawn. */
function fakeCanvas() { function fakeCanvas() {
@@ -107,3 +107,12 @@ describe("createFrameTextureCache", () => {
expect(cache.get("BROOD")).toBeNull(); expect(cache.get("BROOD")).toBeNull();
}); });
}); });
describe("frameRimColor", () => {
it("gives every frame type its own rim colour for the lifted look", () => {
const rims = FRAME_TYPES.filter((type) => type !== "NONE").map(frameRimColor);
expect(new Set(rims).size).toBe(rims.length);
expect(frameRimColor("BROOD")).toBe(framePatternSpec("BROOD")!.rim);
expect(frameRimColor("NONE")).toBeNull();
});
});
@@ -137,6 +137,14 @@ export function framePatternSpec(type: FrameType): FramePatternSpec | null {
return SPECS[type] ?? null; return SPECS[type] ?? null;
} }
/** Rim colours of the frames without a comb texture. */
const OPEN_RIMS: Partial<Record<FrameType, string>> = { DRONE_FRAME: "#ffc46b", DIVIDER: "#bfe4ff" };
/** Edge colour of a lifted or selected frame, matching its type; null for an empty slot. */
export function frameRimColor(type: FrameType): string | null {
return SPECS[type]?.rim ?? OPEN_RIMS[type] ?? null;
}
/** Small deterministic PRNG (mulberry32). */ /** Small deterministic PRNG (mulberry32). */
function random(seed: number): () => number { function random(seed: number): () => number {
let state = seed >>> 0; let state = seed >>> 0;
@@ -1,15 +1,17 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import type { RefObject } from "react"; import type { RefObject } from "react";
import { Canvas } from "@react-three/fiber"; import { Canvas } from "@react-three/fiber";
import type { HiveLayout } from "@/lib/hive/layout"; import type { HiveLayout } from "@/lib/hive/layout";
import type { HiveCatalog, HiveState } from "@/lib/hive/types"; import type { HiveCatalog, HiveState } from "@/lib/hive/types";
import { useEffectiveMotionLevel } from "@/lib/motion/motion-preference"; import { useEffectiveMotionLevel } from "@/lib/motion/motion-preference";
import { useHiveView } from "../use-hive-view"; import { useHiveViewStore } from "../use-hive-view";
import { CAMERA_FOV_DEG } from "./camera-fit"; import { CAMERA_FOV_DEG } from "./camera-fit";
import type { OverlayInsets } from "./camera-fit";
import { HiveSceneContent } from "./hive-scene-content"; import { HiveSceneContent } from "./hive-scene-content";
import { HiveStage, detectStageTier } from "./hive-stage"; import { HiveStage, detectStageTier } from "./hive-stage";
import type { QualityVerdict } from "./quality";
import { SceneKitProvider } from "./scene-kit"; import { SceneKitProvider } from "./scene-kit";
import { shouldIdle } from "./use-hive-motion"; import { shouldIdle } from "./use-hive-motion";
@@ -24,6 +26,8 @@ export interface HiveCanvasProps {
onReady: () => void; onReady: () => void;
/** The WebGL context was lost; the browser may or may not restore it. */ /** The WebGL context was lost; the browser may or may not restore it. */
onContextLost: () => void; onContextLost: () => void;
/** Card pixels covered by the frosted overlays (title/focus bar, buttons). */
insets?: OverlayInsets;
} }
/** Card visible in the viewport and tab in the foreground (idle motion pauses otherwise). */ /** Card visible in the viewport and tab in the foreground (idle motion pauses otherwise). */
@@ -61,12 +65,28 @@ export default function HiveCanvas({
onSelectFrame, onSelectFrame,
onReady, onReady,
onContextLost, onContextLost,
insets,
}: HiveCanvasProps) { }: HiveCanvasProps) {
const motion = useEffectiveMotionLevel(); const motion = useEffectiveMotionLevel();
const view = useHiveView(); // Only acts on the view (empty click); the scene content subscribes itself.
const view = useHiveViewStore();
const root = useRef<HTMLDivElement>(null); const root = useRef<HTMLDivElement>(null);
const detachContextLost = useRef<(() => void) | null>(null);
// Detach on unmount, so R3F's own forceContextLoss after unmount does not count as a loss.
useEffect(() => () => detachContextLost.current?.(), []);
const { visible, tabVisible } = useOnScreen(root); const { visible, tabVisible } = useOnScreen(root);
const [tier] = useState(detectStageTier); // Starts from the device class, drops to low when it renders below 45 fps for 2 s;
// depth of field only after it measured 55 fps or more (a 60 Hz screen).
const [tier, setTier] = useState(detectStageTier);
const [fast, setFast] = useState(false);
const onQuality = useCallback((verdict: QualityVerdict) => {
if (verdict === "slow") {
setTier("low");
setFast(false);
} else {
setFast(true);
}
}, []);
const idle = shouldIdle({ motion, visible, tabVisible }); const idle = shouldIdle({ motion, visible, tabVisible });
return ( return (
@@ -86,17 +106,20 @@ export default function HiveCanvas({
view.setHoveredFrame(null); view.setHoveredFrame(null);
}} }}
onCreated={({ gl, invalidate }) => { onCreated={({ gl, invalidate }) => {
gl.domElement.addEventListener("webglcontextlost", (event) => { const canvas = gl.domElement;
const onLost = (event: Event) => {
// Without preventDefault the browser never restores the context. // Without preventDefault the browser never restores the context.
event.preventDefault(); event.preventDefault();
onContextLost(); onContextLost();
}); };
canvas.addEventListener("webglcontextlost", onLost);
detachContextLost.current = () => canvas.removeEventListener("webglcontextlost", onLost);
invalidate(); invalidate();
onReady(); onReady();
}} }}
> >
<SceneKitProvider motion={motion}> <SceneKitProvider motion={motion} tier={tier}>
<HiveStage tier={tier} /> <HiveStage tier={tier} depthOfField={fast && motion === "full"} onQuality={onQuality} />
<HiveSceneContent <HiveSceneContent
layout={layout} layout={layout}
state={state} state={state}
@@ -105,6 +128,8 @@ export default function HiveCanvas({
onSelectPart={onSelectPart} onSelectPart={onSelectPart}
onSelectFrame={onSelectFrame} onSelectFrame={onSelectFrame}
idle={idle} idle={idle}
onScreen={visible && tabVisible}
insets={insets}
/> />
</SceneKitProvider> </SceneKitProvider>
</Canvas> </Canvas>
@@ -83,6 +83,13 @@ const hit = (renderer: Rendered["renderer"], id: string): ReactThreeTest.ReactTh
const hits = (renderer: Rendered["renderer"]): string[] => const hits = (renderer: Rendered["renderer"]): string[] =>
renderer.scene.findAll((node) => typeof node.props.userData?.hit === "string").map((node) => node.props.userData.hit); renderer.scene.findAll((node) => typeof node.props.userData?.hit === "string").map((node) => node.props.userData.hit);
/** Frame plates stay mounted (no mount work on the focus flight); only the open box shows them. */
const shownFrames = (renderer: Rendered["renderer"]): string[] =>
renderer.scene
.findAll((node) => typeof node.props.userData?.hit === "string" && node.props.userData.hit.includes("#"))
.filter((node) => node.parent?.instance.visible)
.map((node) => node.props.userData.hit);
async function tap(renderer: Rendered["renderer"], id: string, pointerType = "mouse") { async function tap(renderer: Rendered["renderer"], id: string, pointerType = "mouse") {
const target = hit(renderer, id); const target = hit(renderer, id);
await renderer.fireEvent(target, "onPointerDown", { pointerType }); await renderer.fireEvent(target, "onPointerDown", { pointerType });
@@ -92,18 +99,29 @@ async function tap(renderer: Rendered["renderer"], id: string, pointerType = "mo
describe("HiveSceneContent", () => { describe("HiveSceneContent", () => {
it("renders one hit box per part and insert, and frames only as hints at rest", async () => { it("renders one hit box per part and insert, and frames only as hints at rest", async () => {
const { renderer } = await renderScene(); const { renderer } = await renderScene();
expect(hits(renderer).sort()).toEqual(["b1", "f", "fe", "ff", "fi", "r"]); expect(hits(renderer).filter((id) => !id.includes("#")).sort()).toEqual(["b1", "f", "fe", "ff", "fi", "r"]);
expect(shownFrames(renderer)).toEqual([]);
}); });
it("shows the frames of an open box as plates, without the empty slots under the pocket", async () => { it("shows the frames of an open box as plates, without the empty slots under the pocket", async () => {
const { renderer, store } = await renderScene(); const { renderer, store } = await renderScene();
await act(async () => store.openBox("b1")); await act(async () => store.openBox("b1"));
const frameHits = hits(renderer).filter((id) => id.startsWith("b1#")); const frameHits = shownFrames(renderer);
expect(frameHits).toHaveLength(8); expect(frameHits).toHaveLength(8);
expect(frameHits.every((id) => id.startsWith("b1#"))).toBe(true);
expect(frameHits).not.toContain("b1#1"); expect(frameHits).not.toContain("b1#1");
expect(frameHits).not.toContain("b1#2"); expect(frameHits).not.toContain("b1#2");
}); });
it("keeps the merged frame-hint geometry across hover changes (no rebuild per render)", async () => {
const { renderer, store } = await renderScene();
const hint = () => renderer.scene.find((node) => node.props.name === "frame-hints:b1").instance;
const before = (hint() as unknown as { geometry: unknown }).geometry;
await act(async () => store.setHovered("b1"));
await act(async () => store.setHovered("fe"));
expect((hint() as unknown as { geometry: unknown }).geometry).toBe(before);
});
it("reports pointer over a part as hover to the shared store and clears it on leave", async () => { it("reports pointer over a part as hover to the shared store and clears it on leave", async () => {
const { renderer, store } = await renderScene(); const { renderer, store } = await renderScene();
await renderer.fireEvent(hit(renderer, "b1"), "onPointerOver", { pointerType: "mouse" }); await renderer.fireEvent(hit(renderer, "b1"), "onPointerOver", { pointerType: "mouse" });
@@ -2,10 +2,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useThree } from "@react-three/fiber"; import { useThree } from "@react-three/fiber";
import type { HiveLayout, PartBox, Vec3 } from "@/lib/hive/layout"; import type { FrameBox, HiveLayout, PartBox, Vec3 } from "@/lib/hive/layout";
import type { HiveCatalog, HiveState } from "@/lib/hive/types"; import type { HiveCatalog, HiveState } from "@/lib/hive/types";
import { focusedIdOf, useHiveView } from "../use-hive-view"; import { focusedIdOf, useHiveView } from "../use-hive-view";
import type { HoveredFrame } from "../use-hive-view"; import type { HoveredFrame } from "../use-hive-view";
import type { OverlayInsets } from "./camera-fit";
import { CameraRig } from "./camera-rig"; import { CameraRig } from "./camera-rig";
import { FramePlate } from "./frame-plate"; import { FramePlate } from "./frame-plate";
import { ModelRig } from "./model-rig"; import { ModelRig } from "./model-rig";
@@ -37,9 +38,16 @@ export interface HiveSceneContentProps {
onSelectFrame: (boxId: string, slot: number) => void; onSelectFrame: (boxId: string, slot: number) => void;
/** Idle motion may run (see shouldIdle). */ /** Idle motion may run (see shouldIdle). */
idle: boolean; idle: boolean;
/** Card and tab visible (the quality probe runs at any motion level). */
onScreen?: boolean;
/** Card pixels covered by the frosted overlays; the camera fits the model between them. */
insets?: OverlayInsets;
} }
const NO_INSETS: OverlayInsets = { top: 0, bottom: 0 };
const partKey = (id: string): string => `part:${id}`; const partKey = (id: string): string => `part:${id}`;
const NO_FRAMES: FrameBox[] = [];
const frameTargetKey = (boxId: string, slot: number): string => `frame:${frameKey(boxId, slot)}`; const frameTargetKey = (boxId: string, slot: number): string => `frame:${frameKey(boxId, slot)}`;
function parseKey(key: string): { kind: "part"; id: string } | { kind: "frame"; boxId: string; slot: number } | null { function parseKey(key: string): { kind: "part"; id: string } | { kind: "frame"; boxId: string; slot: number } | null {
@@ -72,6 +80,8 @@ export function HiveSceneContent({
onSelectPart, onSelectPart,
onSelectFrame, onSelectFrame,
idle, idle,
onScreen = false,
insets = NO_INSETS,
}: HiveSceneContentProps) { }: HiveSceneContentProps) {
const view = useHiveView(); const view = useHiveView();
const focusedId = focusedIdOf(view); const focusedId = focusedIdOf(view);
@@ -174,16 +184,22 @@ export function HiveSceneContent({
}, []); }, []);
const getPivot = useCallback(() => pivot.current, []); const getPivot = useCallback(() => pivot.current, []);
const insertsOf = (host: PartBox) => layout.inserts.filter((insert) => insert.stackIndex === host.stackIndex); const insertsOf = (host: PartBox) => layout.inserts.filter((insert) => insert.stackIndex === host.stackIndex);
// One stable array per box, so the merged hint geometry is built once per layout, not per render.
const framesByBox = useMemo(() => {
const grouped = new Map<string, FrameBox[]>();
for (const frame of layout.frames) grouped.set(frame.boxId, [...(grouped.get(frame.boxId) ?? []), frame]);
return grouped;
}, [layout]);
return ( return (
<> <>
<CameraRig layout={layout} focus={targets.focus} onPivot={setPivot} /> <CameraRig layout={layout} focus={targets.focus} onPivot={setPivot} insets={insets} />
<ModelRig getPivot={getPivot} idle={idle}> <ModelRig getPivot={getPivot} idle={idle} onScreen={onScreen}>
{layout.parts.map((placed) => { {layout.parts.map((placed) => {
const part = model.get(placed.id); const part = model.get(placed.id);
const target = targets.nodes.get(placed.id); const target = targets.nodes.get(placed.id);
if (!part || !target) return null; if (!part || !target) return null;
const frames = layout.frames.filter((frame) => frame.boxId === placed.id); const frames = framesByBox.get(placed.id) ?? NO_FRAMES;
return ( return (
<StackPart <StackPart
key={placed.id} key={placed.id}
@@ -209,8 +225,9 @@ export function HiveSceneContent({
/> />
); );
})} })}
{target.content {/* Frame plates stay mounted (hidden while their box is closed), so opening a box
? frames.map((frame) => { only animates and does not create materials during the flight. */}
{frames.map((frame) => {
const frameTarget = targets.frames.get(frameKey(frame.boxId, frame.slot)); const frameTarget = targets.frames.get(frameKey(frame.boxId, frame.slot));
if (!frameTarget || frameTarget.look === "hidden") return null; if (!frameTarget || frameTarget.look === "hidden") return null;
return ( return (
@@ -223,8 +240,7 @@ export function HiveSceneContent({
label={frameLabel(labels, frame.boxId, frame.slot)} label={frameLabel(labels, frame.boxId, frame.slot)}
/> />
); );
}) })}
: null}
</StackPart> </StackPart>
); );
})} })}
@@ -1,12 +1,14 @@
"use client"; "use client";
import { useEffect, useMemo, useRef } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useFrame, useThree } from "@react-three/fiber"; import { useFrame, useThree } from "@react-three/fiber";
import { MeshReflectorMaterial } from "@react-three/drei"; import { MeshReflectorMaterial } from "@react-three/drei";
import { Bloom, EffectComposer, ToneMapping, Vignette } from "@react-three/postprocessing"; import { Bloom, DepthOfField, EffectComposer, ToneMapping, Vignette } from "@react-three/postprocessing";
import { ToneMappingMode } from "postprocessing"; import { ToneMappingMode } from "postprocessing";
import type { DepthOfFieldEffect } from "postprocessing";
import { import {
AdditiveBlending, AdditiveBlending,
DoubleSide,
BufferGeometry, BufferGeometry,
CanvasTexture, CanvasTexture,
Float32BufferAttribute, Float32BufferAttribute,
@@ -15,8 +17,10 @@ import {
ShaderMaterial, ShaderMaterial,
} from "three"; } from "three";
import type { Points } from "three"; import type { Points } from "three";
import { createQualityMonitor } from "./quality";
import type { QualityVerdict, StageTier } from "./quality";
import { useSceneKit } from "./scene-kit"; import { useSceneKit } from "./scene-kit";
import { setSceneBackground, setUniform } from "./scene-mutations"; import { markTimerFrame, setEffectBokeh, setEffectTarget, setSceneBackground, setUniform } from "./scene-mutations";
import type { HoloTheme } from "./theme"; import type { HoloTheme } from "./theme";
// The holo stage (spec 9.3, mockups g2/h2): deep midnight backdrop with a // The holo stage (spec 9.3, mockups g2/h2): deep midnight backdrop with a
@@ -29,7 +33,7 @@ const OVERLAY_LAYER = 1;
const FLOOR_SIZE = 240; const FLOOR_SIZE = 240;
const PARTICLE_COUNT = 42; const PARTICLE_COUNT = 42;
export type StageTier = "high" | "low"; export type { StageTier } from "./quality";
/** High: reflective floor and MSAA (desktop-class). Low: fake floor, no MSAA (phones, ≤ 4 cores). */ /** High: reflective floor and MSAA (desktop-class). Low: fake floor, no MSAA (phones, ≤ 4 cores). */
export function detectStageTier(): StageTier { export function detectStageTier(): StageTier {
@@ -342,9 +346,43 @@ function Lights() {
); );
} }
function Effects({ tier }: { tier: StageTier }) { /** Watches frame times while the scene animates and reports slow or fast (adaptive tier, depth of field). */
function QualityWatcher({ onVerdict }: { onVerdict: (verdict: QualityVerdict) => void }) {
const { signals } = useSceneKit();
const [monitor] = useState(() => createQualityMonitor());
useFrame((_, delta) => {
if (signals.timerFrame) {
// Frames paced by the idle timer say nothing about speed; start the window over.
markTimerFrame(signals, false);
monitor.push(Number.POSITIVE_INFINITY);
return;
}
const verdict = monitor.push(delta);
if (verdict) onVerdict(verdict);
});
return null;
}
/** Bokeh of the depth of field in focus mode; 0 in the rest view. */
const FOCUS_BOKEH = 2.4;
function Effects({ tier, depthOfField }: { tier: StageTier; depthOfField: boolean }) {
const { signals } = useSceneKit();
const dof = useRef<DepthOfFieldEffect>(null);
// Mounted for good once allowed (no composer rebuild and shader compile on the focus click);
// the focus point switches the blur on and off.
useFrame(() => {
if (!dof.current) return;
if (signals.focusPoint) setEffectTarget(dof.current, signals.focusPoint);
setEffectBokeh(dof.current, signals.focusPoint ? FOCUS_BOKEH : 0);
});
return ( return (
<EffectComposer multisampling={tier === "high" ? 4 : 0}> <EffectComposer multisampling={tier === "high" ? 4 : 0}>
{depthOfField ? (
<DepthOfField ref={dof} target={[0, 0, 0]} worldFocusRange={4} bokehScale={0} resolutionScale={0.5} />
) : (
<></>
)}
<Bloom <Bloom
mipmapBlur mipmapBlur
luminanceThreshold={0.7} luminanceThreshold={0.7}
@@ -359,14 +397,81 @@ function Effects({ tier }: { tier: StageTier }) {
); );
} }
export function HiveStage({ tier }: { tier: StageTier }) { const HAZE_VERTEX = /* glsl */ `
varying float vHeight;
varying float vFacing;
void main() {
vHeight = position.y;
vec4 view = modelViewMatrix * vec4(position, 1.0);
vec3 normalView = normalize(normalMatrix * normal);
vFacing = abs(dot(normalView, normalize(-view.xyz)));
gl_Position = projectionMatrix * view;
}
`;
const HAZE_FRAGMENT = /* glsl */ `
uniform vec3 uColor;
uniform float uHeight;
varying float vHeight;
varying float vFacing;
void main() {
float along = clamp(vHeight / uHeight + 0.5, 0.0, 1.0);
// Brightest in the middle of the cone, fading toward the floor and the apex.
float fade = smoothstep(0.0, 0.35, along) * (1.0 - smoothstep(0.75, 1.0, along));
gl_FragColor = vec4(uColor * pow(vFacing, 2.0) * fade * 0.07, 1.0);
}
`;
const HAZE_HEIGHT = 18;
/** A faint cone of cool light falling onto the hive from above and behind. */
function Haze() {
const { theme } = useSceneKit();
const material = useMemo(
() =>
new ShaderMaterial({
uniforms: { uColor: { value: theme.rim.clone() }, uHeight: { value: HAZE_HEIGHT } },
vertexShader: HAZE_VERTEX,
fragmentShader: HAZE_FRAGMENT,
transparent: true,
depthWrite: false,
blending: AdditiveBlending,
side: DoubleSide,
}),
[theme],
);
useEffect(() => () => material.dispose(), [material]);
return (
<mesh
position={[-1.5, HAZE_HEIGHT / 2 - 1, -3]}
rotation={[0.18, 0, 0.12]}
material={material}
renderOrder={-1}
layers={OVERLAY_LAYER}
raycast={() => {}}
>
<coneGeometry args={[6.5, HAZE_HEIGHT, 48, 1, true]} />
</mesh>
);
}
export interface HiveStageProps {
tier: StageTier;
/** Depth of field (used in focus mode): only when the device measured fast (≥ 55 fps). */
depthOfField: boolean;
onQuality: (verdict: QualityVerdict) => void;
}
export function HiveStage({ tier, depthOfField, onQuality }: HiveStageProps) {
return ( return (
<> <>
<Backdrop /> <Backdrop />
<Lights /> <Lights />
<Floor tier={tier} /> <Floor tier={tier} />
<Particles /> <Haze />
<Effects tier={tier} /> {tier === "high" ? <Particles /> : null}
<QualityWatcher onVerdict={onQuality} />
<Effects tier={tier} depthOfField={depthOfField && tier === "high"} />
</> </>
); );
} }
@@ -1,17 +1,22 @@
import { AdditiveBlending, Color, DoubleSide, NormalBlending, ShaderMaterial } from "three"; import { AdditiveBlending, Color, DoubleSide, NormalBlending, ShaderMaterial } from "three";
import type { CubeTexture } from "three";
import type { HoloTheme } from "./theme"; import type { HoloTheme } from "./theme";
// Hand-written shaders instead of transmission: a fake studio environment // Hand-written shaders instead of transmission. The glass samples a small
// (white soft box above, blue rim light behind, faint cool bounce below) with // procedural studio cube map (scene-env.ts) for reflections, a dispersive
// fresnel gives the glass look at a fraction of the cost (spec 9.9). // refraction of it for body, blurred mips for frosted inner faces and its hot
// spots for glints; without the cube map it falls back to an analytic studio.
// One draw call per part either way (spec 9.9).
const GLASS_VERTEX = /* glsl */ ` const GLASS_VERTEX = /* glsl */ `
varying vec3 vNormalW; varying vec3 vNormalW;
varying vec3 vViewDir; varying vec3 vViewDir;
varying vec3 vLocal;
void main() { void main() {
vec4 world = modelMatrix * vec4(position, 1.0); vec4 world = modelMatrix * vec4(position, 1.0);
vNormalW = normalize(mat3(modelMatrix) * normal); vNormalW = normalize(mat3(modelMatrix) * normal);
vViewDir = cameraPosition - world.xyz; vViewDir = cameraPosition - world.xyz;
vLocal = position;
gl_Position = projectionMatrix * viewMatrix * world; gl_Position = projectionMatrix * viewMatrix * world;
} }
`; `;
@@ -20,31 +25,71 @@ const GLASS_FRAGMENT = /* glsl */ `
uniform vec3 uTint; uniform vec3 uTint;
uniform vec3 uRim; uniform vec3 uRim;
uniform vec3 uBounce; uniform vec3 uBounce;
uniform vec3 uSweepColor;
uniform float uOpacity; uniform float uOpacity;
uniform float uFrost; uniform float uFrost;
uniform float uGlow; uniform float uGlow;
uniform samplerCube uEnv;
uniform float uEnvStrength;
uniform float uDispersion;
uniform float uSweep;
uniform float uHeight;
varying vec3 vNormalW; varying vec3 vNormalW;
varying vec3 vViewDir; varying vec3 vViewDir;
void main() { varying vec3 vLocal;
vec3 n = normalize(vNormalW);
vec3 v = normalize(vViewDir); vec3 analyticStudio(vec3 r) {
if (!gl_FrontFacing) n = -n;
float ndv = clamp(abs(dot(n, v)), 0.0, 1.0);
float fresnel = pow(1.0 - ndv, 3.0);
vec3 r = reflect(-v, n);
float sky = smoothstep(-0.1, 0.9, r.y); float sky = smoothstep(-0.1, 0.9, r.y);
float ground = smoothstep(0.05, -0.7, r.y); float ground = smoothstep(0.05, -0.7, r.y);
float softbox = pow(max(dot(r, normalize(vec3(-0.35, 0.8, -0.5))), 0.0), 28.0); float softbox = pow(max(dot(r, normalize(vec3(-0.35, 0.8, -0.5))), 0.0), 28.0);
float rimBack = pow(max(dot(r, normalize(vec3(0.25, 0.2, -1.0))), 0.0), 5.0); float rimBack = pow(max(dot(r, normalize(vec3(0.25, 0.2, -1.0))), 0.0), 5.0);
vec3 env = uRim * (0.3 * sky + 0.85 * rimBack) + uBounce * 0.35 * ground + vec3(1.0) * softbox * 1.3; return uRim * (0.3 * sky + 0.85 * rimBack) + uBounce * 0.35 * ground + vec3(1.0) * softbox * 1.3;
vec3 color = uTint * (0.16 + uFrost * 0.3) + env * (0.25 + 0.75 * fresnel); }
color += uBounce * 0.08 * smoothstep(0.5, -0.9, n.y);
float alpha = 0.085 + uFrost * 0.16 + fresnel * 0.55 + softbox * 0.45; void main() {
if (!gl_FrontFacing) { vec3 n = normalize(vNormalW);
// Faintly frosted inner faces. vec3 v = normalize(vViewDir);
bool back = !gl_FrontFacing;
if (back) n = -n;
float ndv = clamp(abs(dot(n, v)), 0.0, 1.0);
float fresnel = pow(1.0 - ndv, 3.0);
vec3 r = reflect(-v, n);
// Reflection: sharp on the outside, blurred (frosted) on the inner faces.
vec3 cubeReflection = textureCube(uEnv, r, back ? 4.0 : 0.0).rgb;
vec3 reflection = mix(analyticStudio(r), cubeReflection, uEnvStrength);
// Body: the studio seen through the glass, slightly split per channel.
vec3 refraction = vec3(
textureCube(uEnv, refract(-v, n, 1.0 / (1.5 - uDispersion)), 2.0).r,
textureCube(uEnv, refract(-v, n, 1.0 / 1.5), 2.0).g,
textureCube(uEnv, refract(-v, n, 1.0 / (1.5 + uDispersion)), 2.0).b
) * uEnvStrength;
// Glints where the chamfers catch the hot spots.
float glint = smoothstep(1.6, 4.0, dot(cubeReflection, vec3(0.333))) * uEnvStrength;
vec3 color = uTint * (0.14 + uFrost * 0.3)
+ reflection * (0.22 + 0.78 * fresnel)
+ mix(refraction, vec3(dot(refraction, vec3(0.333))), 0.6) * 0.09
+ vec3(1.0) * glint * 0.9;
float alpha = 0.08 + uFrost * 0.16 + fresnel * 0.55 + glint * 0.5
+ dot(refraction, vec3(0.03));
if (back) {
alpha *= 0.6; alpha *= 0.6;
color = mix(color, uTint * 0.45, 0.35); color = mix(color, uTint * 0.45, 0.35);
} }
// Hover: a holographic shimmer sweeps across once and a thin scan line climbs the part.
if (uSweep > 0.0) {
float height = max(uHeight, 1e-3);
float y = clamp(vLocal.y / height + 0.5, 0.0, 1.0);
float diagonal = y * 0.75 + vLocal.x * 0.08;
float progress = uSweep * 2.2 - 0.6;
float band = exp(-pow((diagonal - progress) * 7.0, 2.0)) * (1.0 - smoothstep(0.8, 1.0, uSweep));
float scan = (1.0 - smoothstep(0.0, 0.012, abs(y - uSweep))) * step(uSweep, 0.999);
color += uSweepColor * (band * 0.55 + scan * 1.4);
alpha += band * 0.18 + scan * 0.45;
}
color *= 0.78 + 0.22 * uGlow; color *= 0.78 + 0.22 * uGlow;
gl_FragColor = vec4(color, clamp(alpha, 0.0, 1.0) * uOpacity); gl_FragColor = vec4(color, clamp(alpha, 0.0, 1.0) * uOpacity);
} }
@@ -53,6 +98,10 @@ void main() {
export interface GlassOptions { export interface GlassOptions {
/** 0 = clear, 1 = milky (insulation, divider board). */ /** 0 = clear, 1 = milky (insulation, divider board). */
frost?: number; frost?: number;
/** Studio cube map; without it the analytic studio is used. */
env?: CubeTexture | null;
/** Colour split of the refraction; 0 on the low tier. */
dispersion?: number;
} }
export function createGlassMaterial(theme: HoloTheme, options: GlassOptions = {}): ShaderMaterial { export function createGlassMaterial(theme: HoloTheme, options: GlassOptions = {}): ShaderMaterial {
@@ -61,9 +110,15 @@ export function createGlassMaterial(theme: HoloTheme, options: GlassOptions = {}
uTint: { value: theme.tint.clone() }, uTint: { value: theme.tint.clone() },
uRim: { value: theme.rim.clone() }, uRim: { value: theme.rim.clone() },
uBounce: { value: theme.bounce.clone() }, uBounce: { value: theme.bounce.clone() },
uSweepColor: { value: theme.edgeStrong.clone() },
uOpacity: { value: 1 }, uOpacity: { value: 1 },
uFrost: { value: options.frost ?? 0 }, uFrost: { value: options.frost ?? 0 },
uGlow: { value: 1 }, uGlow: { value: 1 },
uEnv: { value: options.env ?? null },
uEnvStrength: { value: options.env ? 1 : 0 },
uDispersion: { value: options.dispersion ?? 0.03 },
uSweep: { value: 0 },
uHeight: { value: 1 },
}, },
vertexShader: GLASS_VERTEX, vertexShader: GLASS_VERTEX,
fragmentShader: GLASS_FRAGMENT, fragmentShader: GLASS_FRAGMENT,
@@ -77,12 +132,23 @@ export function createGlassMaterial(theme: HoloTheme, options: GlassOptions = {}
const EDGE_VERTEX = /* glsl */ ` const EDGE_VERTEX = /* glsl */ `
attribute float aHot; attribute float aHot;
attribute float aStrength; attribute float aStrength;
attribute float aAlong;
attribute float aPhase;
varying float vHot; varying float vHot;
varying float vStrength; varying float vStrength;
varying float vAlong;
varying float vPhase;
varying float vFacing;
void main() { void main() {
vHot = aHot; vHot = aHot;
vStrength = aStrength; vStrength = aStrength;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); vAlong = aAlong;
vPhase = aPhase;
vec4 view = modelViewMatrix * vec4(position, 1.0);
// Tube normal in view space: faces toward the camera form the white-hot core.
vec3 normalView = normalize(normalMatrix * normal);
vFacing = abs(dot(normalView, normalize(-view.xyz)));
gl_Position = projectionMatrix * view;
} }
`; `;
@@ -91,17 +157,26 @@ uniform vec3 uCyan;
uniform vec3 uHot; uniform vec3 uHot;
uniform float uIntensity; uniform float uIntensity;
uniform float uOpacity; uniform float uOpacity;
uniform float uTime;
uniform float uPulse;
varying float vHot; varying float vHot;
varying float vStrength; varying float vStrength;
varying float vAlong;
varying float vPhase;
varying float vFacing;
void main() { void main() {
float hot = vHot * vHot; float hot = vHot * vHot;
vec3 color = mix(uCyan, uHot, hot); float core = pow(vFacing, 4.0);
float energy = uIntensity * vStrength * (1.0 + hot * 1.6); vec3 color = mix(uCyan, uHot, clamp(max(hot, core * 0.75), 0.0, 1.0));
// Slow light pulses travelling along the edges (motion full only).
float phase = fract(vAlong * 0.22 - uTime * 0.12 + vPhase);
float pulse = smoothstep(0.0, 0.035, phase) * (1.0 - smoothstep(0.035, 0.14, phase)) * uPulse;
float energy = uIntensity * vStrength * (0.55 + 0.45 * core + hot * 1.6 + pulse * 1.3);
gl_FragColor = vec4(color * energy * uOpacity, 1.0); gl_FragColor = vec4(color * energy * uOpacity, 1.0);
} }
`; `;
/** Glowing edges: additive, white-hot at the corners, electric cyan along the edge. */ /** Glowing edges: additive, white-hot core and corners, electric cyan rim, slow pulses at `full`. */
export function createEdgeMaterial(theme: HoloTheme): ShaderMaterial { export function createEdgeMaterial(theme: HoloTheme): ShaderMaterial {
return new ShaderMaterial({ return new ShaderMaterial({
uniforms: { uniforms: {
@@ -109,6 +184,8 @@ export function createEdgeMaterial(theme: HoloTheme): ShaderMaterial {
uHot: { value: theme.hot.clone() }, uHot: { value: theme.hot.clone() },
uIntensity: { value: 1.6 }, uIntensity: { value: 1.6 },
uOpacity: { value: 1 }, uOpacity: { value: 1 },
uTime: { value: 0 },
uPulse: { value: 0 },
}, },
vertexShader: EDGE_VERTEX, vertexShader: EDGE_VERTEX,
fragmentShader: EDGE_FRAGMENT, fragmentShader: EDGE_FRAGMENT,
@@ -118,6 +195,49 @@ export function createEdgeMaterial(theme: HoloTheme): ShaderMaterial {
}); });
} }
const CAUSTIC_VERTEX = /* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const CAUSTIC_FRAGMENT = /* glsl */ `
uniform vec3 uColor;
uniform float uTime;
uniform float uStrength;
uniform vec2 uScale;
varying vec2 vUv;
void main() {
vec2 p = vUv * uScale;
float v = sin(p.x + uTime) + sin((p.y + uTime) * 0.5) + sin((p.x + p.y + uTime) * 0.5);
vec2 q = p + vec2(sin(uTime / 3.0), cos(uTime / 2.0)) * 2.0;
v += sin(sqrt(q.x * q.x + q.y * q.y + 1.0) + uTime);
float caustic = pow(1.0 - abs(sin(v * 1.7)), 6.0);
float edge = smoothstep(0.0, 0.08, vUv.x) * smoothstep(0.0, 0.08, 1.0 - vUv.x)
* smoothstep(0.0, 0.08, vUv.y) * smoothstep(0.0, 0.08, 1.0 - vUv.y);
gl_FragColor = vec4(uColor * caustic * edge * uStrength, 1.0);
}
`;
/** Soft golden caustics on the floor under a liquid; additive, animated only at `full`. */
export function createCausticMaterial(color: Color, scale: [number, number]): ShaderMaterial {
return new ShaderMaterial({
uniforms: {
uColor: { value: color.clone() },
uTime: { value: 0 },
uStrength: { value: 0.9 },
uScale: { value: scale },
},
vertexShader: CAUSTIC_VERTEX,
fragmentShader: CAUSTIC_FRAGMENT,
transparent: true,
depthWrite: false,
blending: AdditiveBlending,
});
}
const GRID_VERTEX = /* glsl */ ` const GRID_VERTEX = /* glsl */ `
varying vec3 vLocal; varying vec3 vLocal;
void main() { void main() {
@@ -5,7 +5,10 @@ import type { MutableRefObject, ReactNode } from "react";
import { useFrame, useThree } from "@react-three/fiber"; import { useFrame, useThree } from "@react-three/fiber";
import type { Group } from "three"; import type { Group } from "three";
import type { Vec3 } from "@/lib/hive/layout"; import type { Vec3 } from "@/lib/hive/layout";
import { createRigPointer } from "./rig-pointer";
import type { RigInput } from "./rig-pointer";
import { useSceneKit } from "./scene-kit"; import { useSceneKit } from "./scene-kit";
import { capturePointer, markTimerFrame, releasePointer } from "./scene-mutations";
import { MAX_FRAME_SECONDS } from "./use-animated-node"; import { MAX_FRAME_SECONDS } from "./use-animated-node";
import { stepToward } from "./use-hive-motion"; import { stepToward } from "./use-hive-motion";
@@ -15,28 +18,11 @@ import { stepToward } from "./use-hive-motion";
// springs back. Vertical swipes scroll the page (touch-action: pan-y). // springs back. Vertical swipes scroll the page (touch-action: pan-y).
// - at `full` only: idle float ±4 mm over 6 s and a pointer tilt ≤ 6°. // - at `full` only: idle float ±4 mm over 6 s and a pointer tilt ≤ 6°.
export const MAX_TURN = (60 * Math.PI) / 180; export { MAX_TILT, MAX_TURN, SPRING_BACK_MS, clampTurn } from "./rig-pointer";
export const MAX_TILT = (6 * Math.PI) / 180;
export const IDLE_FLOAT = 0.04; export const IDLE_FLOAT = 0.04;
export const IDLE_PERIOD_SECONDS = 6; export const IDLE_PERIOD_SECONDS = 6;
export const SPRING_BACK_MS = 4000;
/** Idle motion asks for frames at about 30 fps, which is plenty for ±4 mm. */ /** Idle motion asks for frames at about 30 fps, which is plenty for ±4 mm. */
export const IDLE_FRAME_MS = 33; export const IDLE_FRAME_MS = 33;
/** Pixels of horizontal travel before a press counts as a drag. */
const DRAG_START_PX = 8;
/** A drag across the full card width turns the model by this much. */
const TURN_PER_WIDTH = Math.PI;
export function clampTurn(angle: number): number {
return Math.max(-MAX_TURN, Math.min(MAX_TURN, angle));
}
interface RigInput {
turn: number;
tiltX: number;
tiltY: number;
dragging: boolean;
}
function useRigInput(enabledTilt: boolean): MutableRefObject<RigInput> { function useRigInput(enabledTilt: boolean): MutableRefObject<RigInput> {
const gl = useThree((state) => state.gl); const gl = useThree((state) => state.gl);
@@ -46,64 +32,31 @@ function useRigInput(enabledTilt: boolean): MutableRefObject<RigInput> {
useEffect(() => { useEffect(() => {
const element = gl.domElement as HTMLElement | undefined; const element = gl.domElement as HTMLElement | undefined;
if (!element || typeof element.addEventListener !== "function") return; if (!element || typeof element.addEventListener !== "function") return;
let press: { x: number; y: number; turn: number; id: number } | null = null; const pointer = createRigPointer(input.current, {
let springBack: ReturnType<typeof setTimeout> | null = null; tilt: enabledTilt,
rect: () => element.getBoundingClientRect(),
const onDown = (event: PointerEvent) => { // Captured while dragging, so a release outside the canvas still ends the drag.
press = { x: event.clientX, y: event.clientY, turn: input.current.turn, id: event.pointerId }; capture: (id) => capturePointer(element, id),
if (springBack) clearTimeout(springBack); release: (id) => releasePointer(element, id),
}; changed: invalidate,
const onMove = (event: PointerEvent) => { });
const rect = element.getBoundingClientRect(); const onDown = (event: PointerEvent) => pointer.down(event);
if (press && press.id === event.pointerId) { const onMove = (event: PointerEvent) => pointer.move(event);
const dx = event.clientX - press.x; const onUp = (event: PointerEvent) => pointer.up(event);
const dy = event.clientY - press.y; const onLeave = () => pointer.leave();
if (!input.current.dragging && Math.abs(dx) > DRAG_START_PX && Math.abs(dx) > Math.abs(dy)) {
input.current.dragging = true;
}
if (input.current.dragging) {
input.current.turn = clampTurn(press.turn + (dx / Math.max(rect.width, 1)) * TURN_PER_WIDTH);
invalidate();
}
return;
}
if (enabledTilt && event.pointerType === "mouse" && rect.width > 0 && rect.height > 0) {
const nx = ((event.clientX - rect.left) / rect.width) * 2 - 1;
const ny = ((event.clientY - rect.top) / rect.height) * 2 - 1;
input.current.tiltY = nx * MAX_TILT;
input.current.tiltX = ny * MAX_TILT * 0.5;
invalidate();
}
};
const onUp = () => {
if (input.current.dragging) {
springBack = setTimeout(() => {
input.current.turn = 0;
invalidate();
}, SPRING_BACK_MS);
}
press = null;
// Let the click that follows a drag see `dragging`, then clear it.
setTimeout(() => {
input.current.dragging = false;
}, 0);
};
const onLeave = () => {
input.current.tiltX = 0;
input.current.tiltY = 0;
invalidate();
};
element.addEventListener("pointerdown", onDown); element.addEventListener("pointerdown", onDown);
element.addEventListener("pointermove", onMove); element.addEventListener("pointermove", onMove);
element.addEventListener("pointerup", onUp); element.addEventListener("pointerup", onUp);
element.addEventListener("pointercancel", onUp); element.addEventListener("pointercancel", onUp);
element.addEventListener("lostpointercapture", onUp);
element.addEventListener("pointerleave", onLeave); element.addEventListener("pointerleave", onLeave);
return () => { return () => {
if (springBack) clearTimeout(springBack); pointer.dispose();
element.removeEventListener("pointerdown", onDown); element.removeEventListener("pointerdown", onDown);
element.removeEventListener("pointermove", onMove); element.removeEventListener("pointermove", onMove);
element.removeEventListener("pointerup", onUp); element.removeEventListener("pointerup", onUp);
element.removeEventListener("pointercancel", onUp); element.removeEventListener("pointercancel", onUp);
element.removeEventListener("lostpointercapture", onUp);
element.removeEventListener("pointerleave", onLeave); element.removeEventListener("pointerleave", onLeave);
}; };
}, [gl, invalidate, enabledTilt]); }, [gl, invalidate, enabledTilt]);
@@ -111,21 +64,37 @@ function useRigInput(enabledTilt: boolean): MutableRefObject<RigInput> {
return input; return input;
} }
/** Asks for a frame about every 33 ms while the idle motion runs (demand frame loop). */ /** Seconds of continuous idle frames after mount, so the quality monitor can measure the device. */
function useIdleTicker(active: boolean): void { export const QUALITY_PROBE_SECONDS = 4;
/**
* Asks for frames (demand frame loop): continuously during the quality probe
* while the card is on screen (any motion level, so a weak device also drops
* from the high tier), then about every 33 ms while the idle motion runs
* (marked as timer frames).
*/
function useIdleTicker(active: boolean, probing: boolean): void {
const invalidate = useThree((state) => state.invalidate); const invalidate = useThree((state) => state.invalidate);
const { signals } = useSceneKit();
const timer = useRef<ReturnType<typeof setTimeout> | null>(null); const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const probe = useRef(0);
useEffect(() => { useEffect(() => {
if (active) invalidate(); if (active || probing) invalidate();
return () => { return () => {
if (timer.current) clearTimeout(timer.current); if (timer.current) clearTimeout(timer.current);
timer.current = null; timer.current = null;
}; };
}, [active, invalidate]); }, [active, probing, invalidate]);
useFrame(() => { useFrame((_, delta) => {
if (probing && probe.current < QUALITY_PROBE_SECONDS) {
probe.current += Math.min(delta, MAX_FRAME_SECONDS);
invalidate();
return;
}
if (!active || timer.current !== null) return; if (!active || timer.current !== null) return;
timer.current = setTimeout(() => { timer.current = setTimeout(() => {
timer.current = null; timer.current = null;
markTimerFrame(signals, true);
invalidate(); invalidate();
}, IDLE_FRAME_MS); }, IDLE_FRAME_MS);
}); });
@@ -136,10 +105,12 @@ export interface ModelRigProps {
getPivot: () => Vec3; getPivot: () => Vec3;
/** Idle motion may run (motion `full`, card visible, tab visible). */ /** Idle motion may run (motion `full`, card visible, tab visible). */
idle: boolean; idle: boolean;
/** Card and tab visible: the quality probe may run (any motion level). */
onScreen?: boolean;
children: ReactNode; children: ReactNode;
} }
export function ModelRig({ getPivot, idle, children }: ModelRigProps) { export function ModelRig({ getPivot, idle, onScreen = false, children }: ModelRigProps) {
const { motion } = useSceneKit(); const { motion } = useSceneKit();
const invalidate = useThree((state) => state.invalidate); const invalidate = useThree((state) => state.invalidate);
const full = motion === "full"; const full = motion === "full";
@@ -147,7 +118,7 @@ export function ModelRig({ getPivot, idle, children }: ModelRigProps) {
const outer = useRef<Group>(null); const outer = useRef<Group>(null);
const inner = useRef<Group>(null); const inner = useRef<Group>(null);
const current = useRef({ turn: 0, tiltX: 0, tiltY: 0, float: 0, time: 0 }); const current = useRef({ turn: 0, tiltX: 0, tiltY: 0, float: 0, time: 0 });
useIdleTicker(idle && full); useIdleTicker(idle && full, onScreen || (idle && full));
useFrame((_, delta) => { useFrame((_, delta) => {
if (!outer.current || !inner.current) return; if (!outer.current || !inner.current) return;
@@ -169,4 +169,16 @@ describe("geometry builders", () => {
expect(min).toBe(0); expect(min).toBe(0);
geometry.dispose(); geometry.dispose();
}); });
it("gives the edge tubes unit normals, a distance along the edge and a phase per edge", () => {
const geometry = buildEdgeGeometry([{ a: [0, 0, 0], b: [2, 0, 0], hotA: 1, hotB: 1, strength: 1 }]);
const normal = geometry.getAttribute("normal");
const along = geometry.getAttribute("aAlong");
expect(Math.hypot(normal.getX(0), normal.getY(0), normal.getZ(0))).toBeCloseTo(1, 6);
expect(normal.getX(0)).toBeCloseTo(0, 6);
expect(along.getX(0)).toBe(0);
expect(along.getX(along.count - 1)).toBeCloseTo(2, 6);
expect(geometry.getAttribute("aPhase").getX(0)).toBeGreaterThanOrEqual(0);
geometry.dispose();
});
}); });
@@ -517,14 +517,21 @@ function perpendicular(direction: Vec3): [Vec3, Vec3] {
return [u, normalize(cross(u, direction))]; return [u, normalize(cross(u, direction))];
} }
/** Thin square tubes along every edge, with `aHot` (corner glow) and `aStrength` attributes. */ /**
* Thin square tubes along every edge: `normal` (round tube normals for the
* white-hot core), `aHot` (corner glow), `aStrength`, `aAlong` (distance along
* the edge) and `aPhase` (per edge) for the travelling pulses.
*/
export function buildEdgeGeometry(segments: EdgeSegment[], width: number = EDGE_WIDTH): BufferGeometry { export function buildEdgeGeometry(segments: EdgeSegment[], width: number = EDGE_WIDTH): BufferGeometry {
const positions: number[] = []; const positions: number[] = [];
const normals: number[] = [];
const hot: number[] = []; const hot: number[] = [];
const strength: number[] = []; const strength: number[] = [];
const along: number[] = [];
const phase: number[] = [];
const indices: number[] = []; const indices: number[] = [];
const half = width / 2; const half = width / 2;
for (const edge of segments) { for (const [segmentIndex, edge] of segments.entries()) {
const delta: Vec3 = [edge.b[0] - edge.a[0], edge.b[1] - edge.a[1], edge.b[2] - edge.a[2]]; const delta: Vec3 = [edge.b[0] - edge.a[0], edge.b[1] - edge.a[1], edge.b[2] - edge.a[2]];
const length = Math.hypot(delta[0], delta[1], delta[2]); const length = Math.hypot(delta[0], delta[1], delta[2]);
if (length < 1e-6) continue; if (length < 1e-6) continue;
@@ -538,6 +545,8 @@ export function buildEdgeGeometry(segments: EdgeSegment[], width: number = EDGE_
[1, edge.hotB], [1, edge.hotB],
]; ];
const base = positions.length / 3; const base = positions.length / 3;
// Deterministic per-edge phase, so the travelling pulses do not march in step.
const edgePhase = (Math.sin(segmentIndex * 12.9898) * 43758.5453) % 1;
for (const [t, ringHot] of rings) { for (const [t, ringHot] of rings) {
for (const [su, sw] of [ for (const [su, sw] of [
[1, 1], [1, 1],
@@ -545,11 +554,21 @@ export function buildEdgeGeometry(segments: EdgeSegment[], width: number = EDGE_
[-1, -1], [-1, -1],
[1, -1], [1, -1],
]) { ]) {
const offset: Vec3 = [
(u[0] * su + w[0] * sw) * half,
(u[1] * su + w[1] * sw) * half,
(u[2] * su + w[2] * sw) * half,
];
const offsetLength = Math.hypot(offset[0], offset[1], offset[2]) || 1;
for (let axis = 0; axis < 3; axis += 1) { for (let axis = 0; axis < 3; axis += 1) {
positions.push(edge.a[axis] + delta[axis] * t + (u[axis] * su + w[axis] * sw) * half); positions.push(edge.a[axis] + delta[axis] * t + offset[axis]);
// Smooth tube normal: from the edge's axis out through the corner.
normals.push(offset[axis] / offsetLength);
} }
hot.push(ringHot); hot.push(ringHot);
strength.push(edge.strength); strength.push(edge.strength);
along.push(t * length);
phase.push(Math.abs(edgePhase));
} }
} }
for (let band = 0; band < 3; band += 1) { for (let band = 0; band < 3; band += 1) {
@@ -564,8 +583,11 @@ export function buildEdgeGeometry(segments: EdgeSegment[], width: number = EDGE_
} }
const geometry = new BufferGeometry(); const geometry = new BufferGeometry();
geometry.setAttribute("position", new Float32BufferAttribute(positions, 3)); geometry.setAttribute("position", new Float32BufferAttribute(positions, 3));
geometry.setAttribute("normal", new Float32BufferAttribute(normals, 3));
geometry.setAttribute("aHot", new Float32BufferAttribute(hot, 1)); geometry.setAttribute("aHot", new Float32BufferAttribute(hot, 1));
geometry.setAttribute("aStrength", new Float32BufferAttribute(strength, 1)); geometry.setAttribute("aStrength", new Float32BufferAttribute(strength, 1));
geometry.setAttribute("aAlong", new Float32BufferAttribute(along, 1));
geometry.setAttribute("aPhase", new Float32BufferAttribute(phase, 1));
geometry.setIndex(indices); geometry.setIndex(indices);
geometry.computeBoundingSphere(); geometry.computeBoundingSphere();
return geometry; return geometry;
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import { createQualityMonitor, springStep } from "./quality";
describe("createQualityMonitor", () => {
const feed = (monitor: ReturnType<typeof createQualityMonitor>, fps: number, seconds: number) => {
const verdicts = [];
for (let time = 0; time < seconds; time += 1 / fps) verdicts.push(monitor.push(1 / fps));
return verdicts.filter((verdict) => verdict !== null);
};
it("reports slow after two seconds of rendering below 45 fps", () => {
const monitor = createQualityMonitor();
expect(feed(monitor, 30, 1.9)).toEqual([]);
expect(feed(monitor, 30, 0.3)).toEqual(["slow"]);
});
it("reports fast after two seconds at 60 fps or more", () => {
expect(feed(createQualityMonitor(), 120, 2.1)).toEqual(["fast"]);
});
it("counts a 60 Hz screen (about 59.9 fps) as fast", () => {
expect(feed(createQualityMonitor(), 59.9, 2.2)).toEqual(["fast"]);
});
it("stays silent between the two thresholds", () => {
expect(feed(createQualityMonitor(), 50, 4.5)).toEqual([]);
});
it("ignores pauses of the demand frame loop instead of counting them as slow frames", () => {
const monitor = createQualityMonitor();
feed(monitor, 120, 1);
expect(monitor.push(3)).toBeNull();
// The window restarts after the pause: one more second is not enough.
expect(feed(monitor, 120, 1)).toEqual([]);
expect(feed(monitor, 120, 1.1)).toEqual(["fast"]);
});
});
describe("springStep", () => {
it("overshoots the target slightly and settles", () => {
let state = { value: 0, velocity: 0 };
let peak = 0;
for (let frame = 0; frame < 120; frame += 1) {
state = springStep(state.value, state.velocity, 1, 1 / 60);
peak = Math.max(peak, state.value);
}
expect(peak).toBeGreaterThan(1.005);
expect(peak).toBeLessThan(1.08);
expect(state).toEqual({ value: 1, velocity: 0 });
});
it("is most of the way there after about 350 ms", () => {
let state = { value: 0, velocity: 0 };
for (let frame = 0; frame < 21; frame += 1) state = springStep(state.value, state.velocity, 1, 1 / 60);
expect(state.value).toBeGreaterThan(0.9);
});
it("is frame-rate independent enough for a long frame", () => {
const state = springStep(0, 0, 1, 1 / 15);
expect(state.value).toBeGreaterThan(0);
expect(state.value).toBeLessThan(1.1);
});
});
@@ -0,0 +1,78 @@
// Adaptive quality and the flight spring, as pure functions.
/** High: reflective floor, MSAA, particles, dispersion, depth of field when fast. Low: none of these. */
export type StageTier = "high" | "low";
/** Below this the scene drops to the low tier (no reflector, MSAA, depth of field, particles). */
export const SLOW_FPS = 45;
/** At or above this depth of field may run in focus mode (55, so a 60 Hz screen measuring ~59.9 counts). */
export const FAST_FPS = 55;
/** Seconds of continuous rendering per verdict. */
export const QUALITY_WINDOW_SECONDS = 2;
/** A longer gap is a pause of the demand frame loop, not a slow frame. */
const PAUSE_SECONDS = 0.25;
export type QualityVerdict = "slow" | "fast";
export interface QualityMonitor {
/** One rendered frame with its delta; returns a verdict when a window completes. */
push(deltaSeconds: number): QualityVerdict | null;
}
/**
* Watches frame times while the scene actually renders (demand loop: flights,
* idle motion). Equivalent to drei's PerformanceMonitor, without counting the
* pauses between demand frames as slow frames.
*/
export function createQualityMonitor(
slowFps: number = SLOW_FPS,
fastFps: number = FAST_FPS,
windowSeconds: number = QUALITY_WINDOW_SECONDS,
): QualityMonitor {
let time = 0;
let frames = 0;
return {
push(delta) {
if (!(delta > 0) || delta > PAUSE_SECONDS) {
time = 0;
frames = 0;
return null;
}
time += delta;
frames += 1;
if (time < windowSeconds) return null;
const fps = frames / time;
time = 0;
frames = 0;
if (fps < slowFps) return "slow";
if (fps >= fastFps) return "fast";
return null;
},
};
}
/** Spring for flights at motion `full`: a touch of overshoot (damping ratio ≈ 0.72), settled in ~0.5 s. */
export const SPRING_STIFFNESS = 210;
export const SPRING_DAMPING = 21;
const SPRING_SUBSTEP = 1 / 120;
const SPRING_EPSILON = 1e-3;
export interface SpringState {
value: number;
velocity: number;
}
/** One frame of a damped spring toward `target` (semi-implicit Euler with small substeps). */
export function springStep(value: number, velocity: number, target: number, deltaSeconds: number): SpringState {
let x = value;
let v = velocity;
let left = Math.max(0, deltaSeconds);
while (left > 0) {
const dt = Math.min(SPRING_SUBSTEP, left);
v += (SPRING_STIFFNESS * (target - x) - SPRING_DAMPING * v) * dt;
x += v * dt;
left -= dt;
}
if (Math.abs(target - x) < SPRING_EPSILON && Math.abs(v) < SPRING_EPSILON * 10) return { value: target, velocity: 0 };
return { value: x, velocity: v };
}
@@ -0,0 +1,111 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MAX_TURN, SPRING_BACK_MS, createRigPointer } from "./rig-pointer";
import type { PointerSample, RigInput } from "./rig-pointer";
function setup(tilt = true) {
const input: RigInput = { turn: 0, tiltX: 0, tiltY: 0, dragging: false };
const host = {
tilt,
rect: () => ({ left: 0, top: 0, width: 400, height: 300 }),
capture: vi.fn(),
release: vi.fn(),
changed: vi.fn(),
};
const pointer = createRigPointer(input, host);
return { input, host, pointer };
}
const sample = (clientX: number, extra: Partial<PointerSample> = {}): PointerSample => ({
clientX,
clientY: 100,
pointerId: 1,
pointerType: "mouse",
buttons: 1,
...extra,
});
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe("createRigPointer", () => {
it("turns on a horizontal drag and captures the pointer, so the release is seen outside the canvas", () => {
const { input, host, pointer } = setup();
pointer.down(sample(100));
pointer.move(sample(104));
expect(input.dragging).toBe(false);
pointer.move(sample(160));
expect(input.dragging).toBe(true);
expect(host.capture).toHaveBeenCalledWith(1);
expect(input.turn).toBeGreaterThan(0);
});
it("clamps the turn to ±60°", () => {
const { input, pointer } = setup();
pointer.down(sample(0));
pointer.move(sample(2000));
expect(input.turn).toBeCloseTo(MAX_TURN, 9);
});
it("ends a mouse drag whose button was released outside (buttons = 0) and springs back after 4 s", () => {
const { input, host, pointer } = setup();
pointer.down(sample(100));
pointer.move(sample(200));
expect(input.dragging).toBe(true);
// The pointer comes back over the canvas with no button pressed.
pointer.move(sample(260, { buttons: 0 }));
vi.advanceTimersByTime(1);
expect(input.dragging).toBe(false);
expect(host.release).toHaveBeenCalledWith(1);
const turned = input.turn;
expect(turned).toBeGreaterThan(0);
vi.advanceTimersByTime(SPRING_BACK_MS);
expect(input.turn).toBe(0);
});
it("springs back 4 s after a normal release, and a new press cancels it", () => {
const { input, pointer } = setup();
pointer.down(sample(100));
pointer.move(sample(200));
pointer.up(sample(200));
vi.advanceTimersByTime(SPRING_BACK_MS - 100);
pointer.down(sample(200));
vi.advanceTimersByTime(500);
expect(input.turn).toBeGreaterThan(0);
});
it("keeps dragging true until the click after the drag has run", () => {
const { input, pointer } = setup();
pointer.down(sample(100));
pointer.move(sample(200));
pointer.up(sample(200));
expect(input.dragging).toBe(true);
vi.advanceTimersByTime(0);
expect(input.dragging).toBe(false);
});
it("tilts toward a hovering mouse only when tilt is enabled, and levels on leave", () => {
const tilted = setup(true);
tilted.pointer.move(sample(400, { buttons: 0 }));
expect(tilted.input.tiltY).toBeGreaterThan(0);
tilted.pointer.leave();
expect(tilted.input.tiltY).toBe(0);
const still = setup(false);
still.pointer.move(sample(400, { buttons: 0 }));
expect(still.input.tiltY).toBe(0);
});
it("clears its timers on dispose", () => {
const { input, pointer } = setup();
pointer.down(sample(100));
pointer.move(sample(200));
pointer.up(sample(200));
pointer.dispose();
vi.advanceTimersByTime(SPRING_BACK_MS);
expect(input.turn).toBeGreaterThan(0);
});
});
@@ -0,0 +1,118 @@
// Pointer handling of the model rig (spec 9.4), without DOM or three.js so it
// can be tested: a horizontal drag turns the model ±60° (pointer captured, so
// a release outside the canvas still ends it), 4 s after the drag it springs
// back; a hovering mouse tilts it slightly (motion `full`).
export const MAX_TURN = (60 * Math.PI) / 180;
export const MAX_TILT = (6 * Math.PI) / 180;
export const SPRING_BACK_MS = 4000;
/** Pixels of horizontal travel before a press counts as a drag. */
const DRAG_START_PX = 8;
/** A drag across the full card width turns the model by this much. */
const TURN_PER_WIDTH = Math.PI;
export interface RigInput {
turn: number;
tiltX: number;
tiltY: number;
dragging: boolean;
}
export interface PointerSample {
clientX: number;
clientY: number;
pointerId: number;
pointerType: string;
/** Pressed buttons; 0 = none (a mouse released outside the canvas). */
buttons: number;
}
export interface RigPointerHost {
/** Pointer tilt allowed (motion `full`). */
tilt: boolean;
rect(): { left: number; top: number; width: number; height: number };
capture(pointerId: number): void;
release(pointerId: number): void;
/** Something changed: request a frame. */
changed(): void;
}
export interface RigPointer {
down(event: PointerSample): void;
move(event: PointerSample): void;
up(event?: Pick<PointerSample, "pointerId">): void;
leave(): void;
dispose(): void;
}
export function clampTurn(angle: number): number {
return Math.max(-MAX_TURN, Math.min(MAX_TURN, angle));
}
export function createRigPointer(input: RigInput, host: RigPointerHost): RigPointer {
let press: { x: number; y: number; turn: number; id: number; captured: boolean } | null = null;
let springBack: ReturnType<typeof setTimeout> | null = null;
let clearDrag: ReturnType<typeof setTimeout> | null = null;
const pointer: RigPointer = {
down(event) {
press = { x: event.clientX, y: event.clientY, turn: input.turn, id: event.pointerId, captured: false };
if (springBack) clearTimeout(springBack);
springBack = null;
},
move(event) {
if (press && press.id === event.pointerId) {
// The mouse button went up outside the canvas: end the drag now.
if (event.pointerType === "mouse" && event.buttons === 0) {
pointer.up(event);
return;
}
const dx = event.clientX - press.x;
const dy = event.clientY - press.y;
if (!input.dragging && Math.abs(dx) > DRAG_START_PX && Math.abs(dx) > Math.abs(dy)) {
input.dragging = true;
press.captured = true;
host.capture(press.id);
}
if (input.dragging) {
input.turn = clampTurn(press.turn + (dx / Math.max(host.rect().width, 1)) * TURN_PER_WIDTH);
host.changed();
}
return;
}
const rect = host.rect();
if (host.tilt && event.pointerType === "mouse" && rect.width > 0 && rect.height > 0) {
const nx = ((event.clientX - rect.left) / rect.width) * 2 - 1;
const ny = ((event.clientY - rect.top) / rect.height) * 2 - 1;
input.tiltY = nx * MAX_TILT;
input.tiltX = ny * MAX_TILT * 0.5;
host.changed();
}
},
up() {
if (!press) return;
if (press.captured) host.release(press.id);
if (input.dragging) {
springBack = setTimeout(() => {
input.turn = 0;
host.changed();
}, SPRING_BACK_MS);
}
press = null;
// Let the click that follows a drag see `dragging`, then clear it.
clearDrag = setTimeout(() => {
input.dragging = false;
}, 0);
},
leave() {
input.tiltX = 0;
input.tiltY = 0;
host.changed();
},
dispose() {
if (springBack) clearTimeout(springBack);
if (clearDrag) clearTimeout(clearDrag);
},
};
return pointer;
}
@@ -0,0 +1,82 @@
import {
BackSide,
BoxGeometry,
Color,
CubeCamera,
HalfFloatType,
LinearMipmapLinearFilter,
Mesh,
MeshBasicMaterial,
PlaneGeometry,
Scene,
WebGLCubeRenderTarget,
} from "three";
import type { CubeTexture, WebGLRenderer } from "three";
// A small procedural studio, rendered once into a cube map: dark navy room,
// a white soft box above, electric-blue rim strips behind, a cyan side strip
// and a few hot spots for glints on the chamfers. Cool light only (owner
// 2026-10-16); no HDR file is downloaded.
const ENV_SIZE = 128;
interface Lightformer {
size: [number, number];
position: [number, number, number];
color: string;
intensity: number;
}
const LIGHTFORMERS: Lightformer[] = [
{ size: [9, 6], position: [-1, 9.5, -2], color: "#ffffff", intensity: 2.6 },
{ size: [1.2, 12], position: [-3.5, 1, -9.5], color: "#5a9cff", intensity: 3 },
{ size: [1.2, 12], position: [3.5, 1, -9.5], color: "#5a9cff", intensity: 3 },
{ size: [0.7, 10], position: [9.5, 1, 1], color: "#5fd2ff", intensity: 2.4 },
{ size: [12, 3], position: [0, 3, 9.5], color: "#1b3f7a", intensity: 0.8 },
{ size: [14, 14], position: [0, -9.5, 0], color: "#0b1d3d", intensity: 1 },
{ size: [0.8, 0.8], position: [-6, 8, -6], color: "#ffffff", intensity: 9 },
{ size: [0.6, 0.6], position: [6, 7, 4], color: "#dff4ff", intensity: 7 },
];
export interface StudioEnvironment {
texture: CubeTexture;
dispose(): void;
}
/** Renders the studio once; null when the renderer cannot (test renderer, lost context). */
export function createStudioEnvironment(gl: WebGLRenderer): StudioEnvironment | null {
const scene = new Scene();
const room = new Mesh(new BoxGeometry(20, 20, 20), new MeshBasicMaterial({ color: "#050b19", side: BackSide }));
scene.add(room);
for (const light of LIGHTFORMERS) {
const material = new MeshBasicMaterial({
color: new Color(light.color).multiplyScalar(light.intensity),
toneMapped: false,
});
const panel = new Mesh(new PlaneGeometry(light.size[0], light.size[1]), material);
panel.position.set(...light.position);
// lookAt points the plane's +z (its front face) at the centre of the room.
panel.lookAt(0, 0, 0);
scene.add(panel);
}
const target = new WebGLCubeRenderTarget(ENV_SIZE, {
type: HalfFloatType,
generateMipmaps: true,
minFilter: LinearMipmapLinearFilter,
});
const camera = new CubeCamera(0.1, 40, target);
try {
camera.update(gl, scene);
} catch {
target.dispose();
return null;
} finally {
scene.traverse((object) => {
if (object instanceof Mesh) {
object.geometry.dispose();
(object.material as MeshBasicMaterial).dispose();
}
});
}
return { texture: target.texture, dispose: () => target.dispose() };
}
@@ -1,33 +1,71 @@
"use client"; "use client";
import { createContext, useContext, useEffect, useState } from "react"; import { createContext, useContext, useEffect, useMemo, useState } from "react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useThree } from "@react-three/fiber";
import { Mesh } from "three"; import { Mesh } from "three";
import type { CubeTexture } from "three";
import type { Vec3 } from "@/lib/hive/layout";
import type { MotionLevel } from "@/lib/motion/motion-preference"; import type { MotionLevel } from "@/lib/motion/motion-preference";
import { createFrameTextureCache } from "./frame-textures"; import { createFrameTextureCache } from "./frame-textures";
import type { FrameTextureCache } from "./frame-textures"; import type { FrameTextureCache } from "./frame-textures";
import { createGeometryCache } from "./part-geometry"; import { createGeometryCache } from "./part-geometry";
import type { GeometryCache } from "./part-geometry"; import type { GeometryCache } from "./part-geometry";
import type { StageTier } from "./quality";
import { createStudioEnvironment } from "./scene-env";
import { readHoloTheme } from "./theme"; import { readHoloTheme } from "./theme";
import type { HoloTheme } from "./theme"; import type { HoloTheme } from "./theme";
// Shared per canvas: theme (tokens read once), geometries per part kind and // Shared per canvas: theme (tokens read once), geometries per part kind and
// size, comb textures per frame type, the motion level. Everything is // size, comb textures per frame type, the studio cube map, motion level and
// disposed when the canvas unmounts (spec 9.9: no leak between colonies). // quality tier. Everything is disposed when the canvas unmounts (spec 9.9:
// no leak between colonies).
/** Mutable per-frame hand-offs between scene parts (never drive rendering on their own). */
export interface SceneSignals {
/** The coming frame was requested by the idle timer, not by an animation (quality monitor skips it). */
timerFrame: boolean;
/** World point the camera frames in focus mode (depth of field focuses there). */
focusPoint: Vec3 | null;
}
export interface SceneKit { export interface SceneKit {
theme: HoloTheme; theme: HoloTheme;
geometries: GeometryCache; geometries: GeometryCache;
textures: FrameTextureCache; textures: FrameTextureCache;
motion: MotionLevel; motion: MotionLevel;
tier: StageTier;
/** Studio reflections; null until rendered or where the renderer cannot. */
env: CubeTexture | null;
signals: SceneSignals;
} }
const SceneKitContext = createContext<SceneKit | null>(null); const SceneKitContext = createContext<SceneKit | null>(null);
export function SceneKitProvider({ motion, children }: { motion: MotionLevel; children: ReactNode }) { export interface SceneKitProviderProps {
motion: MotionLevel;
tier?: StageTier;
children: ReactNode;
}
export function SceneKitProvider({ motion, tier = "high", children }: SceneKitProviderProps) {
const gl = useThree((state) => state.gl);
const invalidate = useThree((state) => state.invalidate);
const [theme] = useState(readHoloTheme); const [theme] = useState(readHoloTheme);
const [geometries] = useState(createGeometryCache); const [geometries] = useState(createGeometryCache);
const [textures] = useState(() => createFrameTextureCache()); const [textures] = useState(() => createFrameTextureCache());
const [signals] = useState<SceneSignals>(() => ({ timerFrame: false, focusPoint: null }));
const [env, setEnv] = useState<CubeTexture | null>(null);
useEffect(() => {
const studio = createStudioEnvironment(gl);
// Rendered once after mount; the materials pick it up on the next render.
// eslint-disable-next-line react-hooks/set-state-in-effect -- the cube map needs the live renderer
setEnv(studio?.texture ?? null);
invalidate();
return () => studio?.dispose();
}, [gl, invalidate]);
useEffect( useEffect(
() => () => { () => () => {
geometries.dispose(); geometries.dispose();
@@ -35,9 +73,11 @@ export function SceneKitProvider({ motion, children }: { motion: MotionLevel; ch
}, },
[geometries, textures], [geometries, textures],
); );
return ( const kit = useMemo<SceneKit>(
<SceneKitContext.Provider value={{ theme, geometries, textures, motion }}>{children}</SceneKitContext.Provider> () => ({ theme, geometries, textures, motion, tier, env, signals }),
[theme, geometries, textures, motion, tier, env, signals],
); );
return <SceneKitContext.Provider value={kit}>{children}</SceneKitContext.Provider>;
} }
export function useSceneKit(): SceneKit { export function useSceneKit(): SceneKit {
@@ -1,6 +1,7 @@
import type { Camera, Color, Material, Mesh, Scene, ShaderMaterial, Texture, WebGLRenderer } from "three"; import type { Camera, Color, Material, Mesh, Scene, ShaderMaterial, Texture, WebGLRenderer } from "three";
import type { Vec3 } from "@/lib/hive/layout"; import type { Vec3 } from "@/lib/hive/layout";
import { cameraPosition } from "./camera-fit"; import { cameraPosition } from "./camera-fit";
import type { SceneSignals } from "./scene-kit";
// Imperative three.js updates used from frame callbacks and effects. They live // Imperative three.js updates used from frame callbacks and effects. They live
// outside the components on purpose: scene objects are mutable by design, // outside the components on purpose: scene objects are mutable by design,
@@ -17,6 +18,12 @@ export function setUniformColor(material: ShaderMaterial, name: string, color: C
if (uniform) (uniform.value as Color).copy(color); if (uniform) (uniform.value as Color).copy(color);
} }
/** Sets a colour uniform to the blend of `from` and `to` at `t` (0…1). */
export function setUniformMix(material: ShaderMaterial, name: string, from: Color, to: Color, t: number): void {
const uniform = material.uniforms[name];
if (uniform) (uniform.value as Color).copy(from).lerp(to, Math.min(1, Math.max(0, t)));
}
export function setMaterialOpacity(material: Material, opacity: number): void { export function setMaterialOpacity(material: Material, opacity: number): void {
material.opacity = opacity; material.opacity = opacity;
material.visible = opacity > 0.01; material.visible = opacity > 0.01;
@@ -29,6 +36,22 @@ export function placeCamera(camera: Camera, center: Vec3, distance: number): voi
camera.lookAt(center[0], center[1], center[2]); camera.lookAt(center[0], center[1], center[2]);
} }
/** Shifts the projection vertically by `offsetY` pixels (0 clears it). */
export function offsetView(camera: Camera, width: number, height: number, offsetY: number): void {
if (!("setViewOffset" in camera) || !(width > 0) || !(height > 0)) return;
const perspective = camera as Camera & {
setViewOffset(fw: number, fh: number, x: number, y: number, w: number, h: number): void;
clearViewOffset(): void;
view: { offsetY: number; enabled: boolean } | null;
};
if (Math.abs(offsetY) < 0.5) {
if (perspective.view?.enabled) perspective.clearViewOffset();
return;
}
if (perspective.view?.enabled && Math.abs(perspective.view.offsetY - offsetY) < 0.5) return;
perspective.setViewOffset(width, height, 0, offsetY, width, height);
}
export function setSceneBackground(scene: Scene, background: Texture | Color | null): void { export function setSceneBackground(scene: Scene, background: Texture | Color | null): void {
scene.background = background; scene.background = background;
} }
@@ -52,3 +75,37 @@ export function placeFill(
mesh.position.set(center[0], center[1] - size[1] / 2 + height / 2, center[2]); mesh.position.set(center[0], center[1] - size[1] / 2 + height / 2, center[2]);
return height; return height;
} }
/** Points an effect with a focus target (depth of field) at a world point. */
export function setEffectTarget(effect: { target: { set(x: number, y: number, z: number): unknown } | null }, point: Vec3): void {
effect.target?.set(point[0], point[1], point[2]);
}
export function setEffectBokeh(effect: { bokehScale: number }, scale: number): void {
if (effect.bokehScale !== scale) effect.bokehScale = scale;
}
export function markTimerFrame(signals: SceneSignals, value: boolean): void {
signals.timerFrame = value;
}
export function setFocusPoint(signals: SceneSignals, point: Vec3 | null): void {
signals.focusPoint = point;
}
/** Pointer capture on the canvas (missing in some test DOMs, refused for unknown ids). */
export function capturePointer(element: HTMLElement, pointerId: number): void {
try {
element.setPointerCapture?.(pointerId);
} catch {
// The pointer is gone already; buttons === 0 on the next move ends the drag instead.
}
}
export function releasePointer(element: HTMLElement, pointerId: number): void {
try {
if (element.hasPointerCapture?.(pointerId)) element.releasePointerCapture(pointerId);
} catch {
// Already released.
}
}
@@ -17,7 +17,9 @@ import { setUniform, setUniformColor } from "./scene-mutations";
import type { SceneLabel, ScenePart } from "./scene-model"; import type { SceneLabel, ScenePart } from "./scene-model";
import { useAnimatedNode } from "./use-animated-node"; import { useAnimatedNode } from "./use-animated-node";
import type { AnimatedValues } from "./use-animated-node"; import type { AnimatedValues } from "./use-animated-node";
import { GLOW } from "./use-hive-motion";
import type { NodeTarget } from "./use-hive-motion"; import type { NodeTarget } from "./use-hive-motion";
import { useHoverSweep, useLivingEdges } from "./use-scene-effects";
// One part of the stack or one insert: glass body, glowing edges, screens, // One part of the stack or one insert: glass body, glowing edges, screens,
// an invisible hit box for pointer events, its content in focus mode and its // an invisible hit box for pointer events, its content in focus mode and its
@@ -48,14 +50,17 @@ export interface StackPartProps {
} }
export function StackPart({ part, target, focused, handlers, labels, hintFrames, children }: StackPartProps) { export function StackPart({ part, target, focused, handlers, labels, hintFrames, children }: StackPartProps) {
const { theme, geometries, motion } = useSceneKit(); const { theme, geometries, motion, env, tier } = useSceneKit();
const group = useRef<Group>(null); const group = useRef<Group>(null);
const { kind, box, state, levels } = part; const { kind, box, state, levels } = part;
const shape = geometries.shape(shapeKey(kind, box.size, state, levels.length), () => const shape = geometries.shape(shapeKey(kind, box.size, state, levels.length), () =>
partShapeSpec(kind, box.size, state, levels.length), partShapeSpec(kind, box.size, state, levels.length),
); );
const glass = useMemo(() => createGlassMaterial(theme, { frost: shape.frost }), [theme, shape.frost]); const glass = useMemo(
() => createGlassMaterial(theme, { frost: shape.frost, env, dispersion: tier === "high" ? 0.03 : 0 }),
[theme, shape.frost, env, tier],
);
const edges = useMemo(() => createEdgeMaterial(theme), [theme]); const edges = useMemo(() => createEdgeMaterial(theme), [theme]);
const grids = useMemo( const grids = useMemo(
() => shape.grids.map((grid) => createGridMaterial(theme, { cell: grid.panel.cell, bars: grid.panel.bars })), () => shape.grids.map((grid) => createGridMaterial(theme, { cell: grid.panel.cell, bars: grid.panel.bars })),
@@ -80,6 +85,8 @@ export function StackPart({ part, target, focused, handlers, labels, hintFrames,
grids.forEach((material) => setUniform(material, "uOpacity", opacity)); grids.forEach((material) => setUniform(material, "uOpacity", opacity));
}; };
useAnimatedNode(group, { ...target, look: 0 }, motion, apply); useAnimatedNode(group, { ...target, look: 0 }, motion, apply);
useLivingEdges([edges]);
useHoverSweep(glass, target.glow >= GLOW.hover, box.size[1]);
const showHints = hintFrames && hintFrames.length > 0 && !target.content; const showHints = hintFrames && hintFrames.length > 0 && !target.content;
const sy = box.size[1]; const sy = box.size[1];
@@ -102,6 +109,7 @@ export function StackPart({ part, target, focused, handlers, labels, hintFrames,
{/* The hit box reaches down to the rest place, so a lifted part does not slip from under the pointer. */} {/* The hit box reaches down to the rest place, so a lifted part does not slip from under the pointer. */}
<mesh <mesh
position={[0, -target.lift / 2, 0]} position={[0, -target.lift / 2, 0]}
scale={[box.size[0], box.size[1] + target.lift, box.size[2]]}
raycast={target.interactive ? meshRaycast : noRaycast} raycast={target.interactive ? meshRaycast : noRaycast}
onPointerOver={handlers.onPointerOver} onPointerOver={handlers.onPointerOver}
onPointerOut={handlers.onPointerOut} onPointerOut={handlers.onPointerOut}
@@ -109,7 +117,7 @@ export function StackPart({ part, target, focused, handlers, labels, hintFrames,
onClick={handlers.onClick} onClick={handlers.onClick}
userData={{ hit: part.id }} userData={{ hit: part.id }}
> >
<boxGeometry args={[box.size[0], box.size[1] + target.lift, box.size[2]]} /> <boxGeometry args={[1, 1, 1]} />
{/* Focused: only back faces take hits, so inserts and frames in front stay reachable. */} {/* Focused: only back faces take hits, so inserts and frames in front stay reachable. */}
<meshBasicMaterial visible={false} side={focused ? BackSide : FrontSide} /> <meshBasicMaterial visible={false} side={focused ? BackSide : FrontSide} />
</mesh> </mesh>
@@ -6,10 +6,11 @@ import { useFrame, useThree } from "@react-three/fiber";
import type { Object3D } from "three"; import type { Object3D } from "three";
import type { Vec3 } from "@/lib/hive/layout"; import type { Vec3 } from "@/lib/hive/layout";
import type { MotionLevel } from "@/lib/motion/motion-preference"; import type { MotionLevel } from "@/lib/motion/motion-preference";
import { springStep } from "./quality";
import { SUBTLE_FADE_SECONDS, fadeToward, stepToward } from "./use-hive-motion"; import { SUBTLE_FADE_SECONDS, fadeToward, stepToward } from "./use-hive-motion";
// Moves a node toward its target (spec 9.8): `full` damps position, scale and // Moves a node toward its target (spec 9.8): `full` flies position and scale
// fades; `subtle` jumps in space and fades opacity, glow and look in 150 ms; // on a spring with a slight overshoot and eases the fades; `subtle` jumps in space and fades opacity, glow and look in 150 ms;
// `off` sets everything at once. Frames are only requested while moving. // `off` sets everything at once. Frames are only requested while moving.
/** After a pause the demand loop reports a long delta; never step more than this. */ /** After a pause the demand loop reports a long delta; never step more than this. */
@@ -30,8 +31,16 @@ export interface AnimatedValues {
look: number; look: number;
} }
function stepFade(current: number, target: number, dt: number, motion: MotionLevel): number { /** Opacity fades a little slower than the flight at `full`, so the ghost hive eases in. */
if (motion === "full") return stepToward(current, target, dt, true); const OPACITY_RATE = 7;
function easeToward(current: number, target: number, dt: number, rate: number): number {
const next = current + (target - current) * (1 - Math.exp(-rate * dt));
return Math.abs(target - next) < 1e-3 ? target : next;
}
function stepFade(current: number, target: number, dt: number, motion: MotionLevel, rate?: number): number {
if (motion === "full") return rate ? easeToward(current, target, dt, rate) : stepToward(current, target, dt, true);
if (motion === "subtle") return fadeToward(current, target, dt, SUBTLE_FADE_SECONDS); if (motion === "subtle") return fadeToward(current, target, dt, SUBTLE_FADE_SECONDS);
return target; return target;
} }
@@ -43,7 +52,13 @@ export function useAnimatedNode(
apply: (values: AnimatedValues) => void, apply: (values: AnimatedValues) => void,
): void { ): void {
const invalidate = useThree((state) => state.invalidate); const invalidate = useThree((state) => state.invalidate);
const current = useRef<{ position: Vec3; scale: number; values: AnimatedValues } | null>(null); const current = useRef<{
position: Vec3;
velocity: Vec3;
scale: number;
scaleVelocity: number;
values: AnimatedValues;
} | null>(null);
const latestApply = useRef(apply); const latestApply = useRef(apply);
useEffect(() => { useEffect(() => {
latestApply.current = apply; latestApply.current = apply;
@@ -61,19 +76,34 @@ export function useAnimatedNode(
const goal: AnimatedValues = { opacity: target.opacity, glow: target.glow, look: target.look ?? 0 }; const goal: AnimatedValues = { opacity: target.opacity, glow: target.glow, look: target.look ?? 0 };
if (!current.current) { if (!current.current) {
// First frame: appear in place, no fly-in from the origin. // First frame: appear in place, no fly-in from the origin.
current.current = { position: [...target.position], scale: target.scale, values: goal }; current.current = {
position: [...target.position],
velocity: [0, 0, 0],
scale: target.scale,
scaleVelocity: 0,
values: goal,
};
} else { } else {
const dt = Math.min(delta, MAX_FRAME_SECONDS); const dt = Math.min(delta, MAX_FRAME_SECONDS);
const damped = motion === "full";
const state = current.current; const state = current.current;
state.position = [ if (motion === "full") {
stepToward(state.position[0], target.position[0], dt, damped), // Flights on a spring with a touch of overshoot.
stepToward(state.position[1], target.position[1], dt, damped), for (let axis = 0; axis < 3; axis += 1) {
stepToward(state.position[2], target.position[2], dt, damped), const next = springStep(state.position[axis], state.velocity[axis], target.position[axis], dt);
]; state.position[axis] = next.value;
state.scale = stepToward(state.scale, target.scale, dt, damped); state.velocity[axis] = next.velocity;
}
const nextScale = springStep(state.scale, state.scaleVelocity, target.scale, dt);
state.scale = nextScale.value;
state.scaleVelocity = nextScale.velocity;
} else {
state.position = [...target.position];
state.velocity = [0, 0, 0];
state.scale = target.scale;
state.scaleVelocity = 0;
}
state.values = { state.values = {
opacity: stepFade(state.values.opacity, goal.opacity, dt, motion), opacity: stepFade(state.values.opacity, goal.opacity, dt, motion, OPACITY_RATE),
glow: stepFade(state.values.glow, goal.glow, dt, motion), glow: stepFade(state.values.glow, goal.glow, dt, motion),
look: stepFade(state.values.look, goal.look, dt, motion), look: stepFade(state.values.look, goal.look, dt, motion),
}; };
@@ -0,0 +1,48 @@
"use client";
import { useRef } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import type { ShaderMaterial } from "three";
import { useSceneKit } from "./scene-kit";
import { setUniform } from "./scene-mutations";
import { MAX_FRAME_SECONDS } from "./use-animated-node";
// Small time-driven effects. All of them only move at motion `full`; the
// pulses ride on frames the idle motion already requests, the hover sweep
// asks for its own frames for about one second.
/** Duration of one hover sweep (shimmer and scan line), seconds. */
export const SWEEP_SECONDS = 1.1;
/** Travelling light pulses along the edges. */
export function useLivingEdges(materials: ShaderMaterial[]): void {
const { motion } = useSceneKit();
useFrame((state) => {
const pulse = motion === "full" ? 1 : 0;
for (const material of materials) {
setUniform(material, "uTime", state.clock.elapsedTime);
setUniform(material, "uPulse", pulse);
}
});
}
/** A holographic shimmer and a thin scan line run once across a part when it becomes hovered. */
export function useHoverSweep(material: ShaderMaterial, active: boolean, height: number): void {
const { motion } = useSceneKit();
const invalidate = useThree((state) => state.invalidate);
const clock = useRef(0);
useFrame((_, delta) => {
if (motion !== "full" || !active) {
if (clock.current !== 0) {
clock.current = 0;
setUniform(material, "uSweep", 0);
}
return;
}
clock.current += Math.min(delta, MAX_FRAME_SECONDS);
const sweep = Math.min(clock.current / SWEEP_SECONDS, 1);
setUniform(material, "uHeight", height);
setUniform(material, "uSweep", sweep >= 1 ? 0 : sweep);
if (sweep < 1) invalidate();
});
}
@@ -4,6 +4,7 @@ import dynamic from "next/dynamic";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import type { HiveLayout } from "@/lib/hive/layout"; import type { HiveLayout } from "@/lib/hive/layout";
import type { HiveCatalog, HiveState } from "@/lib/hive/types"; import type { HiveCatalog, HiveState } from "@/lib/hive/types";
import type { OverlayInsets } from "./hive-scene/camera-fit";
import { canUseWebGL, shouldUse3D } from "@/lib/hive/webgl"; import { canUseWebGL, shouldUse3D } from "@/lib/hive/webgl";
import { useEffectiveMotionLevel } from "@/lib/motion/motion-preference"; import { useEffectiveMotionLevel } from "@/lib/motion/motion-preference";
import type { MotionLevel } from "@/lib/motion/motion-preference"; import type { MotionLevel } from "@/lib/motion/motion-preference";
@@ -25,6 +26,8 @@ export interface HiveVisualSlotProps {
partName: (id: string) => string; partName: (id: string) => string;
onSelectPart: (id: string) => void; onSelectPart: (id: string) => void;
onSelectFrame: (boxId: string, slot: number) => void; onSelectFrame: (boxId: string, slot: number) => void;
/** Card pixels covered by the frosted overlays; the 3D camera fits the model between them. */
insets?: OverlayInsets;
} }
function allows3D(motion: MotionLevel): boolean { function allows3D(motion: MotionLevel): boolean {
@@ -42,7 +45,15 @@ function allows3D(motion: MotionLevel): boolean {
* the pointer. Any failure to keep a context leaves the SVG, which is always * the pointer. Any failure to keep a context leaves the SVG, which is always
* rendered below. * rendered below.
*/ */
export function HiveVisualSlot({ layout, state, catalog, partName, onSelectPart, onSelectFrame }: HiveVisualSlotProps) { export function HiveVisualSlot({
layout,
state,
catalog,
partName,
onSelectPart,
onSelectFrame,
insets,
}: HiveVisualSlotProps) {
const motion = useEffectiveMotionLevel(); const motion = useEffectiveMotionLevel();
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
@@ -91,6 +102,7 @@ export function HiveVisualSlot({ layout, state, catalog, partName, onSelectPart,
onSelectFrame={onSelectFrame} onSelectFrame={onSelectFrame}
onReady={onReady} onReady={onReady}
onContextLost={onContextLost} onContextLost={onContextLost}
insets={insets}
/> />
</div> </div>
) : null} ) : null}
@@ -0,0 +1,76 @@
"use client";
import { useMemo, useState } from "react";
import { catalogFromSystem } from "@/lib/hive/facts";
import { hiveLayout } from "@/lib/hive/layout";
import { partDisplayName } from "@/lib/hive/names";
import { withPendingOps } from "@/lib/hive/pending";
import type { HiveSetupView, HiveSystemDto } from "@/lib/hive/types";
import { HiveFactsLine } from "./hive-facts-line";
import { HiveOutline } from "./hive-outline";
import { HivePendingNote } from "./hive-pending-note";
import { HiveSvg } from "./hive-svg";
import { usePendingHiveOps } from "./use-pending-hive";
import { createHiveViewStore, HiveViewProvider } from "./use-hive-view";
const NOTHING = (): void => undefined;
export interface OfflineHiveProps {
colonyId: string;
/** The colony's setup from the pack; null when the pack knows it has none. */
setup: HiveSetupView | null;
/** The hive systems of the pack. */
systems: readonly HiveSystemDto[];
}
/**
* The hive of a colony on the offline page, read-only (spec 10): the SVG and
* the outline, with waiting changes applied. No 3D, no editing.
*/
export function OfflineHive({ colonyId, setup, systems }: OfflineHiveProps) {
if (!setup) {
return <p className="text-muted-foreground">Noch keine Beute angelegt</p>;
}
const system = systems.find((candidate) => candidate.id === setup.systemId);
if (!system) {
return <p className="text-muted-foreground">Die Beute ist nicht offline geladen.</p>;
}
return <OfflineHiveView colonyId={colonyId} setup={setup} system={system} />;
}
function OfflineHiveView({
colonyId,
setup,
system,
}: {
colonyId: string;
setup: HiveSetupView;
system: HiveSystemDto;
}) {
const [store] = useState(createHiveViewStore);
const catalog = useMemo(() => catalogFromSystem(system), [system]);
const pending = usePendingHiveOps(colonyId);
const shown = useMemo(() => withPendingOps(setup, pending, catalog), [setup, pending, catalog]);
const layout = useMemo(() => hiveLayout(shown.state, catalog), [shown.state, catalog]);
const partName = (id: string) => partDisplayName(shown.state, catalog, id);
return (
<HiveViewProvider store={store}>
<div className="flex flex-col gap-3">
<HiveFactsLine facts={shown.facts} />
<HivePendingNote colonyId={colonyId} />
<div className="max-h-72">
<HiveSvg layout={layout} partName={partName} />
</div>
<HiveOutline
state={shown.state}
catalog={catalog}
canEdit={false}
announcement=""
onEditPart={NOTHING}
onEditFrame={NOTHING}
/>
</div>
</HiveViewProvider>
);
}
@@ -96,6 +96,21 @@ describe("PartSheet", () => {
expect(save()).toBeDisabled(); expect(save()).toBeDisabled();
}); });
it("does not let its submit bubble into an enclosing form", async () => {
const outer = vi.fn((event: { preventDefault: () => void }) => event.preventDefault());
const user = userEvent.setup();
render(
<form onSubmit={outer}>
<Harness partId="fe" submit={vi.fn().mockResolvedValue(undefined)} />
</form>,
);
const field = within(dialog()).getByLabelText("Flüssigfutter");
await user.clear(field);
await user.type(field, "3");
await user.click(save());
expect(outer).not.toHaveBeenCalled();
});
it("saves only the changed level as setState", async () => { it("saves only the changed level as setState", async () => {
const submit = vi.fn().mockResolvedValue(undefined); const submit = vi.fn().mockResolvedValue(undefined);
const user = userEvent.setup(); const user = userEvent.setup();
@@ -178,7 +178,9 @@ function PartForm({ located, type, catalog, startWithEvaporator, startWithFrameF
<form <form
className="flex flex-col gap-4 px-4 pb-4" className="flex flex-col gap-4 px-4 pb-4"
onSubmit={(event) => { onSubmit={(event) => {
// Portal forms bubble their submit through the React tree (e.g. into an enclosing form).
event.preventDefault(); event.preventDefault();
event.stopPropagation();
if (ops.length > 0 && valid && !pending) void save(); if (ops.length > 0 && valid && !pending) void save();
}} }}
> >
@@ -9,6 +9,9 @@ import { deriveHiveFacts } from "@/lib/hive/facts";
import { frames, inlineCatalog, part } from "@/lib/hive/test-utils"; import { frames, inlineCatalog, part } from "@/lib/hive/test-utils";
import type { HiveOp, HiveSetupView, HiveState } from "@/lib/hive/types"; import type { HiveOp, HiveSetupView, HiveState } from "@/lib/hive/types";
import { fetchColonyTreatments } from "@/lib/treatments/api"; import { fetchColonyTreatments } from "@/lib/treatments/api";
import { queueHiveChange } from "@/lib/offline/db";
import { hasQueuedHiveChange } from "@/lib/hive/pending";
import { syncPendingEntries } from "@/lib/offline/sync";
import { HIVE_BUSY_MESSAGE, useHiveMutation } from "./use-hive-mutation"; import { HIVE_BUSY_MESSAGE, useHiveMutation } from "./use-hive-mutation";
const push = vi.fn(); const push = vi.fn();
@@ -20,6 +23,21 @@ vi.mock("@/lib/hive/api", async (importOriginal) => ({
postHiveChange: vi.fn(), postHiveChange: vi.fn(),
})); }));
vi.mock("@/lib/hive/pending", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/hive/pending")>()),
hasQueuedHiveChange: vi.fn(),
}));
vi.mock("@/lib/offline/sync", () => ({ syncPendingEntries: vi.fn() }));
const auth = vi.hoisted(() => ({ userId: null as string | null }));
vi.mock("@/lib/auth/auth-context", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/auth/auth-context")>()),
useCurrentUserId: () => auth.userId,
}));
vi.mock("@/lib/offline/db", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/offline/db")>()),
queueHiveChange: vi.fn(),
}));
const catalog = inlineCatalog(); const catalog = inlineCatalog();
const COLONY = "colony-1"; const COLONY = "colony-1";
@@ -69,6 +87,10 @@ beforeEach(() => {
vi.mocked(toast).mockReset(); vi.mocked(toast).mockReset();
vi.mocked(fetchColonyTreatments).mockReset().mockResolvedValue([]); vi.mocked(fetchColonyTreatments).mockReset().mockResolvedValue([]);
push.mockReset(); push.mockReset();
auth.userId = null;
vi.mocked(hasQueuedHiveChange).mockReset().mockResolvedValue(false);
vi.mocked(syncPendingEntries).mockReset().mockResolvedValue({ synced: 0, failed: 0, skippedForeign: 0 });
vi.mocked(queueHiveChange).mockReset().mockResolvedValue(undefined);
}); });
afterEach(() => vi.useRealTimers()); afterEach(() => vi.useRealTimers());
@@ -129,6 +151,90 @@ describe("useHiveMutation", () => {
expect(cached()?.state.stack[2].state.syrupLiters).toBe(2); expect(cached()?.state.stack[2].state.syrupLiters).toBe(2);
}); });
it("queues a card change that fails at network level, under the same key, and keeps the loaded state", async () => {
auth.userId = "user-1";
vi.mocked(postHiveChange).mockRejectedValue(new TypeError("Failed to fetch"));
const { hook, cached } = setup();
let result: HiveSetupView | null | undefined;
await act(async () => {
result = await hook.result.current.submit(refill, "CARD", "2026-10-15");
});
const sentKey = vi.mocked(postHiveChange).mock.calls[0][2];
expect(queueHiveChange).toHaveBeenCalledWith(
{ colonyId: COLONY, occurredOn: "2026-10-15", source: "CARD", ops: refill },
sentKey,
"user-1",
);
// The card shows the queued ops on top of this state, so it must not hold them twice.
expect(cached()?.state.stack[2].state.syrupLiters).toBe(2);
expect(result?.state.stack[2].state.syrupLiters).toBe(3);
});
it("queues an online change behind earlier queued changes of the colony instead of overtaking them", async () => {
auth.userId = "user-1";
vi.mocked(hasQueuedHiveChange).mockResolvedValue(true);
const { hook, cached } = setup();
let result: HiveSetupView | null | undefined;
await act(async () => {
result = await hook.result.current.submit(refill, "CARD", "2026-10-15");
});
expect(postHiveChange).not.toHaveBeenCalled();
expect(queueHiveChange).toHaveBeenCalledWith(
{ colonyId: COLONY, occurredOn: "2026-10-15", source: "CARD", ops: refill },
expect.any(String),
"user-1",
);
expect(syncPendingEntries).toHaveBeenCalledWith("user-1", { afterChange: true });
expect(cached()?.state.stack[2].state.syrupLiters).toBe(2);
expect(result?.state.stack[2].state.syrupLiters).toBe(3);
});
it("queues behind earlier queued changes even when the card has no setup cached", async () => {
auth.userId = "user-1";
vi.mocked(hasQueuedHiveChange).mockResolvedValue(true);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);
const hook = renderHook(() => useHiveMutation(COLONY, catalog), { wrapper });
let result: HiveSetupView | null | undefined;
await act(async () => {
result = await hook.result.current.submit(refill, "CARD", "2026-10-15");
});
expect(postHiveChange).not.toHaveBeenCalled();
expect(queueHiveChange).toHaveBeenCalledTimes(1);
expect(result).toBeNull();
});
it("does not queue server refusals or editor changes", async () => {
auth.userId = "user-1";
vi.mocked(postHiveChange).mockRejectedValue(new ApiError(400, "falsch", null));
const { hook } = setup();
await act(async () => {
await expect(hook.result.current.submit(refill, "CARD", "2026-10-15")).rejects.toThrow("falsch");
});
vi.mocked(postHiveChange).mockRejectedValue(new TypeError("Failed to fetch"));
await act(async () => {
await expect(hook.result.current.submit(refill, "EDITOR", "2026-10-15")).rejects.toThrow();
});
expect(queueHiveChange).not.toHaveBeenCalled();
});
it("does not queue without a signed-in user", async () => {
vi.mocked(postHiveChange).mockRejectedValue(new TypeError("Failed to fetch"));
const { hook } = setup();
await act(async () => {
await expect(hook.result.current.submit(refill, "CARD", "2026-10-15")).rejects.toThrow();
});
expect(queueHiveChange).not.toHaveBeenCalled();
});
it("keeps a newer cache value instead of restoring the snapshot", async () => { it("keeps a newer cache value instead of restoring the snapshot", async () => {
let reject: (e: unknown) => void = () => undefined; let reject: (e: unknown) => void = () => undefined;
vi.mocked(postHiveChange).mockReturnValue(new Promise((_, r) => (reject = r))); vi.mocked(postHiveChange).mockReturnValue(new Promise((_, r) => (reject = r)));
@@ -5,11 +5,16 @@ import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { berlinCalendarDay } from "@/lib/dates"; import { berlinCalendarDay } from "@/lib/dates";
import { ApiError } from "@/lib/auth/api-client"; import { ApiError } from "@/lib/auth/api-client";
import { useCurrentUserId } from "@/lib/auth/auth-context";
import { hiveConflictOf, hiveKeys, postHiveChange } from "@/lib/hive/api"; import { hiveConflictOf, hiveKeys, postHiveChange } from "@/lib/hive/api";
import { deriveHiveFacts } from "@/lib/hive/facts"; import { deriveHiveFacts } from "@/lib/hive/facts";
import { hasQueuedHiveChange } from "@/lib/hive/pending";
import { applyHiveOps } from "@/lib/hive/ops"; import { applyHiveOps } from "@/lib/hive/ops";
import { fetchColonyTreatments } from "@/lib/treatments/api"; import { fetchColonyTreatments } from "@/lib/treatments/api";
import { queueHiveChange } from "@/lib/offline/db";
import { newIdempotencyKey } from "@/lib/offline/idempotency"; import { newIdempotencyKey } from "@/lib/offline/idempotency";
import { syncPendingEntries } from "@/lib/offline/sync";
import { OFFLINE_QUEUE_QUERY_KEY } from "@/lib/offline/use-capture-mutation";
import type { import type {
ClientHiveChangeSource, ClientHiveChangeSource,
HiveApplyResult, HiveApplyResult,
@@ -31,7 +36,12 @@ export interface HiveConflict {
} }
export interface HiveMutation { export interface HiveMutation {
submit(ops: HiveOp[], source: ClientHiveChangeSource, occurredOn?: string): Promise<HiveSetupView>; /** Resolves with the saved setup; null when the change was queued and no setup is loaded to show it on. */
submit(
ops: HiveOp[],
source: ClientHiveChangeSource,
occurredOn?: string,
): Promise<HiveSetupView | null>;
conflict: HiveConflict | null; conflict: HiveConflict | null;
/** "retry" sends the ops again against the current state (new key); "discard" drops them. */ /** "retry" sends the ops again against the current state (new key); "discard" drops them. */
resolveConflict(action: "retry" | "discard"): Promise<void>; resolveConflict(action: "retry" | "discard"): Promise<void>;
@@ -66,10 +76,14 @@ function withState(view: HiveSetupView, state: HiveState, version: number, catal
* (optimistic); a failure refetches it. One submit at a time. An attempt keeps * (optimistic); a failure refetches it. One submit at a time. An attempt keeps
* its idempotency key across manual re-presses after a network error or 5xx; * its idempotency key across manual re-presses after a network error or 5xx;
* a retry after a conflict gets a fresh one. Follow-up toasts are suggestions only. * a retry after a conflict gets a fresh one. Follow-up toasts are suggestions only.
* A card change that fails at network level goes into the offline queue
* `pending-hive-changes` under the same key (spec 10); the card shows it from
* there, so the cache keeps the state it had.
*/ */
export function useHiveMutation(colonyId: string, catalog: HiveCatalog): HiveMutation { export function useHiveMutation(colonyId: string, catalog: HiveCatalog): HiveMutation {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const router = useRouter(); const router = useRouter();
const ownerUserId = useCurrentUserId();
const [conflict, setConflict] = useState<HiveConflict | null>(null); const [conflict, setConflict] = useState<HiveConflict | null>(null);
const [pendingCount, setPendingCount] = useState(0); const [pendingCount, setPendingCount] = useState(0);
const lastAttempt = useRef<Attempt | null>(null); const lastAttempt = useRef<Attempt | null>(null);
@@ -105,7 +119,7 @@ export function useHiveMutation(colonyId: string, catalog: HiveCatalog): HiveMut
); );
const run = useCallback( const run = useCallback(
async (attempt: Attempt): Promise<HiveSetupView> => { async (attempt: Attempt): Promise<HiveSetupView | null> => {
if (inFlight.current) throw new Error(HIVE_BUSY_MESSAGE); if (inFlight.current) throw new Error(HIVE_BUSY_MESSAGE);
inFlight.current = true; inFlight.current = true;
const { ops, source, occurredOn } = attempt; const { ops, source, occurredOn } = attempt;
@@ -117,6 +131,17 @@ export function useHiveMutation(colonyId: string, catalog: HiveCatalog): HiveMut
// A running refetch must not overwrite the optimistic state. // A running refetch must not overwrite the optimistic state.
await queryClient.cancelQueries({ queryKey }); await queryClient.cancelQueries({ queryKey });
const previous = queryClient.getQueryData<HiveSetupView>(queryKey); const previous = queryClient.getQueryData<HiveSetupView>(queryKey);
// Behind an earlier queued change of this colony it must not overtake: queue it too,
// whether or not the card has a setup loaded.
if (ownerUserId && source === "CARD" && (await hasQueuedHiveChange(colonyId, ownerUserId))) {
await queueHiveChange({ colonyId, occurredOn, source, ops }, key, ownerUserId);
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
// Online again, the queue goes out in order.
void syncPendingEntries(ownerUserId, { afterChange: true }).catch(() => undefined);
if (!previous) return null;
const queued = applyHiveOps(previous.state, ops, catalog);
return queued.ok ? withState(previous, queued.state, previous.version, catalog) : previous;
}
if (previous) { if (previous) {
const applied = applyHiveOps(previous.state, ops, catalog); const applied = applyHiveOps(previous.state, ops, catalog);
if (applied.ok) { if (applied.ok) {
@@ -153,6 +178,16 @@ export function useHiveMutation(colonyId: string, catalog: HiveCatalog): HiveMut
ops, ops,
preview: applyHiveOps(body.current.state, ops, catalog), preview: applyHiveOps(body.current.state, ops, catalog),
}); });
} else if (previous && ownerUserId && source === "CARD" && !(error instanceof ApiError)) {
// No network: queue it. The pending ops are shown on top of the loaded state.
await queueHiveChange({ colonyId, occurredOn, source, ops }, key, ownerUserId);
failedKey.current = null;
lastAttempt.current = null;
if (optimistic && queryClient.getQueryData(queryKey) === optimistic) {
queryClient.setQueryData(queryKey, previous);
}
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
return optimistic ?? previous;
} else { } else {
failedKey.current = isAmbiguousFailure(error) ? { signature, key } : null; failedKey.current = isAmbiguousFailure(error) ? { signature, key } : null;
// Restore only if nobody changed the cache since our optimistic write; then refetch. // Restore only if nobody changed the cache since our optimistic write; then refetch.
@@ -168,7 +203,7 @@ export function useHiveMutation(colonyId: string, catalog: HiveCatalog): HiveMut
setPendingCount((count) => count - 1); setPendingCount((count) => count - 1);
} }
}, },
[catalog, colonyId, queryClient, queryKey, suggestFollowUps], [catalog, colonyId, ownerUserId, queryClient, queryKey, suggestFollowUps],
); );
const submit = useCallback( const submit = useCallback(
@@ -0,0 +1,18 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useCurrentUserId } from "@/lib/auth/auth-context";
import { fetchHive, hiveKeys } from "@/lib/hive/api";
/**
* The colony's hive setup. Without network the colony's offline pack answers
* (read from this device, so the query never waits for a network).
*/
export function useHiveSetup(colonyId: string) {
const userId = useCurrentUserId();
return useQuery({
queryKey: hiveKeys.detail(colonyId),
queryFn: () => fetchHive(colonyId, userId),
networkMode: "always",
});
}
@@ -2,13 +2,20 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useCurrentUserId } from "@/lib/auth/auth-context";
import { catalogFromSystem } from "@/lib/hive/facts"; import { catalogFromSystem } from "@/lib/hive/facts";
import { fetchHiveSystems, hiveKeys } from "@/lib/hive/api"; import { fetchHiveSystems, fetchHiveSystemsWithFallback, hiveKeys } from "@/lib/hive/api";
import type { HiveCatalog, HiveSystemDto } from "@/lib/hive/types"; import type { HiveCatalog, HiveSystemDto } from "@/lib/hive/types";
/** The shared systems query (catalog of every system); the card and the init flow read the same cache. */ /** The shared systems query (catalog of every system); the card and the init flow read the same cache. */
export function useHiveSystems() { export function useHiveSystems() {
return useQuery({ queryKey: hiveKeys.systems, queryFn: fetchHiveSystems }); const userId = useCurrentUserId();
return useQuery({
queryKey: hiveKeys.systems,
queryFn: () => fetchHiveSystemsWithFallback(userId, fetchHiveSystems),
// Offline the catalog of the offline packs answers from this device.
networkMode: "always",
});
} }
/** Catalog of one system, stable while the systems data stays the same. */ /** Catalog of one system, stable while the systems data stays the same. */
@@ -1,7 +1,7 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { act, renderHook } from "@testing-library/react"; import { act, renderHook } from "@testing-library/react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { createHiveViewStore, focusedIdOf, HiveViewProvider, useHiveView } from "./use-hive-view"; import { createHiveViewStore, focusedIdOf, HiveViewProvider, useHiveView, useHiveViewStore } from "./use-hive-view";
describe("createHiveViewStore", () => { describe("createHiveViewStore", () => {
it("starts empty and keeps the same state object until something changes", () => { it("starts empty and keeps the same state object until something changes", () => {
@@ -88,6 +88,24 @@ describe("useHiveView", () => {
expect(result.current.focusedPartId).toBe("fe"); expect(result.current.focusedPartId).toBe("fe");
}); });
it("gives the store without subscribing (no re-render on view changes)", () => {
const store = createHiveViewStore();
let renders = 0;
const wrapper = ({ children }: { children: ReactNode }) => (
<HiveViewProvider store={store}>{children}</HiveViewProvider>
);
const { result } = renderHook(
() => {
renders += 1;
return useHiveViewStore();
},
{ wrapper },
);
expect(result.current).toBe(store);
act(() => store.setHovered("x"));
expect(renders).toBe(1);
});
it("throws outside a provider", () => { it("throws outside a provider", () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {}); const spy = vi.spyOn(console, "error").mockImplementation(() => {});
expect(() => renderHook(() => useHiveView())).toThrow(/HiveViewProvider/); expect(() => renderHook(() => useHiveView())).toThrow(/HiveViewProvider/);
@@ -90,6 +90,13 @@ export function HiveViewProvider({ store, children }: { store: HiveViewStore; ch
return createElement(HiveViewContext.Provider, { value: store }, children); return createElement(HiveViewContext.Provider, { value: store }, children);
} }
/** The store itself, for event handlers that only act (no re-render on view changes). */
export function useHiveViewStore(): HiveViewStore {
const store = useContext(HiveViewContext);
if (!store) throw new Error("useHiveViewStore must be used inside a HiveViewProvider");
return store;
}
export function useHiveView(): HiveView { export function useHiveView(): HiveView {
const store = useContext(HiveViewContext); const store = useContext(HiveViewContext);
if (!store) throw new Error("useHiveView must be used inside a HiveViewProvider"); if (!store) throw new Error("useHiveView must be used inside a HiveViewProvider");
@@ -0,0 +1,44 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useCurrentUserId } from "@/lib/auth/auth-context";
import { hasRejectedHiveChange, pendingHiveChanges } from "@/lib/hive/pending";
import type { HiveOp } from "@/lib/hive/types";
import { OFFLINE_QUEUE_QUERY_KEY } from "@/lib/offline/use-capture-mutation";
const NO_CHANGES: never[] = [];
/** The colony's waiting hive changes (queue and queued inspections), read from this device. */
function usePendingHiveChanges(colonyId: string) {
const userId = useCurrentUserId();
const query = useQuery({
// Under the queue prefix, so a queued, sent or discarded entry refreshes it.
queryKey: [...OFFLINE_QUEUE_QUERY_KEY, userId, "hive", colonyId],
queryFn: () => pendingHiveChanges(colonyId, userId ?? ""),
enabled: userId !== null && colonyId !== "",
networkMode: "always",
});
return query.data ?? NO_CHANGES;
}
/** All ops waiting for the colony, in sending order: apply them with `withPendingOps`. */
export function usePendingHiveOps(colonyId: string): HiveOp[] {
return usePendingHiveChanges(colonyId).flatMap((change) => change.ops);
}
/** How many changes of the colony's hive wait for network. */
export function usePendingHiveCount(colonyId: string): number {
return usePendingHiveChanges(colonyId).length;
}
/** True while a queued hive change of the colony was refused and waits for the user. */
export function useHasRejectedHiveChange(colonyId: string): boolean {
const userId = useCurrentUserId();
const query = useQuery({
queryKey: [...OFFLINE_QUEUE_QUERY_KEY, userId, "hive", colonyId, "rejected"],
queryFn: () => hasRejectedHiveChange(colonyId, userId ?? ""),
enabled: userId !== null && colonyId !== "",
networkMode: "always",
});
return query.data ?? false;
}
@@ -6,6 +6,7 @@ import {
discardQueueItem, discardQueueItem,
listQueue, listQueue,
retryBulkWithout, retryBulkWithout,
retryInspectionWithoutHiveChange,
type QueueItem, type QueueItem,
type StoredPhoto, type StoredPhoto,
} from "@/lib/offline/db"; } from "@/lib/offline/db";
@@ -17,6 +18,7 @@ vi.mock("@/lib/offline/db", () => ({
listQueue: vi.fn(), listQueue: vi.fn(),
discardQueueItem: vi.fn(), discardQueueItem: vi.fn(),
retryBulkWithout: vi.fn(), retryBulkWithout: vi.fn(),
retryInspectionWithoutHiveChange: vi.fn(),
})); }));
vi.mock("@/lib/offline/sync", () => ({ syncPendingEntries: vi.fn() })); vi.mock("@/lib/offline/sync", () => ({ syncPendingEntries: vi.fn() }));
vi.mock("@/lib/photos/store", async (importOriginal) => ({ vi.mock("@/lib/photos/store", async (importOriginal) => ({
@@ -93,6 +95,7 @@ describe("PendingQueueSheet", () => {
vi.mocked(listQueue).mockReset().mockResolvedValue([REJECTED, WAITING]); vi.mocked(listQueue).mockReset().mockResolvedValue([REJECTED, WAITING]);
vi.mocked(discardQueueItem).mockReset().mockResolvedValue(undefined); vi.mocked(discardQueueItem).mockReset().mockResolvedValue(undefined);
vi.mocked(retryBulkWithout).mockReset().mockResolvedValue(undefined); vi.mocked(retryBulkWithout).mockReset().mockResolvedValue(undefined);
vi.mocked(retryInspectionWithoutHiveChange).mockReset().mockResolvedValue(undefined);
vi.mocked(listPendingPhotos).mockReset().mockResolvedValue([]); vi.mocked(listPendingPhotos).mockReset().mockResolvedValue([]);
vi.mocked(discardReadyPhoto).mockReset().mockResolvedValue(undefined); vi.mocked(discardReadyPhoto).mockReset().mockResolvedValue(undefined);
vi.mocked(resetPhotoError).mockReset().mockResolvedValue(undefined); vi.mocked(resetPhotoError).mockReset().mockResolvedValue(undefined);
@@ -131,6 +134,54 @@ describe("PendingQueueSheet", () => {
); );
}); });
const FORBIDDEN_HIVE: QueueItem = {
store: "pending-inspections",
id: 11,
label: "Durchsicht",
queuedAt: "2026-10-14T08:00:00.000Z",
hasHiveChange: true,
canResendWithoutHiveChange: true,
lastError: { status: 403, message: "Forbidden", at: "2026-10-14T09:00:00.000Z" },
};
it("words a 403 on a hive change and sends the inspection without it", async () => {
vi.mocked(listQueue).mockResolvedValue([FORBIDDEN_HIVE]);
renderSheet();
const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" });
expect(await within(sheet).findByText(/Keine Berechtigung, die Beute zu ändern/)).toBeInTheDocument();
await userEvent.click(within(sheet).getByRole("button", { name: "Ohne Beute-Änderung senden" }));
await waitFor(() => expect(retryInspectionWithoutHiveChange).toHaveBeenCalledWith(11));
await waitFor(() =>
expect(syncPendingEntries).toHaveBeenCalledWith("user-1", { afterChange: true }),
);
});
it("lists a rejected hive change with the conflict text and only the discard action", async () => {
vi.mocked(listQueue).mockResolvedValue([
{
store: "pending-hive-changes",
id: 12,
label: "Beute-Änderung",
queuedAt: "2026-10-14T08:00:00.000Z",
hasHiveChange: true,
lastError: {
status: 409,
message: "Konflikt",
details: { code: "HIVE_CONFLICT", opIndex: 0, reason: "MISSING_ID: x" },
at: "2026-10-14T09:00:00.000Z",
},
},
]);
renderSheet();
const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" });
expect(await within(sheet).findByText(/Die Beute wurde inzwischen geändert/)).toBeInTheDocument();
expect(within(sheet).queryByRole("button", { name: "Ohne Beute-Änderung senden" })).not.toBeInTheDocument();
expect(within(sheet).getByRole("button", { name: /^Verwerfen/ })).toBeInTheDocument();
});
it("names the button generally when a refused colony is not known by code", async () => { it("names the button generally when a refused colony is not known by code", async () => {
vi.mocked(listQueue).mockResolvedValue([ vi.mocked(listQueue).mockResolvedValue([
{ ...REJECTED_BULK, resendWithout: { colonyIds: ["c-3", "c-4"], codes: ["V-3"] } }, { ...REJECTED_BULK, resendWithout: { colonyIds: ["c-3", "c-4"], codes: ["V-3"] } },
@@ -12,10 +12,16 @@ import {
discardQueueItem, discardQueueItem,
listQueue, listQueue,
retryBulkWithout, retryBulkWithout,
retryInspectionWithoutHiveChange,
type QueueError, type QueueError,
type QueueItem, type QueueItem,
} from "@/lib/offline/db"; } from "@/lib/offline/db";
import { entriesSynced, foreignEntriesWaiting, photosAttached, rejectionText } from "@/lib/offline/queue-texts"; import {
entriesSynced,
foreignEntriesWaiting,
photosAttached,
queueRejectionText,
} from "@/lib/offline/queue-texts";
import { syncPendingEntries } from "@/lib/offline/sync"; import { syncPendingEntries } from "@/lib/offline/sync";
import { OFFLINE_QUEUE_QUERY_KEY } from "@/lib/offline/use-capture-mutation"; import { OFFLINE_QUEUE_QUERY_KEY } from "@/lib/offline/use-capture-mutation";
import { discardReadyPhoto, listPendingPhotos, resetPhotoError } from "@/lib/photos/store"; import { discardReadyPhoto, listPendingPhotos, resetPhotoError } from "@/lib/photos/store";
@@ -72,11 +78,17 @@ function discardDescription(item: QueueItem): string {
} }
/** Rejected with the server's reason, or waiting - in words, never by colour alone. */ /** Rejected with the server's reason, or waiting - in words, never by colour alone. */
function QueueStatus({ lastError }: { lastError?: QueueError }) { function QueueStatus({
lastError,
changesHive = false,
}: {
lastError?: QueueError;
changesHive?: boolean;
}) {
return lastError ? ( return lastError ? (
<p className="flex items-start gap-2 text-sm text-destructive"> <p className="flex items-start gap-2 text-sm text-destructive">
<AlertTriangle className="mt-0.5 size-4 shrink-0" aria-hidden /> <AlertTriangle className="mt-0.5 size-4 shrink-0" aria-hidden />
<span>Abgelehnt: {rejectionText(lastError.message)}</span> <span>Abgelehnt: {queueRejectionText(lastError, changesHive)}</span>
</p> </p>
) : ( ) : (
<p className="flex items-start gap-2 text-sm text-muted-foreground"> <p className="flex items-start gap-2 text-sm text-muted-foreground">
@@ -202,6 +214,25 @@ export function PendingQueueSheet({
onError: () => toast.error("Eintrag konnte nicht erneut gesendet werden."), onError: () => toast.error("Eintrag konnte nicht erneut gesendet werden."),
}); });
// „Ohne Beute-Änderung senden“: the hive change leaves the inspection (same
// key, rejection cleared), then a normal run sends it.
const resendWithoutHive = useMutation({
mutationFn: async (id: number) => {
await retryInspectionWithoutHiveChange(id);
return syncPendingEntries(userId, { afterChange: true });
},
networkMode: "always",
onSuccess: (result) => {
if (result.synced > 0) {
toast.success(entriesSynced(result.synced));
void queryClient.invalidateQueries();
return;
}
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
},
onError: () => toast.error("Eintrag konnte nicht erneut gesendet werden."),
});
const names = entryNames(items); const names = entryNames(items);
const photoNames = entryNames( const photoNames = entryNames(
photos.map((photo) => ({ label: PHOTO_LABEL, queuedAt: photo.createdAt })), photos.map((photo) => ({ label: PHOTO_LABEL, queuedAt: photo.createdAt })),
@@ -250,7 +281,20 @@ export function PendingQueueSheet({
isPending={discard.isPending} isPending={discard.isPending}
/> />
</div> </div>
<QueueStatus lastError={item.lastError} /> <QueueStatus lastError={item.lastError} changesHive={item.hasHiveChange} />
{item.canResendWithoutHiveChange && typeof item.id === "number" ? (
<Button
type="button"
variant="outline"
size="touch"
className="self-start"
disabled={resendWithoutHive.isPending}
onClick={() => resendWithoutHive.mutate(item.id as number)}
>
<RotateCw aria-hidden />
Ohne Beute-Änderung senden
</Button>
) : null}
{item.resendWithout && typeof item.id === "number" ? ( {item.resendWithout && typeof item.id === "number" ? (
<ResendWithoutButton <ResendWithoutButton
resend={item.resendWithout} resend={item.resendWithout}
@@ -0,0 +1,115 @@
"use client";
import type { ReactNode } from "react";
import Link from "next/link";
import { Mic, Square } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useDictation, type UseDictationResult } from "./use-dictation";
interface DictationButtonProps {
fieldLabel: string;
/** Receives the recognized text; the caller appends it, never replaces. */
onAppend(text: string): void;
}
/** The state as words; colour alone never carries it. */
function statusText(dictation: UseDictationResult): string | null {
const { state, online, onDevice, failure } = dictation;
switch (state) {
case "listening":
return "Hört zu …";
case "needs-consent":
return "Cloud-Erkennung zuerst erlauben.";
case "error":
return failure === "microphone"
? "Mikrofon nicht erlaubt. Bitte tippen."
: "Diktat nicht möglich. Bitte tippen.";
case "idle":
if (!online) {
return onDevice ? "Nur auf dem Gerät" : "Offline nicht möglich";
}
return null;
default:
return null;
}
}
/**
* Microphone button next to a free-text field. It only exists where the
* browser can recognize speech and either the device or the user's consent
* covers it.
*/
export function DictationButton({ fieldLabel, onAppend }: DictationButtonProps) {
const dictation = useDictation({ onFinal: onAppend });
const { state, interim, start, stop, support, online, onDevice } = dictation;
if (!support) {
return null;
}
if (support.isInstalledIosPwa) {
return (
<p className="text-xs text-muted-foreground">Hier lieber eine Sprachnotiz aufnehmen</p>
);
}
if (state === "unavailable") {
return null;
}
const listening = state === "listening";
const text = statusText(dictation);
const offlineBlocked = !online && !onDevice;
return (
<div className="flex shrink-0 flex-col items-end gap-1">
{listening ? (
<Button
type="button"
variant="default"
size="touch"
onClick={stop}
aria-label={`Stopp: Diktat für ${fieldLabel} beenden`}
>
<Square aria-hidden="true" />
Stopp
</Button>
) : (
<Button
type="button"
variant="outline"
size="touch"
onClick={start}
disabled={offlineBlocked}
aria-label={`Diktieren: ${fieldLabel}`}
>
<Mic aria-hidden="true" />
Diktieren
</Button>
)}
<p role="status" aria-live="polite" className="min-h-4 text-xs text-muted-foreground">
{text}
</p>
{listening && interim ? (
<p className="max-w-56 text-right text-xs italic text-muted-foreground/70">{interim}</p>
) : null}
{state === "needs-consent" ? (
<Link href="/settings/voice" className="text-xs underline underline-offset-2">
Zu „Sprache &amp; Datenschutz“
</Link>
) : null}
</div>
);
}
interface DictationRowProps extends DictationButtonProps {
children: ReactNode;
}
/** The field and its dictation button side by side. */
export function DictationRow({ children, ...button }: DictationRowProps) {
return (
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1">{children}</div>
<DictationButton {...button} />
</div>
);
}
@@ -0,0 +1,241 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { DictationButton } from "./dictation-button";
import { appendDictation } from "@/lib/voice/append-text";
import { setCloudRecognitionConsent } from "@/lib/voice/consent";
class FakeRecognition {
static instances: FakeRecognition[] = [];
static localAnswer: string | undefined = "available";
static available = vi.fn(async () => FakeRecognition.localAnswer);
lang = "";
continuous = false;
interimResults = false;
maxAlternatives = 1;
declare processLocally?: boolean;
handlers: Record<string, (event: unknown) => void> = {};
static throwOnStart = false;
start = vi.fn(() => {
if (FakeRecognition.throwOnStart) {
throw new Error("InvalidStateError");
}
});
stop = vi.fn();
abort = vi.fn();
constructor() {
FakeRecognition.instances.push(this);
}
set onresult(fn: (event: unknown) => void) {
this.handlers.result = fn;
}
set onend(fn: (event: unknown) => void) {
this.handlers.end = fn;
}
set onerror(fn: (event: unknown) => void) {
this.handlers.error = fn;
}
set onaudiostart(fn: (event: unknown) => void) {
this.handlers.audiostart = fn;
}
}
function speak(final: string, interim = "") {
const engine = FakeRecognition.instances.at(-1)!;
const results: unknown[] = [];
if (final) {
results.push(Object.assign([{ transcript: final }], { isFinal: true }));
}
if (interim) {
results.push(Object.assign([{ transcript: interim }], { isFinal: false }));
}
act(() => engine.handlers.result({ resultIndex: 0, results }));
}
function Harness({ initial = "" }: { initial?: string }) {
const [value, setValue] = useState(initial);
return (
<div>
<label htmlFor="notes-field">Notizen</label>
<input
id="notes-field"
value={value}
onChange={(event) => setValue(event.target.value)}
/>
<DictationButton
fieldLabel="Notizen"
onAppend={(text) => setValue((current) => appendDictation(current, text))}
/>
</div>
);
}
async function renderSettled(ui: React.ReactElement) {
const rendered = render(ui);
// detectSpeechSupport resolves on a microtask; let it land.
await act(async () => {});
return rendered;
}
describe("DictationButton", () => {
beforeEach(() => {
window.localStorage.clear();
FakeRecognition.instances = [];
FakeRecognition.localAnswer = "available";
FakeRecognition.available.mockClear();
(window as unknown as Record<string, unknown>).SpeechRecognition = FakeRecognition;
vi.spyOn(window.navigator, "onLine", "get").mockReturnValue(true);
});
afterEach(() => {
delete (window as unknown as Record<string, unknown>).SpeechRecognition;
vi.restoreAllMocks();
});
it("hides the button when the browser has no speech recognition", async () => {
delete (window as unknown as Record<string, unknown>).SpeechRecognition;
await renderSettled(<Harness />);
expect(screen.queryByRole("button", { name: /Diktieren/ })).not.toBeInTheDocument();
});
it("appends and never overwrites", async () => {
const user = userEvent.setup();
await renderSettled(<Harness initial="Alt" />);
await user.click(screen.getByRole("button", { name: /Diktieren/ }));
speak("neu");
expect(screen.getByLabelText("Notizen")).toHaveValue("Alt neu");
const engine = FakeRecognition.instances[0];
expect(engine.lang).toBe("de-DE");
expect(engine.continuous).toBe(false);
expect(engine.interimResults).toBe(true);
expect(engine.processLocally).toBe(true);
});
it("starts the field from empty without a leading space", async () => {
const user = userEvent.setup();
await renderSettled(<Harness />);
await user.click(screen.getByRole("button", { name: /Diktieren/ }));
speak("Königin gesehen");
expect(screen.getByLabelText("Notizen")).toHaveValue("Königin gesehen");
});
it("offers cloud recognition only after consent", async () => {
FakeRecognition.localAnswer = "unavailable";
const first = await renderSettled(<Harness />);
expect(screen.queryByRole("button", { name: /Diktieren/ })).not.toBeInTheDocument();
first.unmount();
setCloudRecognitionConsent(true);
await renderSettled(<Harness />);
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: /Diktieren/ }));
expect(FakeRecognition.instances[0].processLocally).toBeUndefined();
});
it("shows the state as text, not only colour", async () => {
const user = userEvent.setup();
await renderSettled(<Harness />);
await user.click(screen.getByRole("button", { name: /Diktieren/ }));
speak("", "und Stifte");
expect(screen.getByRole("status")).toHaveTextContent("Hört zu …");
expect(screen.getByText("und Stifte")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Stopp/ })).toBeInTheDocument();
});
it("says „Nur auf dem Gerät“ while offline", async () => {
vi.spyOn(window.navigator, "onLine", "get").mockReturnValue(false);
await renderSettled(<Harness />);
expect(screen.getByRole("status")).toHaveTextContent("Nur auf dem Gerät");
expect(screen.getByRole("button", { name: /Diktieren/ })).toBeEnabled();
});
it("disables dictation offline when only the cloud would work", async () => {
FakeRecognition.localAnswer = "downloadable";
setCloudRecognitionConsent(true);
vi.spyOn(window.navigator, "onLine", "get").mockReturnValue(false);
await renderSettled(<Harness />);
expect(screen.getByRole("button", { name: /Diktieren/ })).toBeDisabled();
expect(screen.getByRole("status")).toHaveTextContent("Offline nicht möglich");
});
it("stops on the stop button and releases the recognizer", async () => {
const user = userEvent.setup();
const { unmount } = await renderSettled(<Harness />);
await user.click(screen.getByRole("button", { name: /Diktieren/ }));
await user.click(screen.getByRole("button", { name: /Stopp/ }));
const engine = FakeRecognition.instances[0];
expect(engine.stop).toHaveBeenCalledTimes(1);
act(() => engine.handlers.end({}));
await waitFor(() =>
expect(screen.getByRole("button", { name: /Diktieren/ })).toBeInTheDocument(),
);
// A second session that is still running when the page goes away is aborted.
await user.click(screen.getByRole("button", { name: /Diktieren/ }));
const second = FakeRecognition.instances[1];
unmount();
expect(second.abort).toHaveBeenCalled();
});
it("points installed iOS PWAs to a voice note instead of the button", async () => {
vi.spyOn(window.navigator, "userAgent", "get").mockReturnValue(
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)",
);
Object.defineProperty(window.navigator, "standalone", { value: true, configurable: true });
await renderSettled(<Harness />);
expect(screen.queryByRole("button", { name: /Diktieren/ })).not.toBeInTheDocument();
expect(screen.getByText("Hier lieber eine Sprachnotiz aufnehmen")).toBeInTheDocument();
Reflect.deleteProperty(window.navigator, "standalone");
});
it("reports a refused microphone as text", async () => {
const user = userEvent.setup();
await renderSettled(<Harness />);
await user.click(screen.getByRole("button", { name: /Diktieren/ }));
act(() => FakeRecognition.instances[0].handlers.error({ error: "not-allowed" }));
expect(screen.getByRole("status")).toHaveTextContent("Mikrofon nicht erlaubt");
});
it("shows the German error text when the engine cannot be built or started", async () => {
const user = userEvent.setup();
await renderSettled(<Harness />);
FakeRecognition.throwOnStart = true;
await user.click(screen.getByRole("button", { name: /Diktieren/ }));
expect(screen.getByRole("status")).toHaveTextContent("Diktat nicht möglich");
FakeRecognition.throwOnStart = false;
});
it("re-reads the consent at start, so a withdrawal on the settings page counts", async () => {
FakeRecognition.localAnswer = "unavailable";
setCloudRecognitionConsent(true);
const user = userEvent.setup();
await renderSettled(<Harness />);
setCloudRecognitionConsent(false);
await user.click(screen.getByRole("button", { name: /Diktieren/ }));
expect(FakeRecognition.instances).toHaveLength(0);
expect(screen.getByRole("status")).toHaveTextContent("Cloud-Erkennung zuerst erlauben");
});
});
@@ -0,0 +1,167 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import {
createRecognition,
detectSpeechSupport,
type RecognitionHandle,
type SpeechSupport,
} from "@/lib/voice/speech-support";
import { getCloudRecognitionConsent } from "@/lib/voice/consent";
import { VOICE_VOCABULARY } from "@/lib/voice/vocabulary";
export type DictationState = "idle" | "listening" | "unavailable" | "needs-consent" | "error";
/** Why the last session failed; only set in state `error`. */
export type DictationFailure = "microphone" | "other";
interface UseDictationOptions {
onFinal(text: string): void;
}
export interface UseDictationResult {
state: DictationState;
interim: string;
start(): void;
stop(): void;
/** `null` until the browser has been asked. */
support: SpeechSupport | null;
/** Recognition runs on the device (no audio leaves it). */
onDevice: boolean;
online: boolean;
failure: DictationFailure | null;
}
const MICROPHONE_ERRORS = new Set(["not-allowed", "audio-capture"]);
const CONSENT_ERRORS = new Set(["service-not-allowed", "language-not-supported"]);
const QUIET_ERRORS = new Set(["no-speech", "aborted"]);
function useOnline(): boolean {
const [online, setOnline] = useState(true);
useEffect(() => {
const sync = () => setOnline(window.navigator.onLine);
sync();
window.addEventListener("online", sync);
window.addEventListener("offline", sync);
return () => {
window.removeEventListener("online", sync);
window.removeEventListener("offline", sync);
};
}, []);
return online;
}
function useSpeechSupport(): SpeechSupport | null {
const [support, setSupport] = useState<SpeechSupport | null>(null);
useEffect(() => {
let cancelled = false;
void detectSpeechSupport().then((detected) => {
if (!cancelled) {
setSupport(detected);
}
});
return () => {
cancelled = true;
};
}, []);
return support;
}
/**
* One-shot browser dictation (`continuous: false`). Recognition runs on the
* device when the engine says it can; otherwise only with the stored cloud
* consent. Results are handed out, never stored here.
*/
export function useDictation({ onFinal }: UseDictationOptions): UseDictationResult {
const support = useSpeechSupport();
const online = useOnline();
const [phase, setPhase] = useState<"idle" | "listening" | "needs-consent" | "error">("idle");
const [failure, setFailure] = useState<DictationFailure | null>(null);
const [interim, setInterim] = useState("");
const handleRef = useRef<RecognitionHandle | null>(null);
const onFinalRef = useRef(onFinal);
useEffect(() => {
onFinalRef.current = onFinal;
}, [onFinal]);
const onDevice = support?.local === "available";
const usable = support !== null && support.api && (onDevice || support.cloudAllowed);
const release = useCallback(() => {
handleRef.current = null;
setInterim("");
}, []);
const start = useCallback(() => {
if (handleRef.current || !support || !usable) {
return;
}
// The switch on the settings page may have changed since detection.
const cloudAllowed = getCloudRecognitionConsent();
if (!onDevice && !cloudAllowed) {
setPhase("needs-consent");
return;
}
let handle: RecognitionHandle | null = null;
try {
handle = createRecognition({
lang: "de-DE",
continuous: false,
interimResults: true,
maxAlternatives: 1,
processLocally: onDevice,
phrases: VOICE_VOCABULARY,
});
if (!handle) {
return;
}
handleRef.current = handle;
setFailure(null);
handle.on("result", (event) => {
setInterim(event.interimText);
if (event.finalText) {
onFinalRef.current(event.finalText);
}
});
handle.on("end", () => {
release();
setPhase((current) => (current === "listening" ? "idle" : current));
});
handle.on("error", (code) => {
release();
if (QUIET_ERRORS.has(code)) {
setPhase("idle");
} else if (CONSENT_ERRORS.has(code) && !getCloudRecognitionConsent()) {
setPhase("needs-consent");
} else {
setFailure(MICROPHONE_ERRORS.has(code) ? "microphone" : "other");
setPhase("error");
}
});
setPhase("listening");
handle.start();
} catch {
// Any failure while building or starting the engine shows as text.
release();
setFailure("other");
setPhase("error");
}
}, [support, usable, onDevice, release]);
const stop = useCallback(() => {
// The engine still delivers a pending result and then fires "end".
handleRef.current?.stop();
}, []);
useEffect(
() => () => {
handleRef.current?.abort();
handleRef.current = null;
},
[],
);
const state: DictationState = !usable && phase !== "needs-consent" ? "unavailable" : phase;
return { state, interim, start, stop, support, onDevice, online, failure };
}
@@ -1,5 +1,6 @@
import type { ApiaryStatus } from "./types"; import type { ApiaryStatus } from "./types";
import type { NextStep, SeasonSegment } from "@/lib/autopilot/types"; import type { NextStep, SeasonSegment } from "@/lib/autopilot/types";
import type { HiveSetupView, HiveSystemDto } from "@/lib/hive/types";
import type { Colony, ColonyCondition, ColonyStatus } from "@/lib/colonies/types"; import type { Colony, ColonyCondition, ColonyStatus } from "@/lib/colonies/types";
import type { Inspection } from "@/lib/inspections/types"; import type { Inspection } from "@/lib/inspections/types";
import type { Queen } from "@/lib/queens/types"; import type { Queen } from "@/lib/queens/types";
@@ -97,4 +98,11 @@ export interface OfflinePackV1 {
* returns as `steps`). Absent in packs cached before T4. * returns as `steps`). Absent in packs cached before T4.
*/ */
nextSteps?: Record<string, NextStep[]>; nextSteps?: Record<string, NextStep[]>;
/**
* The hive setup per colony id: only colonies of this apiary that have one.
* Absent in packs cached before the hive card.
*/
hives?: Record<string, HiveSetupView>;
/** The hive systems with their part types. Absent in older packs. */
hiveCatalog?: HiveSystemDto[];
} }
@@ -120,6 +120,8 @@ interface PackOptions {
inspections?: OfflinePackV1["inspections"]; inspections?: OfflinePackV1["inspections"];
tasks?: OfflinePackV1["tasks"]; tasks?: OfflinePackV1["tasks"];
inspectionsVisible?: boolean; inspectionsVisible?: boolean;
hives?: OfflinePackV1["hives"];
hiveCatalog?: OfflinePackV1["hiveCatalog"];
} }
/** A pack as the server sends it: colonies, their overview rows and the sections. */ /** A pack as the server sends it: colonies, their overview rows and the sections. */
@@ -131,6 +133,8 @@ export function offlinePack({
inspections = [], inspections = [],
tasks = [], tasks = [],
inspectionsVisible = true, inspectionsVisible = true,
hives,
hiveCatalog,
}: PackOptions = {}): OfflinePackV1 { }: PackOptions = {}): OfflinePackV1 {
return { return {
version: 1, version: 1,
@@ -145,6 +149,9 @@ export function offlinePack({
queens, queens,
inspections, inspections,
tasks, tasks,
// Like the server: absent in packs from before the hive card.
...(hives ? { hives } : {}),
...(hiveCatalog ? { hiveCatalog } : {}),
}; };
} }
+60
View File
@@ -1,11 +1,15 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { ApiError, apiFetch } from "@/lib/auth/api-client"; import { ApiError, apiFetch } from "@/lib/auth/api-client";
import { listPacks } from "@/lib/apiaries/offline-pack";
import { offlinePack, packColony, storedPack } from "@/lib/apiaries/offline-pack.fixtures";
import { setupView, systemDto } from "@/components/hive/test-fixtures";
import { import {
createHive, createHive,
fetchHive, fetchHive,
fetchHiveAt, fetchHiveAt,
fetchHiveHistory, fetchHiveHistory,
fetchHiveSystems, fetchHiveSystems,
fetchHiveSystemsWithFallback,
fetchHiveTemplate, fetchHiveTemplate,
hiveConflictOf, hiveConflictOf,
hiveErrorCode, hiveErrorCode,
@@ -18,6 +22,17 @@ vi.mock("@/lib/auth/api-client", async (importOriginal) => {
return { ...original, apiFetch: vi.fn() }; return { ...original, apiFetch: vi.fn() };
}); });
vi.mock("@/lib/apiaries/offline-pack", () => ({ listPacks: vi.fn() }));
const NETWORK_DOWN = new TypeError("Failed to fetch");
const packWithHive = storedPack(
offlinePack({
colonies: [packColony("colony-1", "V-1"), packColony("colony-2", "V-2")],
hives: { "colony-1": setupView() },
hiveCatalog: [systemDto()],
}),
);
describe("hive api", () => { describe("hive api", () => {
beforeEach(() => { beforeEach(() => {
vi.mocked(apiFetch).mockReset(); vi.mocked(apiFetch).mockReset();
@@ -66,4 +81,49 @@ describe("hive api", () => {
expect(hiveErrorCode(new ApiError(404, "nf", { code: "HIVE_NOT_FOUND" }))).toBe("HIVE_NOT_FOUND"); expect(hiveErrorCode(new ApiError(404, "nf", { code: "HIVE_NOT_FOUND" }))).toBe("HIVE_NOT_FOUND");
expect(hiveErrorCode(new ApiError(500, "x"))).toBeNull(); expect(hiveErrorCode(new ApiError(500, "x"))).toBeNull();
}); });
describe("offline reading", () => {
beforeEach(() => {
vi.mocked(listPacks).mockReset().mockResolvedValue([packWithHive]);
vi.mocked(apiFetch).mockRejectedValue(NETWORK_DOWN);
});
it("answers the hive from the pack when the network fails", async () => {
await expect(fetchHive("colony-1", "user-1")).resolves.toEqual(setupView());
expect(listPacks).toHaveBeenCalledWith("user-1");
});
it("answers HIVE_NOT_FOUND for a colony the pack knows has no setup", async () => {
const error = await fetchHive("colony-2", "user-1").catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(ApiError);
expect(hiveErrorCode(error)).toBe("HIVE_NOT_FOUND");
});
it("passes the network error on when the pack cannot tell (older pack, unknown colony)", async () => {
vi.mocked(listPacks).mockResolvedValue([storedPack(offlinePack())]);
await expect(fetchHive("colony-1", "user-1")).rejects.toBe(NETWORK_DOWN);
vi.mocked(listPacks).mockResolvedValue([packWithHive]);
await expect(fetchHive("unknown", "user-1")).rejects.toBe(NETWORK_DOWN);
});
it("never covers up a server answer with the pack", async () => {
vi.mocked(apiFetch).mockRejectedValue(new ApiError(404, "nf", { code: "HIVE_NOT_FOUND" }));
await expect(fetchHive("colony-1", "user-1")).rejects.toBeInstanceOf(ApiError);
expect(listPacks).not.toHaveBeenCalled();
});
it("reads the server only without a user", async () => {
await expect(fetchHive("colony-1")).rejects.toBe(NETWORK_DOWN);
expect(listPacks).not.toHaveBeenCalled();
});
it("falls back to the catalog of the pack for the systems", async () => {
await expect(fetchHiveSystemsWithFallback("user-1")).resolves.toEqual([systemDto()]);
});
it("passes the network error on when no pack carries a catalog", async () => {
vi.mocked(listPacks).mockResolvedValue([storedPack(offlinePack())]);
await expect(fetchHiveSystemsWithFallback("user-1")).rejects.toBe(NETWORK_DOWN);
});
});
}); });
Loaded 100 of 123 files, more files were not shown because too many files have changed in this diff. Show more