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:
1 parent
ab45d6e24e
commit
1a57de8573
123 files changed
+7639
-355
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,
|
||||||
|
|||||||
@@ -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,17 +104,18 @@ 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 ?? []);
|
||||||
const recipients = await this.recipients(db, keys, [userId]);
|
return this.inTransaction(tx, async (db) => {
|
||||||
const [row] = await this.write(
|
const recipients = await this.recipients(db, keys, [userId]);
|
||||||
db,
|
const [row] = await this.write(
|
||||||
recipients.map((id) => ({ userId: id, dedupeKey: dedupeKey ?? null })),
|
db,
|
||||||
content,
|
recipients.map((id) => ({ userId: id, dedupeKey: dedupeKey ?? null })),
|
||||||
keys,
|
content,
|
||||||
);
|
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(
|
||||||
|
|||||||
@@ -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 = (
|
||||||
where: {
|
await prisma.user.findMany({
|
||||||
isActive: true,
|
where: {
|
||||||
role: {
|
isActive: true,
|
||||||
status: 'ACTIVE',
|
role: {
|
||||||
permissions: { some: { permission: { key: 'compliance:manage' } } },
|
status: 'ACTIVE',
|
||||||
|
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',
|
||||||
|
|||||||
@@ -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"
|
||||||
|
]
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Generated
+1
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,50 +858,70 @@ function InspectionCaptureForm({
|
|||||||
</Field>
|
</Field>
|
||||||
<Field label="Varroa-Befund">
|
<Field label="Varroa-Befund">
|
||||||
{({ id }) => (
|
{({ id }) => (
|
||||||
<Input
|
<DictationRow
|
||||||
id={id}
|
fieldLabel="Varroa-Befund"
|
||||||
className="h-11"
|
onAppend={(text) => appendDictated("varroaIssues", text)}
|
||||||
value={values.varroaIssues}
|
>
|
||||||
onChange={(event) =>
|
<Input
|
||||||
set("varroaIssues", event.target.value)
|
id={id}
|
||||||
}
|
className="h-11"
|
||||||
/>
|
value={values.varroaIssues}
|
||||||
|
onChange={(event) =>
|
||||||
|
set("varroaIssues", event.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</DictationRow>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Gesundheitsauffälligkeiten">
|
<Field label="Gesundheitsauffälligkeiten">
|
||||||
{({ id }) => (
|
{({ id }) => (
|
||||||
<Input
|
<DictationRow
|
||||||
id={id}
|
fieldLabel="Gesundheitsauffälligkeiten"
|
||||||
className="h-11"
|
onAppend={(text) => appendDictated("healthIssues", text)}
|
||||||
value={values.healthIssues}
|
>
|
||||||
onChange={(event) =>
|
<Input
|
||||||
set("healthIssues", event.target.value)
|
id={id}
|
||||||
}
|
className="h-11"
|
||||||
/>
|
value={values.healthIssues}
|
||||||
|
onChange={(event) =>
|
||||||
|
set("healthIssues", event.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</DictationRow>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Maßnahmen">
|
<Field label="Maßnahmen">
|
||||||
{({ id }) => (
|
{({ id }) => (
|
||||||
<Textarea
|
<DictationRow
|
||||||
id={id}
|
fieldLabel="Maßnahmen"
|
||||||
value={values.measures}
|
onAppend={(text) => appendDictated("measures", text)}
|
||||||
onChange={(event) => set("measures", event.target.value)}
|
>
|
||||||
/>
|
<Textarea
|
||||||
|
id={id}
|
||||||
|
value={values.measures}
|
||||||
|
onChange={(event) => set("measures", event.target.value)}
|
||||||
|
/>
|
||||||
|
</DictationRow>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Notizen">
|
<Field label="Notizen">
|
||||||
{({ id }) => (
|
{({ id }) => (
|
||||||
<Textarea
|
<DictationRow
|
||||||
id={id}
|
fieldLabel="Notizen"
|
||||||
value={values.notes}
|
onAppend={(text) => appendDictated("notes", text)}
|
||||||
onChange={(event) => set("notes", event.target.value)}
|
>
|
||||||
/>
|
<Textarea
|
||||||
|
id={id}
|
||||||
|
value={values.notes}
|
||||||
|
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 & 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) => {
|
||||||
...(await importOriginal<typeof import("@/lib/hive/api")>()),
|
const fetchHiveSystems = vi.fn();
|
||||||
fetchHive: vi.fn(),
|
return {
|
||||||
fetchHiveSystems: vi.fn(),
|
...(await importOriginal<typeof import("@/lib/hive/api")>()),
|
||||||
fetchHiveTemplate: vi.fn(),
|
fetchHive: vi.fn(),
|
||||||
fetchHiveHistory: vi.fn(),
|
fetchHiveSystems,
|
||||||
fetchHiveAt: vi.fn(),
|
// The offline-aware variant answers like the plain one here (no offline packs in this spec).
|
||||||
postHiveChange: vi.fn(),
|
fetchHiveSystemsWithFallback: vi.fn(() => fetchHiveSystems()),
|
||||||
createHive: vi.fn(),
|
fetchHiveTemplate: vi.fn(),
|
||||||
|
fetchHiveHistory: vi.fn(),
|
||||||
|
fetchHiveAt: vi.fn(),
|
||||||
|
postHiveChange: 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,29 +318,31 @@ function HiveCardBody({ colonyId, setup, catalog, canEdit, mode, onOpsChange }:
|
|||||||
partName={partName}
|
partName={partName}
|
||||||
onSelectPart={selectPart}
|
onSelectPart={selectPart}
|
||||||
onSelectFrame={editFrame}
|
onSelectFrame={editFrame}
|
||||||
|
insets={insets}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{focusedId ? (
|
<div ref={topOverlayRef} className="pointer-events-none absolute inset-x-3 top-3">
|
||||||
<HiveFocusBar
|
{focusedId ? (
|
||||||
focusedId={focusedId}
|
<HiveFocusBar
|
||||||
name={partName(focusedId)}
|
focusedId={focusedId}
|
||||||
chips={focusChips(shownState, catalog, focusedId)}
|
name={partName(focusedId)}
|
||||||
canEdit={editable && hasSheet(focusedId)}
|
chips={focusChips(shownState, catalog, focusedId)}
|
||||||
onEdit={() => editPart(focusedId)}
|
canEdit={editable && hasSheet(focusedId)}
|
||||||
onBack={leaveFocus}
|
onEdit={() => editPart(focusedId)}
|
||||||
/>
|
onBack={leaveFocus}
|
||||||
) : (
|
/>
|
||||||
<div
|
) : (
|
||||||
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}`}
|
<div className={`w-fit max-w-[min(26rem,100%)] rounded-2xl px-4 py-2.5 sm: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,22 +225,22 @@ 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. */}
|
||||||
const frameTarget = targets.frames.get(frameKey(frame.boxId, frame.slot));
|
{frames.map((frame) => {
|
||||||
if (!frameTarget || frameTarget.look === "hidden") return null;
|
const frameTarget = targets.frames.get(frameKey(frame.boxId, frame.slot));
|
||||||
return (
|
if (!frameTarget || frameTarget.look === "hidden") return null;
|
||||||
<FramePlate
|
return (
|
||||||
key={frame.slot}
|
<FramePlate
|
||||||
frame={frame}
|
key={frame.slot}
|
||||||
hostCenter={placed.center}
|
frame={frame}
|
||||||
target={frameTarget}
|
hostCenter={placed.center}
|
||||||
handlers={handlersFor(frameTargetKey(frame.boxId, frame.slot))}
|
target={frameTarget}
|
||||||
label={frameLabel(labels, frame.boxId, frame.slot)}
|
handlers={handlersFor(frameTargetKey(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 & 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 } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
Reference in new issue
Block a user