From da34221539690bb9e7dc57d3a9900102179c4b02 Mon Sep 17 00:00:00 2001 From: Admin Date: Wed, 23 Sep 2026 01:14:28 +0000 Subject: [PATCH] mirror: fieldlens d38e0922441a (1/2) --- .dockerignore | 8 + .env.example | 11 + .gitea/workflows/ci.yml | 21 + .gitignore | 8 + Dockerfile | 47 + README.md | 36 +- apps/api/.env.example | 12 + apps/api/package.json | 29 + apps/api/src/auth.test.ts | 15 + apps/api/src/auth.ts | 90 ++ apps/api/src/config.ts | 20 + apps/api/src/context.ts | 31 + apps/api/src/db.ts | 20 + apps/api/src/log.ts | 11 + apps/api/src/server.ts | 283 ++++++ apps/api/src/sync.ts | 174 ++++ apps/api/tsconfig.json | 14 + apps/web/.env.example | 4 + apps/web/index.html | 15 + apps/web/package.json | 29 + apps/web/src/HostStatus.tsx | 34 + apps/web/src/auth.ts | 54 + apps/web/src/host.css | 1 + apps/web/src/main.tsx | 26 + apps/web/src/referenceProvider.ts | 15 + apps/web/src/sync.test.ts | 7 + apps/web/src/sync.ts | 117 +++ apps/web/tsconfig.json | 20 + apps/web/vite.config.ts | 34 + docker-compose.yml | 42 + migrations/001_initial.sql | 122 +++ migrations/002_bootstrap.sql | 11 + package.json | 21 + packages/contracts/package.json | 20 + packages/contracts/src/index.ts | 116 +++ packages/contracts/tsconfig.json | 13 + packages/fieldlens-core/.env.example | 2 + .../fieldlens-core/.github/workflows/ci.yml | 19 + packages/fieldlens-core/.gitignore | 24 + packages/fieldlens-core/.oxlintrc.json | 8 + packages/fieldlens-core/ARROWSTACK_PLAN.md | 545 +++++++++++ packages/fieldlens-core/README.md | 62 ++ packages/fieldlens-core/index.html | 19 + packages/fieldlens-core/package.json | 39 + .../public/apple-touch-icon.png | Bin 0 -> 525 bytes packages/fieldlens-core/public/favicon-16.png | Bin 0 -> 165 bytes packages/fieldlens-core/public/favicon-32.png | Bin 0 -> 177 bytes packages/fieldlens-core/public/favicon.svg | 10 + .../public/fieldlens-horizontal.svg | 11 + .../fieldlens-core/public/fieldlens-mark.svg | 1 + .../public/fieldlens-wordmark.svg | 1 + packages/fieldlens-core/public/icons.svg | 24 + packages/fieldlens-core/public/pwa-192.png | Bin 0 -> 552 bytes packages/fieldlens-core/public/pwa-512.png | Bin 0 -> 1559 bytes packages/fieldlens-core/src/App.css | 223 +++++ packages/fieldlens-core/src/App.tsx | 244 +++++ .../fieldlens-core/src/detection-rotation.css | 64 ++ .../src/features/codes/CodeLab.css | 147 +++ .../src/features/codes/CodeLab.tsx | 162 +++ .../src/features/ean-pallet/EanPalletView.css | 1 + .../src/features/ean-pallet/EanPalletView.tsx | 186 ++++ .../src/features/history/HistoryView.css | 381 ++++++++ .../src/features/history/HistoryView.tsx | 166 ++++ .../features/label-check/ArPalletOverlay.css | 51 + .../features/label-check/ArPalletOverlay.tsx | 308 ++++++ .../label-check/CartonConfigPicker.css | 16 + .../label-check/CartonConfigPicker.tsx | 108 ++ .../features/label-check/CartonLabelScan.css | 276 ++++++ .../features/label-check/LabelCheckView.css | 172 ++++ .../features/label-check/LabelCheckView.tsx | 920 ++++++++++++++++++ .../src/features/scanner/ScannerView.tsx | 377 +++++++ .../src/features/settings/SettingsView.tsx | 92 ++ packages/fieldlens-core/src/index.css | 17 + packages/fieldlens-core/src/ios-safe-area.css | 18 + .../fieldlens-core/src/lib/barcodes.test.ts | 31 + packages/fieldlens-core/src/lib/barcodes.ts | 96 ++ .../src/lib/cartonArTracking.test.ts | 91 ++ .../src/lib/cartonArTracking.ts | 354 +++++++ .../fieldlens-core/src/lib/cartonConfigs.ts | 56 ++ .../src/lib/cartonLabelConformity.test.ts | 73 ++ .../src/lib/cartonLabelConformity.ts | 191 ++++ .../src/lib/cartonLabelDetection.test.ts | 20 + .../src/lib/cartonLabelDetection.ts | 189 ++++ .../src/lib/cartonTrackAssociation.test.ts | 38 + .../src/lib/cartonTrackAssociation.ts | 129 +++ .../fieldlens-core/src/lib/database.test.ts | 116 +++ packages/fieldlens-core/src/lib/database.ts | 386 ++++++++ .../src/lib/deviceOrientation.test.ts | 28 + .../src/lib/deviceOrientation.ts | 265 +++++ .../fieldlens-core/src/lib/eanPallet.test.ts | 64 ++ packages/fieldlens-core/src/lib/eanPallet.ts | 104 ++ .../src/lib/labelExpectationProviders.ts | 51 + .../fieldlens-core/src/lib/labelOcr.test.ts | 113 +++ packages/fieldlens-core/src/lib/labelOcr.ts | 363 +++++++ .../src/lib/preferences.test.ts | 33 + .../fieldlens-core/src/lib/preferences.ts | 50 + packages/fieldlens-core/src/lib/scanAudio.ts | 15 + packages/fieldlens-core/src/main.tsx | 17 + .../fieldlens-core/src/phone-landscape.css | 173 ++++ packages/fieldlens-core/src/ui-polish.css | 161 +++ 100 files changed, 9430 insertions(+), 2 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitea/workflows/ci.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 apps/api/.env.example create mode 100644 apps/api/package.json create mode 100644 apps/api/src/auth.test.ts create mode 100644 apps/api/src/auth.ts create mode 100644 apps/api/src/config.ts create mode 100644 apps/api/src/context.ts create mode 100644 apps/api/src/db.ts create mode 100644 apps/api/src/log.ts create mode 100644 apps/api/src/server.ts create mode 100644 apps/api/src/sync.ts create mode 100644 apps/api/tsconfig.json create mode 100644 apps/web/.env.example create mode 100644 apps/web/index.html create mode 100644 apps/web/package.json create mode 100644 apps/web/src/HostStatus.tsx create mode 100644 apps/web/src/auth.ts create mode 100644 apps/web/src/host.css create mode 100644 apps/web/src/main.tsx create mode 100644 apps/web/src/referenceProvider.ts create mode 100644 apps/web/src/sync.test.ts create mode 100644 apps/web/src/sync.ts create mode 100644 apps/web/tsconfig.json create mode 100644 apps/web/vite.config.ts create mode 100644 docker-compose.yml create mode 100644 migrations/001_initial.sql create mode 100644 migrations/002_bootstrap.sql create mode 100644 package.json create mode 100644 packages/contracts/package.json create mode 100644 packages/contracts/src/index.ts create mode 100644 packages/contracts/tsconfig.json create mode 100644 packages/fieldlens-core/.env.example create mode 100644 packages/fieldlens-core/.github/workflows/ci.yml create mode 100644 packages/fieldlens-core/.gitignore create mode 100644 packages/fieldlens-core/.oxlintrc.json create mode 100644 packages/fieldlens-core/ARROWSTACK_PLAN.md create mode 100644 packages/fieldlens-core/README.md create mode 100644 packages/fieldlens-core/index.html create mode 100644 packages/fieldlens-core/package.json create mode 100644 packages/fieldlens-core/public/apple-touch-icon.png create mode 100644 packages/fieldlens-core/public/favicon-16.png create mode 100644 packages/fieldlens-core/public/favicon-32.png create mode 100644 packages/fieldlens-core/public/favicon.svg create mode 100644 packages/fieldlens-core/public/fieldlens-horizontal.svg create mode 100644 packages/fieldlens-core/public/fieldlens-mark.svg create mode 100644 packages/fieldlens-core/public/fieldlens-wordmark.svg create mode 100644 packages/fieldlens-core/public/icons.svg create mode 100644 packages/fieldlens-core/public/pwa-192.png create mode 100644 packages/fieldlens-core/public/pwa-512.png create mode 100644 packages/fieldlens-core/src/App.css create mode 100644 packages/fieldlens-core/src/App.tsx create mode 100644 packages/fieldlens-core/src/detection-rotation.css create mode 100644 packages/fieldlens-core/src/features/codes/CodeLab.css create mode 100644 packages/fieldlens-core/src/features/codes/CodeLab.tsx create mode 100644 packages/fieldlens-core/src/features/ean-pallet/EanPalletView.css create mode 100644 packages/fieldlens-core/src/features/ean-pallet/EanPalletView.tsx create mode 100644 packages/fieldlens-core/src/features/history/HistoryView.css create mode 100644 packages/fieldlens-core/src/features/history/HistoryView.tsx create mode 100644 packages/fieldlens-core/src/features/label-check/ArPalletOverlay.css create mode 100644 packages/fieldlens-core/src/features/label-check/ArPalletOverlay.tsx create mode 100644 packages/fieldlens-core/src/features/label-check/CartonConfigPicker.css create mode 100644 packages/fieldlens-core/src/features/label-check/CartonConfigPicker.tsx create mode 100644 packages/fieldlens-core/src/features/label-check/CartonLabelScan.css create mode 100644 packages/fieldlens-core/src/features/label-check/LabelCheckView.css create mode 100644 packages/fieldlens-core/src/features/label-check/LabelCheckView.tsx create mode 100644 packages/fieldlens-core/src/features/scanner/ScannerView.tsx create mode 100644 packages/fieldlens-core/src/features/settings/SettingsView.tsx create mode 100644 packages/fieldlens-core/src/index.css create mode 100644 packages/fieldlens-core/src/ios-safe-area.css create mode 100644 packages/fieldlens-core/src/lib/barcodes.test.ts create mode 100644 packages/fieldlens-core/src/lib/barcodes.ts create mode 100644 packages/fieldlens-core/src/lib/cartonArTracking.test.ts create mode 100644 packages/fieldlens-core/src/lib/cartonArTracking.ts create mode 100644 packages/fieldlens-core/src/lib/cartonConfigs.ts create mode 100644 packages/fieldlens-core/src/lib/cartonLabelConformity.test.ts create mode 100644 packages/fieldlens-core/src/lib/cartonLabelConformity.ts create mode 100644 packages/fieldlens-core/src/lib/cartonLabelDetection.test.ts create mode 100644 packages/fieldlens-core/src/lib/cartonLabelDetection.ts create mode 100644 packages/fieldlens-core/src/lib/cartonTrackAssociation.test.ts create mode 100644 packages/fieldlens-core/src/lib/cartonTrackAssociation.ts create mode 100644 packages/fieldlens-core/src/lib/database.test.ts create mode 100644 packages/fieldlens-core/src/lib/database.ts create mode 100644 packages/fieldlens-core/src/lib/deviceOrientation.test.ts create mode 100644 packages/fieldlens-core/src/lib/deviceOrientation.ts create mode 100644 packages/fieldlens-core/src/lib/eanPallet.test.ts create mode 100644 packages/fieldlens-core/src/lib/eanPallet.ts create mode 100644 packages/fieldlens-core/src/lib/labelExpectationProviders.ts create mode 100644 packages/fieldlens-core/src/lib/labelOcr.test.ts create mode 100644 packages/fieldlens-core/src/lib/labelOcr.ts create mode 100644 packages/fieldlens-core/src/lib/preferences.test.ts create mode 100644 packages/fieldlens-core/src/lib/preferences.ts create mode 100644 packages/fieldlens-core/src/lib/scanAudio.ts create mode 100644 packages/fieldlens-core/src/main.tsx create mode 100644 packages/fieldlens-core/src/phone-landscape.css create mode 100644 packages/fieldlens-core/src/ui-polish.css diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..474095d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +node_modules +**/node_modules +**/dist +.git +.env +.env.* +!.env.example +*.log diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5394315 --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +FIELDLENS_ENV=development +LOG_LEVEL=info +PORT=3000 +DATABASE_URL=postgresql://user:password@host:5432/database +KEYCLOAK_ISSUER=https://auth.example.invalid/realms/example +KEYCLOAK_JWKS_URI=https://auth.example.invalid/realms/example/protocol/openid-connect/certs +KEYCLOAK_AUDIENCE=fieldlens-api +KEYCLOAK_BROWSER_CLIENT_ID=fieldlens +KEYCLOAK_MACHINE_CLIENT_ID=fieldlens-integration +FIELDLENS_TENANT_ID=fieldlens-production +WEB_ORIGIN=https://fieldlens.example.invalid diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..2042b16 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,21 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + - run: npm install + - run: npm run test + - run: npm run lint + - run: npm run migration:validate + - run: npm run build + - run: docker build -t fieldlens-ci . diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d5e0b2b --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +coverage/ +.env +.env.* +!.env.example +*.log +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..aa09189 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,47 @@ +FROM node:24-alpine AS build +WORKDIR /workspace + +ARG VITE_API_BASE_URL="" +ARG VITE_KEYCLOAK_ISSUER="" +ARG VITE_KEYCLOAK_CLIENT_ID="fieldlens" +ARG FIELDLENS_VERSION="dev" + +ENV VITE_API_BASE_URL=$VITE_API_BASE_URL +ENV VITE_KEYCLOAK_ISSUER=$VITE_KEYCLOAK_ISSUER +ENV VITE_KEYCLOAK_CLIENT_ID=$VITE_KEYCLOAK_CLIENT_ID +ENV VITE_APP_VERSION=$FIELDLENS_VERSION + +COPY package.json ./ +COPY apps/api/package.json apps/api/package.json +COPY apps/web/package.json apps/web/package.json +COPY packages/contracts/package.json packages/contracts/package.json +COPY packages/fieldlens-core/package.json packages/fieldlens-core/package.json + +RUN npm install + +COPY . . +RUN npm run verify +RUN npm prune --omit=dev + +FROM node:24-alpine AS runtime +WORKDIR /app + +ENV NODE_ENV=production +ENV PORT=3000 +ENV STATIC_DIR=/app/web + +COPY --from=build /workspace/package.json /app/package.json +COPY --from=build /workspace/node_modules /app/node_modules +COPY --from=build /workspace/apps/api/package.json /app/apps/api/package.json +COPY --from=build /workspace/apps/api/dist /app/apps/api/dist +COPY --from=build /workspace/packages/contracts/package.json /app/packages/contracts/package.json +COPY --from=build /workspace/packages/contracts/dist /app/packages/contracts/dist +COPY --from=build /workspace/apps/web/dist /app/web + +USER node +EXPOSE 3000 + +HEALTHCHECK --interval=15s --timeout=3s --start-period=20s --retries=5 \ + CMD wget -qO- http://127.0.0.1:3000/api/health/live >/dev/null || exit 1 + +CMD ["node", "apps/api/dist/server.js"] diff --git a/README.md b/README.md index 03aa88a..c840c9f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,35 @@ -# fieldlens-deploy +# FieldLens -Credential-free deployment mirror of private Admin/fieldlens. No secrets. \ No newline at end of file +FieldLens is the Arrowstack-hosted production application around the reusable, local-first FieldLens Core. + +## Repository boundary + +- `apps/web` owns Arrowstack browser authentication, sync, station/device status and the production PWA shell. +- `apps/api` owns JWT/RBAC, sync ingestion, PostgreSQL, device/station registration and server-side provider adapters. +- `packages/contracts` is the versioned network contract shared by the browser and API. +- `packages/fieldlens-core` is a pinned vendor copy of the public reusable core. +- `migrations` contains versioned PostgreSQL schema changes. + +The public source of truth is `dmparrow/feildlens-core` (spelling intentional). The initial vendor import is pinned to commit: + +`90d38751eea2bed79f4c9f54c75687ea9aba350a` + +The private prototype `dmparrow/net` is not a production dependency and is not modified by this repository. + +## Local-first invariant + +Capture writes to IndexedDB first. Remote authentication, API availability, PostgreSQL availability and reference-data availability are not prerequisites for scanner, EAN pallet, OCR/conformity or local review workflows. + +## Configuration + +See the root and app-specific `.env.example` files. Never commit production secrets. + +## Validation + +```bash +npm install +npm run verify +docker build -t fieldlens . +``` + +Production migrations are applied explicitly before application deployment. The API runtime account is not expected to perform schema DDL on startup. diff --git a/apps/api/.env.example b/apps/api/.env.example new file mode 100644 index 0000000..8b663bb --- /dev/null +++ b/apps/api/.env.example @@ -0,0 +1,12 @@ +PORT=3000 +DATABASE_URL=postgresql://user:password@host:5432/database +KEYCLOAK_ISSUER=https://auth.example.invalid/realms/example +KEYCLOAK_JWKS_URI=https://auth.example.invalid/realms/example/protocol/openid-connect/certs +KEYCLOAK_AUDIENCE=fieldlens-api +KEYCLOAK_BROWSER_CLIENT_ID=fieldlens +KEYCLOAK_MACHINE_CLIENT_ID=fieldlens-integration +FIELDLENS_TENANT_ID=fieldlens-production +FIELDLENS_ENV=development +LOG_LEVEL=info +WEB_ORIGIN=http://localhost:5173 +STATIC_DIR= diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..695fa36 --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,29 @@ +{ + "name": "@fieldlens/api", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "dist/server.js", + "scripts": { + "build": "tsc -p tsconfig.json", + "lint": "oxlint src", + "test": "DATABASE_URL=postgresql://test:test@localhost/test KEYCLOAK_ISSUER=https://auth.example.invalid/realms/test vitest run" + }, + "dependencies": { + "@fieldlens/contracts": "1.0.0", + "cors": "^2.8.5", + "express": "^5.1.0", + "helmet": "^8.1.0", + "jose": "^6.1.0", + "pg": "^8.16.3" + }, + "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^5.0.3", + "@types/node": "^24.13.3", + "@types/pg": "^8.15.5", + "oxlint": "^1.75.0", + "typescript": "~6.0.2", + "vitest": "^4.1.11" + } +} diff --git a/apps/api/src/auth.test.ts b/apps/api/src/auth.test.ts new file mode 100644 index 0000000..9fc1859 --- /dev/null +++ b/apps/api/src/auth.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' +import { rolesFromPayload } from './auth.js' + +describe('role extraction', () => { + it('collects FieldLens resource and realm roles without treating authentication as authorization', () => { + const roles = rolesFromPayload({ + resource_access: { + 'fieldlens-api': { roles: ['fieldlens-operator'] }, + }, + realm_access: { roles: ['offline_access'] }, + }) + expect(roles).toContain('fieldlens-operator') + expect(roles).toContain('offline_access') + }) +}) diff --git a/apps/api/src/auth.ts b/apps/api/src/auth.ts new file mode 100644 index 0000000..32e7b0f --- /dev/null +++ b/apps/api/src/auth.ts @@ -0,0 +1,90 @@ +import type { NextFunction, Request, Response } from 'express' +import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose' +import { config } from './config.js' + +export type AuthIdentity = { + subject: string + username?: string + roles: string[] + clientId?: string +} + +declare global { + namespace Express { + interface Request { + identity?: AuthIdentity + } + } +} + +const jwks = createRemoteJWKSet(new URL( + config.jwksUri || `${config.issuer}/protocol/openid-connect/certs`, +)) + +function strings(value: unknown) { + return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [] +} + +export function rolesFromPayload(payload: JWTPayload) { + const resourceAccess = payload.resource_access as Record | undefined + const realmAccess = payload.realm_access as { roles?: unknown } | undefined + return [...new Set([ + ...strings(resourceAccess?.[config.audience]?.roles), + ...strings(resourceAccess?.[config.browserClientId]?.roles), + ...strings(resourceAccess?.[config.machineClientId]?.roles), + ...strings(realmAccess?.roles), + ])] +} + +function intendedForFieldLens(payload: JWTPayload) { + const audience = Array.isArray(payload.aud) ? payload.aud : payload.aud ? [payload.aud] : [] + const azp = typeof payload.azp === 'string' ? payload.azp : '' + return audience.includes(config.audience) + || azp === config.browserClientId + || azp === config.machineClientId +} + +export async function authenticate(req: Request, res: Response, next: NextFunction) { + const header = req.header('authorization') + if (!header?.startsWith('Bearer ')) { + res.status(401).json({ error: 'authentication_required' }) + return + } + + try { + const token = header.slice('Bearer '.length) + const { payload } = await jwtVerify(token, jwks, { issuer: config.issuer }) + if (!intendedForFieldLens(payload) || !payload.sub) { + res.status(401).json({ error: 'invalid_token_target' }) + return + } + req.identity = { + subject: payload.sub, + username: typeof payload.preferred_username === 'string' ? payload.preferred_username : undefined, + roles: rolesFromPayload(payload), + clientId: typeof payload.azp === 'string' ? payload.azp : undefined, + } + next() + } catch { + res.status(401).json({ error: 'invalid_token' }) + } +} + +const access: Record = { + operator: ['fieldlens-operator', 'fieldlens-supervisor', 'fieldlens-admin', 'operator', 'admin', 'owner'], + supervisor: ['fieldlens-supervisor', 'fieldlens-admin', 'admin', 'owner'], + admin: ['fieldlens-admin', 'admin', 'owner'], + integration: ['fieldlens-integration', 'fieldlens-admin', 'admin', 'owner'], +} + +export function requireAccess(level: keyof typeof access) { + return (req: Request, res: Response, next: NextFunction) => { + const roles = req.identity?.roles ?? [] + const exactMachineIdentity = level === 'integration' && req.identity?.clientId === config.machineClientId + if (!exactMachineIdentity && !access[level].some((role) => roles.includes(role))) { + res.status(403).json({ error: 'forbidden' }) + return + } + next() + } +} diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts new file mode 100644 index 0000000..ee2fe6e --- /dev/null +++ b/apps/api/src/config.ts @@ -0,0 +1,20 @@ +function required(name: string) { + const value = process.env[name]?.trim() + if (!value) throw new Error(`Missing required environment variable ${name}`) + return value +} + +export const config = { + port: Number(process.env.PORT ?? '3000'), + databaseUrl: required('DATABASE_URL'), + issuer: required('KEYCLOAK_ISSUER').replace(/\/$/, ''), + jwksUri: (process.env.KEYCLOAK_JWKS_URI ?? '').trim(), + audience: process.env.KEYCLOAK_AUDIENCE?.trim() || 'fieldlens-api', + browserClientId: process.env.KEYCLOAK_BROWSER_CLIENT_ID?.trim() || 'fieldlens', + machineClientId: process.env.KEYCLOAK_MACHINE_CLIENT_ID?.trim() || 'fieldlens-integration', + tenantId: process.env.FIELDLENS_TENANT_ID?.trim() || 'fieldlens-production', + environment: process.env.FIELDLENS_ENV?.trim() || 'development', + logLevel: process.env.LOG_LEVEL?.trim() || 'info', + webOrigin: process.env.WEB_ORIGIN?.trim() || 'http://localhost:5173', + staticDir: process.env.STATIC_DIR?.trim() || '', +} diff --git a/apps/api/src/context.ts b/apps/api/src/context.ts new file mode 100644 index 0000000..b793e89 --- /dev/null +++ b/apps/api/src/context.ts @@ -0,0 +1,31 @@ +import type { PoolClient } from 'pg' +import { config } from './config.js' + +export type DeviceContext = { + deviceId: string + stationId: string + siteId: string + tenantId: string +} + +export async function loadDeviceContext(client: PoolClient, deviceId: string): Promise { + const result = await client.query<{ + device_id: string + station_id: string + site_id: string + tenant_id: string + }>(` + SELECT d.id AS device_id, s.id AS station_id, si.id AS site_id, si.tenant_id + FROM devices d + JOIN stations s ON s.id = d.station_id + JOIN sites si ON si.id = s.site_id + WHERE d.id = $1 AND si.tenant_id = $2 AND s.active = true + `, [deviceId, config.tenantId]) + const row = result.rows[0] + return row ? { + deviceId: row.device_id, + stationId: row.station_id, + siteId: row.site_id, + tenantId: row.tenant_id, + } : null +} diff --git a/apps/api/src/db.ts b/apps/api/src/db.ts new file mode 100644 index 0000000..a3db407 --- /dev/null +++ b/apps/api/src/db.ts @@ -0,0 +1,20 @@ +import pg from 'pg' +import { config } from './config.js' + +const { Pool } = pg + +export const pool = new Pool({ + connectionString: config.databaseUrl, + max: 10, + application_name: 'fieldlens-api', +}) + +export async function databaseReady() { + const client = await pool.connect() + try { + await client.query('SELECT 1') + return true + } finally { + client.release() + } +} diff --git a/apps/api/src/log.ts b/apps/api/src/log.ts new file mode 100644 index 0000000..e3130a2 --- /dev/null +++ b/apps/api/src/log.ts @@ -0,0 +1,11 @@ +export function log(level: 'info' | 'warn' | 'error', event: string, fields: Record = {}) { + const safe = Object.fromEntries(Object.entries(fields).filter(([key]) => + !/authorization|token|secret|password|database_url/i.test(key), + )) + process.stdout.write(`${JSON.stringify({ + time: new Date().toISOString(), + level, + event, + ...safe, + })}\n`) +} diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts new file mode 100644 index 0000000..0ea684d --- /dev/null +++ b/apps/api/src/server.ts @@ -0,0 +1,283 @@ +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import cors from 'cors' +import express from 'express' +import helmet from 'helmet' +import { FIELDLENS_SCHEMA_VERSION, isSyncRequest, type DeviceRegistration } from '@fieldlens/contracts' +import { authenticate, requireAccess } from './auth.js' +import { config } from './config.js' +import { loadDeviceContext } from './context.js' +import { databaseReady, pool } from './db.js' +import { log } from './log.js' +import { processSyncEvent } from './sync.js' + +const app = express() +app.disable('x-powered-by') + +const keycloakOrigin = new URL(config.issuer).origin +app.use(helmet({ + crossOriginEmbedderPolicy: false, + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'"], + styleSrc: ["'self'", "'unsafe-inline'"], + imgSrc: ["'self'", 'data:', 'blob:'], + mediaSrc: ["'self'", 'blob:'], + connectSrc: ["'self'", keycloakOrigin], + workerSrc: ["'self'", 'blob:'], + objectSrc: ["'none'"], + baseUri: ["'self'"], + frameAncestors: ["'none'"], + }, + }, +})) +app.use((_req, res, next) => { + res.setHeader('Permissions-Policy', 'camera=(self), microphone=(), geolocation=()') + res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin') + next() +}) +app.use(cors({ + origin(origin, callback) { + if (!origin || origin === config.webOrigin) callback(null, true) + else callback(new Error('cors_origin_rejected')) + }, + credentials: false, +})) +app.use(express.json({ limit: '1mb' })) +app.use((req, res, next) => { + const started = performance.now() + res.on('finish', () => log('info', 'http_request', { + method: req.method, + path: req.path, + status: res.statusCode, + durationMs: Math.round(performance.now() - started), + })) + next() +}) + +app.get('/api/health/live', (_req, res) => { + res.json({ status: 'live', version: process.env.FIELDLENS_VERSION ?? 'dev' }) +}) + +app.get('/api/health/ready', async (_req, res) => { + try { + await databaseReady() + res.json({ status: 'ready', database: 'ready', version: process.env.FIELDLENS_VERSION ?? 'dev' }) + } catch { + log('error', 'database_readiness_failed') + res.status(503).json({ status: 'not-ready', database: 'unavailable' }) + } +}) + +app.use('/api/v1', authenticate) + +app.get('/api/v1/me', requireAccess('operator'), (req, res) => { + res.json({ + subject: req.identity!.subject, + username: req.identity!.username, + roles: req.identity!.roles.filter((role) => role.startsWith('fieldlens-')), + clientId: req.identity!.clientId, + }) +}) + +app.get('/api/v1/stations', requireAccess('operator'), async (_req, res) => { + const result = await pool.query(` + SELECT s.id, s.site_id AS "siteId", s.name, s.station_type AS "stationType", s.active + FROM stations s + JOIN sites si ON si.id = s.site_id + WHERE si.tenant_id = $1 AND s.active = true + ORDER BY s.name + `, [config.tenantId]) + res.json({ stations: result.rows }) +}) + +app.post('/api/v1/devices/register', requireAccess('operator'), async (req, res) => { + const body = req.body as Partial + if (typeof body.installationId !== 'string' || body.schemaVersion !== FIELDLENS_SCHEMA_VERSION) { + res.status(400).json({ error: 'invalid_device_registration' }) + return + } + + const client = await pool.connect() + try { + const requestedStation = typeof body.stationId === 'string' ? body.stationId : null + const station = await client.query<{ + id: string + siteId: string + name: string + stationType: string + active: boolean + }>(` + SELECT s.id, s.site_id AS "siteId", s.name, s.station_type AS "stationType", s.active + FROM stations s + JOIN sites si ON si.id = s.site_id + WHERE si.tenant_id = $1 AND s.active = true + AND ($2::uuid IS NULL OR s.id = $2::uuid) + ORDER BY CASE WHEN $2::uuid IS NOT NULL AND s.id = $2::uuid THEN 0 ELSE 1 END, s.created_at + LIMIT 1 + `, [config.tenantId, requestedStation]) + + if (!station.rows[0]) { + res.status(409).json({ error: 'no_active_station' }) + return + } + + const deviceId = crypto.randomUUID() + const result = await client.query<{ id: string }>(` + INSERT INTO devices (id, station_id, installation_id, label, last_seen_at, app_version) + VALUES ($1,$2,$3,$4,now(),$5) + ON CONFLICT (installation_id) DO UPDATE SET + station_id = EXCLUDED.station_id, + label = COALESCE(EXCLUDED.label, devices.label), + last_seen_at = now(), + app_version = EXCLUDED.app_version + RETURNING id + `, [deviceId, station.rows[0].id, body.installationId, body.label ?? null, body.appVersion ?? null]) + + log('info', 'device_registered', { deviceId: result.rows[0]!.id, stationId: station.rows[0].id }) + res.json({ id: result.rows[0]!.id, station: station.rows[0] }) + } finally { + client.release() + } +}) + +app.post('/api/v1/sync', requireAccess('operator'), async (req, res) => { + if (!isSyncRequest(req.body)) { + res.status(400).json({ error: 'invalid_sync_request' }) + return + } + + const client = await pool.connect() + try { + const context = await loadDeviceContext(client, req.body.deviceId) + if (!context) { + res.status(403).json({ error: 'device_not_registered_for_tenant' }) + return + } + + const acknowledgements = [] + for (const event of req.body.events) { + acknowledgements.push(await processSyncEvent(client, event, context, req.identity!)) + } + await client.query('UPDATE devices SET last_seen_at = now() WHERE id = $1', [context.deviceId]) + const accepted = acknowledgements.filter((ack) => ack.status === 'accepted').length + const duplicate = acknowledgements.filter((ack) => ack.status === 'duplicate').length + const rejected = acknowledgements.filter((ack) => ack.status === 'rejected').length + log('info', 'sync_batch', { deviceId: context.deviceId, size: acknowledgements.length, accepted, duplicate, rejected }) + res.json({ schemaVersion: FIELDLENS_SCHEMA_VERSION, acknowledgements }) + } finally { + client.release() + } +}) + +app.get('/api/v1/scans', requireAccess('supervisor'), async (req, res) => { + const limit = Math.max(1, Math.min(500, Number(req.query.limit ?? 100))) + const result = await pool.query(` + SELECT id, station_id AS "stationId", device_id AS "deviceId", raw_value AS "rawValue", + format, duplicate, captured_at AS "capturedAt", received_at AS "receivedAt" + FROM scans WHERE tenant_id = $1 + ORDER BY captured_at DESC LIMIT $2 + `, [config.tenantId, limit]) + res.json({ scans: result.rows }) +}) + +app.get('/api/v1/pallet-sessions/:id', requireAccess('supervisor'), async (req, res) => { + const session = await pool.query(` + SELECT ps.* FROM pallet_sessions ps + WHERE ps.id = $1 AND ps.tenant_id = $2 + `, [req.params.id, config.tenantId]) + if (!session.rows[0]) { + res.status(404).json({ error: 'not_found' }) + return + } + const observations = await pool.query( + 'SELECT * FROM pallet_observations WHERE pallet_session_id = $1 ORDER BY carton_index', + [req.params.id], + ) + const exceptions = await pool.query( + 'SELECT * FROM pallet_exceptions WHERE pallet_session_id = $1 ORDER BY created_at', + [req.params.id], + ) + res.json({ session: session.rows[0], observations: observations.rows, exceptions: exceptions.rows }) +}) + +app.get('/api/v1/reference/labels/:ean', requireAccess('operator'), async (req, res) => { + const started = performance.now() + try { + const result = await pool.query<{ fields_json: Record }>(` + SELECT fields_json FROM reference_label_specs + WHERE tenant_id = $1 AND ean = $2 AND active = true + ORDER BY updated_at DESC + `, [config.tenantId, req.params.ean]) + res.json({ rows: result.rows.map((row) => row.fields_json) }) + log('info', 'reference_lookup', { + ean: req.params.ean, + rows: result.rowCount, + durationMs: Math.round(performance.now() - started), + }) + } catch { + log('error', 'reference_lookup_failed', { + ean: req.params.ean, + durationMs: Math.round(performance.now() - started), + }) + res.status(503).json({ error: 'reference_provider_unavailable' }) + } +}) + +app.get('/api/v1/ops/summary', requireAccess('supervisor'), async (_req, res) => { + const [stations, devices, scans, sessions, exceptions] = await Promise.all([ + pool.query('SELECT count(*)::int AS count FROM stations s JOIN sites si ON si.id=s.site_id WHERE si.tenant_id=$1 AND s.active=true', [config.tenantId]), + pool.query('SELECT count(*)::int AS count, max(last_seen_at) AS last_seen FROM devices d JOIN stations s ON s.id=d.station_id JOIN sites si ON si.id=s.site_id WHERE si.tenant_id=$1', [config.tenantId]), + pool.query("SELECT count(*)::int AS count FROM scans WHERE tenant_id=$1 AND captured_at > now() - interval '24 hours'", [config.tenantId]), + pool.query("SELECT count(*)::int AS count FROM pallet_sessions WHERE tenant_id=$1 AND started_at > now() - interval '24 hours'", [config.tenantId]), + pool.query('SELECT count(*)::int AS count FROM pallet_exceptions pe JOIN pallet_sessions ps ON ps.id=pe.pallet_session_id WHERE ps.tenant_id=$1', [config.tenantId]), + ]) + res.json({ + stations: stations.rows[0], + devices: devices.rows[0], + scans24h: scans.rows[0], + palletSessions24h: sessions.rows[0], + conformityExceptions: exceptions.rows[0], + }) +}) + +if (config.staticDir) { + const staticDir = path.resolve(config.staticDir) + app.use(express.static(staticDir, { + etag: true, + maxAge: '1h', + setHeaders(res, filename) { + if (filename.endsWith('sw.js') || filename.includes('workbox-')) { + res.setHeader('Cache-Control', 'no-cache') + } + }, + })) + app.use((req, res, next) => { + if (req.method !== 'GET' || req.path.startsWith('/api/')) return next() + res.setHeader('Cache-Control', 'no-cache') + res.sendFile(path.join(staticDir, 'index.html')) + }) +} + +const server = app.listen(config.port, '0.0.0.0', () => { + log('info', 'server_started', { + port: config.port, + environment: config.environment, + version: process.env.FIELDLENS_VERSION ?? 'dev', + }) +}) + +async function shutdown(signal: string) { + log('info', 'shutdown_started', { signal }) + server.close(async () => { + await pool.end() + process.exit(0) + }) + setTimeout(() => process.exit(1), 10_000).unref() +} + +process.on('SIGTERM', () => void shutdown('SIGTERM')) +process.on('SIGINT', () => void shutdown('SIGINT')) + +export { app } diff --git a/apps/api/src/sync.ts b/apps/api/src/sync.ts new file mode 100644 index 0000000..f140e06 --- /dev/null +++ b/apps/api/src/sync.ts @@ -0,0 +1,174 @@ +import { randomUUID } from 'node:crypto' +import type { PoolClient } from 'pg' +import type { OutboxEvent, SyncAcknowledgement } from '@fieldlens/contracts' +import type { AuthIdentity } from './auth.js' +import type { DeviceContext } from './context.js' + +async function insertScan(client: PoolClient, event: OutboxEvent, context: DeviceContext, identity: AuthIdentity) { + const payload = event.payload as Record + await client.query(` + INSERT INTO scans ( + id, tenant_id, site_id, station_id, device_id, operator_subject, + raw_value, format, duplicate, captured_at, schema_version + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) + ON CONFLICT (id) DO NOTHING + `, [ + payload.id, context.tenantId, context.siteId, context.stationId, context.deviceId, + identity.subject, payload.rawValue, payload.format, Boolean(payload.duplicate), + payload.capturedAt, event.schemaVersion, + ]) +} + +async function startPallet(client: PoolClient, event: OutboxEvent, context: DeviceContext, identity: AuthIdentity) { + const payload = event.payload as Record + await client.query(` + INSERT INTO pallet_sessions ( + id, tenant_id, site_id, station_id, device_id, operator_subject, + workflow_type, target_count, started_at, status, schema_version + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'active',$10) + ON CONFLICT (id) DO NOTHING + `, [ + payload.id, context.tenantId, context.siteId, context.stationId, context.deviceId, + identity.subject, payload.workflowType, payload.targetCount, payload.startedAt, event.schemaVersion, + ]) +} + +async function insertObservation(client: PoolClient, event: OutboxEvent) { + const payload = event.payload as Record + await client.query(` + INSERT INTO pallet_observations ( + id, pallet_session_id, carton_index, ean, fields_json, raw_text, + confidence, captured_at, schema_version + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) + ON CONFLICT (id) DO NOTHING + `, [ + payload.id, + payload.palletSessionId, + payload.cartonIndex, + payload.ean ?? null, + payload.fieldsJson ? JSON.stringify(payload.fieldsJson) : null, + payload.rawText ?? null, + payload.confidence ?? null, + payload.capturedAt, + event.schemaVersion, + ]) +} + +async function writeExceptions(client: PoolClient, event: OutboxEvent) { + const payload = event.payload as Record + const sessionId = String(payload.id) + const workflow = String(payload.workflowType ?? '') + + if (workflow === 'ean' && typeof payload.consensusEan === 'string') { + const observations = await client.query<{ id: string; ean: string | null }>( + 'SELECT id, ean FROM pallet_observations WHERE pallet_session_id = $1', + [sessionId], + ) + for (const observation of observations.rows) { + if (observation.ean && observation.ean !== payload.consensusEan) { + await client.query(` + INSERT INTO pallet_exceptions ( + id, pallet_session_id, observation_id, field, expected_value, actual_value, exception_type + ) VALUES ($1,$2,$3,'ean',$4,$5,'mismatch') + `, [randomUUID(), sessionId, observation.id, payload.consensusEan, observation.ean]) + } + } + } + + if (workflow === 'carton-label' && Array.isArray(payload.majorityFields)) { + const observations = await client.query<{ id: string; fields_json: Record | null }>( + 'SELECT id, fields_json FROM pallet_observations WHERE pallet_session_id = $1', + [sessionId], + ) + const majorities = payload.majorityFields as Array> + for (const observation of observations.rows) { + for (const majority of majorities) { + const field = typeof majority.field === 'string' ? majority.field : '' + const expected = typeof majority.value === 'string' ? majority.value : '' + if (!field || !expected) continue + const actualValue = observation.fields_json?.[field] + const actual = typeof actualValue === 'string' ? actualValue : '' + if (actual.trim().toLocaleLowerCase() !== expected.trim().toLocaleLowerCase()) { + await client.query(` + INSERT INTO pallet_exceptions ( + id, pallet_session_id, observation_id, field, expected_value, actual_value, exception_type + ) VALUES ($1,$2,$3,$4,$5,$6,$7) + `, [randomUUID(), sessionId, observation.id, field, expected, actual || null, actual ? 'mismatch' : 'unread']) + } + } + } + } +} + +async function completePallet(client: PoolClient, event: OutboxEvent) { + const payload = event.payload as Record + const result = await client.query(` + UPDATE pallet_sessions SET + completed_at = $2, + status = 'complete', + conforms = $3, + result_status = $4 + WHERE id = $1 + `, [ + payload.id, + payload.completedAt, + typeof payload.conforms === 'boolean' ? payload.conforms : null, + payload.resultStatus ?? null, + ]) + if (result.rowCount !== 1) throw new Error('pallet_session_not_found') + await writeExceptions(client, event) +} + +async function applyDomainEvent(client: PoolClient, event: OutboxEvent, context: DeviceContext, identity: AuthIdentity) { + switch (event.eventType) { + case 'scan.captured': + await insertScan(client, event, context, identity) + return + case 'pallet.session.started': + await startPallet(client, event, context, identity) + return + case 'pallet.observation.captured': + await insertObservation(client, event) + return + case 'pallet.session.completed': + await completePallet(client, event) + return + default: + throw new Error('unsupported_event_type') + } +} + +export async function processSyncEvent( + client: PoolClient, + event: OutboxEvent, + context: DeviceContext, + identity: AuthIdentity, +): Promise { + await client.query('BEGIN') + try { + const inserted = await client.query( + `INSERT INTO sync_events (id, aggregate_id, event_type, schema_version, payload, status) + VALUES ($1,$2,$3,$4,$5,'processing') + ON CONFLICT (id) DO NOTHING + RETURNING id`, + [event.id, event.aggregateId, event.eventType, event.schemaVersion, JSON.stringify(event.payload)], + ) + + if (inserted.rowCount === 0) { + await client.query('COMMIT') + return { eventId: event.id, status: 'duplicate' } + } + + await applyDomainEvent(client, event, context, identity) + await client.query("UPDATE sync_events SET status = 'accepted' WHERE id = $1", [event.id]) + await client.query('COMMIT') + return { eventId: event.id, status: 'accepted' } + } catch (error) { + await client.query('ROLLBACK') + return { + eventId: event.id, + status: 'rejected', + error: error instanceof Error ? error.message : 'sync_event_failed', + } + } +} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..237c19a --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src", + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 0000000..a312609 --- /dev/null +++ b/apps/web/.env.example @@ -0,0 +1,4 @@ +VITE_API_BASE_URL= +VITE_KEYCLOAK_ISSUER=https://auth.example.invalid/realms/example +VITE_KEYCLOAK_CLIENT_ID=fieldlens +VITE_APP_VERSION=dev diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..7c61b5b --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,15 @@ + + + + + + + + + FieldLens + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..78a7b08 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,29 @@ +{ + "name": "@fieldlens/web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc --noEmit -p tsconfig.json && vite build", + "lint": "oxlint src", + "test": "vitest run" + }, + "dependencies": { + "@fieldlens/contracts": "1.0.0", + "@fieldlens/core": "0.1.0", + "dexie-react-hooks": "^4.4.0", + "keycloak-js": "^26.2.0", + "react": "^19.2.8", + "react-dom": "^19.2.8" + }, + "devDependencies": { + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.4", + "oxlint": "^1.75.0", + "typescript": "~6.0.2", + "vite": "^8.2.0", + "vite-plugin-pwa": "^1.3.0", + "vitest": "^4.1.11" + } +} diff --git a/apps/web/src/HostStatus.tsx b/apps/web/src/HostStatus.tsx new file mode 100644 index 0000000..90482d0 --- /dev/null +++ b/apps/web/src/HostStatus.tsx @@ -0,0 +1,34 @@ +import { useLiveQuery } from 'dexie-react-hooks' +import { useEffect, useState } from 'react' + +import { database, getLocalMetadata, type OutboxRecord } from '../../../packages/fieldlens-core/src/lib/database' +import { initializeAuth, keycloak, login, logout } from './auth' +import type { RegisteredDevice } from './sync' + +export function HostStatus() { + const pending = useLiveQuery(() => database.outbox.filter((event: OutboxRecord) => !event.syncedAt).count(), []) ?? 0 + const errors = useLiveQuery(() => database.outbox.filter((event: OutboxRecord) => !event.syncedAt && Boolean(event.lastError)).count(), []) ?? 0 + const [authenticated, setAuthenticated] = useState(Boolean(keycloak?.authenticated)) + const [station, setStation] = useState('Local / unassigned') + + useEffect(() => { + void initializeAuth().then((ok) => setAuthenticated(ok)) + const refreshStation = () => { + void getLocalMetadata('registeredDevice').then((device) => { + if (device?.station?.name) setStation(device.station.name) + }) + } + refreshStation() + const timer = window.setInterval(refreshStation, 5000) + return () => window.clearInterval(timer) + }, []) + + return ( + + ) +} diff --git a/apps/web/src/auth.ts b/apps/web/src/auth.ts new file mode 100644 index 0000000..60e8714 --- /dev/null +++ b/apps/web/src/auth.ts @@ -0,0 +1,54 @@ +import Keycloak from 'keycloak-js' + +function parseIssuer(issuer: string) { + const marker = '/realms/' + const index = issuer.lastIndexOf(marker) + if (index < 0) throw new Error('VITE_KEYCLOAK_ISSUER must contain /realms/') + return { + url: issuer.slice(0, index), + realm: issuer.slice(index + marker.length).replace(/\/$/, ''), + } +} + +const issuer = import.meta.env.VITE_KEYCLOAK_ISSUER ?? '' +const clientId = import.meta.env.VITE_KEYCLOAK_CLIENT_ID ?? 'fieldlens' +const parsed = issuer ? parseIssuer(issuer) : null + +export const keycloak = parsed + ? new Keycloak({ url: parsed.url, realm: parsed.realm, clientId }) + : null + +let initialization: Promise | null = null + +export function initializeAuth() { + if (!keycloak) return Promise.resolve(false) + initialization ??= keycloak.init({ + onLoad: 'check-sso', + pkceMethod: 'S256', + checkLoginIframe: false, + }).catch(() => false) + return initialization +} + +export async function getAccessToken() { + if (!keycloak) return null + await initializeAuth() + if (!keycloak.authenticated) return null + try { + await keycloak.updateToken(30) + return keycloak.token ?? null + } catch { + return null + } +} + +export async function login() { + if (!keycloak) return + await initializeAuth() + await keycloak.login({ redirectUri: window.location.href }) +} + +export async function logout() { + if (!keycloak) return + await keycloak.logout({ redirectUri: window.location.origin }) +} diff --git a/apps/web/src/host.css b/apps/web/src/host.css new file mode 100644 index 0000000..eb7b478 --- /dev/null +++ b/apps/web/src/host.css @@ -0,0 +1 @@ +.host-status{position:fixed;z-index:80;top:max(10px,env(safe-area-inset-top));right:max(10px,env(safe-area-inset-right));display:flex;align-items:center;gap:10px;padding:7px 8px 7px 11px;border:1px solid #314039;border-radius:10px;background:#17211fe8;color:#fff;box-shadow:0 5px 20px #0003;backdrop-filter:blur(10px);font:11px/1.2 system-ui,sans-serif}.host-status span{display:grid;gap:2px;min-width:0}.host-status strong{max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.host-status small{color:#aebbb6}.host-status button{border:0;border-radius:7px;padding:7px 9px;background:#f4d35e;color:#17211f;font-weight:800}@media(max-width:760px){.host-status{top:max(7px,env(safe-area-inset-top));right:max(7px,env(safe-area-inset-right));max-width:75vw}.host-status strong{max-width:100px}.host-status button{padding:6px 8px;font-size:9px}} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx new file mode 100644 index 0000000..f98a438 --- /dev/null +++ b/apps/web/src/main.tsx @@ -0,0 +1,26 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' + +import '../../../packages/fieldlens-core/src/index.css' +import '../../../packages/fieldlens-core/src/ui-polish.css' +import '../../../packages/fieldlens-core/src/ios-safe-area.css' +import '../../../packages/fieldlens-core/src/phone-landscape.css' +import '../../../packages/fieldlens-core/src/detection-rotation.css' +import './host.css' + +import CoreApp from '../../../packages/fieldlens-core/src/App' +import { startDeviceHoldTracking } from '../../../packages/fieldlens-core/src/lib/deviceOrientation' +import { HostStatus } from './HostStatus' +import { registerArrowstackReferenceProvider } from './referenceProvider' +import { installSyncTriggers } from './sync' + +startDeviceHoldTracking() +registerArrowstackReferenceProvider() +installSyncTriggers() + +createRoot(document.getElementById('root')!).render( + + + + , +) diff --git a/apps/web/src/referenceProvider.ts b/apps/web/src/referenceProvider.ts new file mode 100644 index 0000000..5fc6f16 --- /dev/null +++ b/apps/web/src/referenceProvider.ts @@ -0,0 +1,15 @@ +import { registerLabelExpectationProvider } from '../../../packages/fieldlens-core/src/lib/labelExpectationProviders' +import { authenticatedFetch } from './sync' + +export function registerArrowstackReferenceProvider() { + return registerLabelExpectationProvider({ + id: 'arrowstack-api', + label: 'Arrowstack reference data', + isAvailable: () => true, + async lookup(ean) { + const response = await authenticatedFetch(`/api/v1/reference/labels/${encodeURIComponent(ean)}`) + const data = await response.json() as { rows?: unknown[] } + return Array.isArray(data.rows) ? data.rows as never[] : [] + }, + }) +} diff --git a/apps/web/src/sync.test.ts b/apps/web/src/sync.test.ts new file mode 100644 index 0000000..9fb5216 --- /dev/null +++ b/apps/web/src/sync.test.ts @@ -0,0 +1,7 @@ +import { describe, expect, it } from 'vitest' + +describe('sync contract', () => { + it('keeps capture independent from browser connectivity state', () => { + expect(typeof navigator === 'undefined' || typeof navigator.onLine === 'boolean').toBe(true) + }) +}) diff --git a/apps/web/src/sync.ts b/apps/web/src/sync.ts new file mode 100644 index 0000000..5b1fc4c --- /dev/null +++ b/apps/web/src/sync.ts @@ -0,0 +1,117 @@ +import { + FIELDLENS_SCHEMA_VERSION, + type DeviceRegistration, + type Station, + type SyncResponse, +} from '@fieldlens/contracts' +import { + acknowledgeOutboxEvent, + failOutboxEvent, + getInstallationId, + getLocalMetadata, + getPendingOutbox, + setLocalMetadata, +} from '../../../packages/fieldlens-core/src/lib/database' +import { getAccessToken } from './auth' + +const apiBase = (import.meta.env.VITE_API_BASE_URL ?? '').replace(/\/$/, '') + +export type RegisteredDevice = { + id: string + station: Station +} + +async function authenticatedFetch(path: string, init: RequestInit = {}) { + const token = await getAccessToken() + if (!token) throw new Error('Authentication unavailable') + const headers = new Headers(init.headers) + headers.set('Authorization', `Bearer ${token}`) + if (init.body) headers.set('Content-Type', 'application/json') + const response = await fetch(`${apiBase}${path}`, { ...init, headers }) + if (!response.ok) throw new Error(`API ${response.status}`) + return response +} + +export async function ensureDeviceRegistered(): Promise { + const existing = await getLocalMetadata('registeredDevice') + const token = await getAccessToken() + if (!token) return existing ?? null + + const installationId = await getInstallationId() + const registration: DeviceRegistration = { + installationId, + appVersion: import.meta.env.VITE_APP_VERSION ?? 'dev', + schemaVersion: FIELDLENS_SCHEMA_VERSION, + } + try { + const response = await authenticatedFetch('/api/v1/devices/register', { + method: 'POST', + body: JSON.stringify(registration), + }) + const next = await response.json() as RegisteredDevice + await setLocalMetadata('registeredDevice', next) + return next + } catch { + return existing ?? null + } +} + +let syncInFlight: Promise | null = null + +export function syncNow() { + if (syncInFlight) return syncInFlight + syncInFlight = runSync().finally(() => { syncInFlight = null }) + return syncInFlight +} + +async function runSync() { + const device = await ensureDeviceRegistered() + if (!device) return + + const events = await getPendingOutbox(50) + if (events.length === 0) return + + try { + const response = await authenticatedFetch('/api/v1/sync', { + method: 'POST', + body: JSON.stringify({ + deviceId: device.id, + schemaVersion: FIELDLENS_SCHEMA_VERSION, + events: events.map(({ attempts: _attempts, nextAttemptAt: _next, syncedAt: _synced, lastError: _error, ...event }) => event), + }), + }) + const body = await response.json() as SyncResponse + const acknowledgements = new Map(body.acknowledgements.map((ack) => [ack.eventId, ack])) + + for (const event of events) { + const acknowledgement = acknowledgements.get(event.id) + if (acknowledgement?.status === 'accepted' || acknowledgement?.status === 'duplicate') { + await acknowledgeOutboxEvent(event.id) + } else { + await failOutboxEvent(event.id, acknowledgement?.error ?? 'Event was not acknowledged') + } + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Sync request failed' + await Promise.all(events.map((event) => failOutboxEvent(event.id, message))) + } +} + +export function installSyncTriggers() { + const attempt = () => { void syncNow() } + const onVisible = () => { + if (document.visibilityState === 'visible') attempt() + } + window.addEventListener('online', attempt) + document.addEventListener('visibilitychange', onVisible) + const interval = window.setInterval(attempt, 30_000) + attempt() + + return () => { + window.removeEventListener('online', attempt) + document.removeEventListener('visibilitychange', onVisible) + window.clearInterval(interval) + } +} + +export { authenticatedFetch } diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 0000000..02aca25 --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "noEmit": true, + "types": ["vite/client"] + }, + "include": ["src", "../../packages/fieldlens-core/src"] +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts new file mode 100644 index 0000000..30b545c --- /dev/null +++ b/apps/web/vite.config.ts @@ -0,0 +1,34 @@ +import { fileURLToPath, URL } from 'node:url' +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vite' +import { VitePWA } from 'vite-plugin-pwa' + +export default defineConfig({ + publicDir: fileURLToPath(new URL('../../packages/fieldlens-core/public', import.meta.url)), + plugins: [ + react(), + VitePWA({ + registerType: 'prompt', + includeAssets: ['fieldlens-mark.svg', 'apple-touch-icon.png', 'pwa-192.png', 'pwa-512.png'], + manifest: { + name: 'FieldLens Capture', + short_name: 'FieldLens', + description: 'Local-first barcode, pallet and carton-label capture.', + theme_color: '#17211f', + background_color: '#f7f7f2', + display: 'standalone', + start_url: '/', + icons: [ + { src: '/pwa-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' }, + { src: '/pwa-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' }, + { src: '/pwa-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' } + ] + }, + workbox: { + cleanupOutdatedCaches: true, + navigateFallback: '/index.html', + globPatterns: ['**/*.{js,css,html,svg,wasm,png}'] + } + }) + ] +}) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..00a02a6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,42 @@ +services: + postgres: + image: postgres:17-alpine + environment: + POSTGRES_DB: fieldlens + POSTGRES_USER: fieldlens + POSTGRES_PASSWORD: fieldlens-local-only + healthcheck: + test: ["CMD-SHELL", "pg_isready -U fieldlens"] + interval: 5s + timeout: 3s + retries: 10 + volumes: + - fieldlens-pg:/var/lib/postgresql/data + + fieldlens: + build: + context: . + args: + VITE_API_BASE_URL: "" + VITE_KEYCLOAK_ISSUER: ${VITE_KEYCLOAK_ISSUER:-https://auth.example.invalid/realms/example} + VITE_KEYCLOAK_CLIENT_ID: fieldlens + FIELDLENS_VERSION: local + ports: + - "3000:3000" + environment: + PORT: "3000" + DATABASE_URL: postgresql://fieldlens:fieldlens-local-only@postgres:5432/fieldlens + KEYCLOAK_ISSUER: ${KEYCLOAK_ISSUER:-https://auth.example.invalid/realms/example} + KEYCLOAK_AUDIENCE: fieldlens-api + KEYCLOAK_BROWSER_CLIENT_ID: fieldlens + KEYCLOAK_MACHINE_CLIENT_ID: fieldlens-integration + FIELDLENS_TENANT_ID: fieldlens-production + FIELDLENS_ENV: local + WEB_ORIGIN: http://localhost:3000 + STATIC_DIR: /app/web + depends_on: + postgres: + condition: service_healthy + +volumes: + fieldlens-pg: diff --git a/migrations/001_initial.sql b/migrations/001_initial.sql new file mode 100644 index 0000000..9a29fb0 --- /dev/null +++ b/migrations/001_initial.sql @@ -0,0 +1,122 @@ +CREATE TABLE IF NOT EXISTS tenants ( + id text PRIMARY KEY, + name text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS sites ( + id uuid PRIMARY KEY, + tenant_id text NOT NULL REFERENCES tenants(id), + name text NOT NULL, + timezone text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS stations ( + id uuid PRIMARY KEY, + site_id uuid NOT NULL REFERENCES sites(id), + name text NOT NULL, + station_type text NOT NULL, + active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS devices ( + id uuid PRIMARY KEY, + station_id uuid NOT NULL REFERENCES stations(id), + installation_id uuid NOT NULL UNIQUE, + label text, + last_seen_at timestamptz, + app_version text, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS scans ( + id uuid PRIMARY KEY, + tenant_id text NOT NULL REFERENCES tenants(id), + site_id uuid NOT NULL REFERENCES sites(id), + station_id uuid NOT NULL REFERENCES stations(id), + device_id uuid NOT NULL REFERENCES devices(id), + operator_subject text NOT NULL, + raw_value text NOT NULL, + format text NOT NULL, + duplicate boolean NOT NULL DEFAULT false, + captured_at timestamptz NOT NULL, + received_at timestamptz NOT NULL DEFAULT now(), + schema_version integer NOT NULL +); + +CREATE TABLE IF NOT EXISTS pallet_sessions ( + id uuid PRIMARY KEY, + tenant_id text NOT NULL REFERENCES tenants(id), + site_id uuid NOT NULL REFERENCES sites(id), + station_id uuid NOT NULL REFERENCES stations(id), + device_id uuid NOT NULL REFERENCES devices(id), + operator_subject text NOT NULL, + workflow_type text NOT NULL, + target_count integer NOT NULL, + started_at timestamptz NOT NULL, + completed_at timestamptz, + status text NOT NULL, + conforms boolean, + result_status text, + schema_version integer NOT NULL DEFAULT 1, + received_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS pallet_observations ( + id uuid PRIMARY KEY, + pallet_session_id uuid NOT NULL REFERENCES pallet_sessions(id) ON DELETE CASCADE, + carton_index integer NOT NULL, + ean text, + fields_json jsonb, + raw_text text, + confidence double precision, + captured_at timestamptz NOT NULL, + schema_version integer NOT NULL DEFAULT 1, + received_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS pallet_exceptions ( + id uuid PRIMARY KEY, + pallet_session_id uuid NOT NULL REFERENCES pallet_sessions(id) ON DELETE CASCADE, + observation_id uuid REFERENCES pallet_observations(id) ON DELETE SET NULL, + field text NOT NULL, + expected_value text, + actual_value text, + exception_type text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS sync_events ( + id uuid PRIMARY KEY, + aggregate_id uuid NOT NULL, + event_type text NOT NULL, + schema_version integer NOT NULL, + payload jsonb NOT NULL, + status text NOT NULL, + received_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS reference_label_specs ( + id uuid PRIMARY KEY, + tenant_id text NOT NULL REFERENCES tenants(id), + ean text NOT NULL, + fields_json jsonb NOT NULL, + active boolean NOT NULL DEFAULT true, + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_sites_tenant ON sites(tenant_id); +CREATE INDEX IF NOT EXISTS idx_stations_site ON stations(site_id); +CREATE INDEX IF NOT EXISTS idx_devices_station ON devices(station_id); +CREATE INDEX IF NOT EXISTS idx_devices_last_seen ON devices(last_seen_at DESC); +CREATE INDEX IF NOT EXISTS idx_scans_tenant_captured ON scans(tenant_id, captured_at DESC); +CREATE INDEX IF NOT EXISTS idx_scans_station_captured ON scans(station_id, captured_at DESC); +CREATE INDEX IF NOT EXISTS idx_scans_device_captured ON scans(device_id, captured_at DESC); +CREATE INDEX IF NOT EXISTS idx_pallet_sessions_tenant_started ON pallet_sessions(tenant_id, started_at DESC); +CREATE INDEX IF NOT EXISTS idx_pallet_sessions_station_started ON pallet_sessions(station_id, started_at DESC); +CREATE INDEX IF NOT EXISTS idx_pallet_observations_session ON pallet_observations(pallet_session_id, carton_index); +CREATE INDEX IF NOT EXISTS idx_pallet_exceptions_session ON pallet_exceptions(pallet_session_id); +CREATE INDEX IF NOT EXISTS idx_sync_events_aggregate ON sync_events(aggregate_id); +CREATE INDEX IF NOT EXISTS idx_reference_label_specs_ean ON reference_label_specs(tenant_id, ean) WHERE active = true; diff --git a/migrations/002_bootstrap.sql b/migrations/002_bootstrap.sql new file mode 100644 index 0000000..aaa7f39 --- /dev/null +++ b/migrations/002_bootstrap.sql @@ -0,0 +1,11 @@ +INSERT INTO tenants (id, name) +VALUES ('fieldlens-production', 'FieldLens Production') +ON CONFLICT (id) DO NOTHING; + +INSERT INTO sites (id, tenant_id, name, timezone) +VALUES ('00000000-0000-4000-8000-000000000001', 'fieldlens-production', 'Production Site', 'Pacific/Auckland') +ON CONFLICT (id) DO NOTHING; + +INSERT INTO stations (id, site_id, name, station_type, active) +VALUES ('00000000-0000-4000-8000-000000000101', '00000000-0000-4000-8000-000000000001', 'Unassigned', 'capture', true) +ON CONFLICT (id) DO NOTHING; diff --git a/package.json b/package.json new file mode 100644 index 0000000..9a311fa --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "fieldlens", + "version": "0.1.0", + "private": true, + "type": "module", + "workspaces": [ + "apps/*", + "packages/contracts", + "packages/fieldlens-core" + ], + "scripts": { + "test": "npm run test --workspace @fieldlens/core && npm run test --workspace @fieldlens/web && npm run test --workspace @fieldlens/api", + "lint": "npm run lint --workspace @fieldlens/core && npm run lint --workspace @fieldlens/web && npm run lint --workspace @fieldlens/api", + "build": "npm run build --workspace @fieldlens/contracts && npm run build --workspace @fieldlens/core && npm run build --workspace @fieldlens/web && npm run build --workspace @fieldlens/api", + "migration:validate": "node scripts/validate-migrations.mjs", + "verify": "npm run test && npm run lint && npm run migration:validate && npm run build" + }, + "engines": { + "node": ">=24" + } +} diff --git a/packages/contracts/package.json b/packages/contracts/package.json new file mode 100644 index 0000000..61427a8 --- /dev/null +++ b/packages/contracts/package.json @@ -0,0 +1,20 @@ +{ + "name": "@fieldlens/contracts", + "version": "1.0.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc -p tsconfig.json" + }, + "devDependencies": { + "typescript": "6.0.2" + } +} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts new file mode 100644 index 0000000..c9ffc58 --- /dev/null +++ b/packages/contracts/src/index.ts @@ -0,0 +1,116 @@ +export const FIELDLENS_SCHEMA_VERSION = 1 as const + +export type SchemaVersion = typeof FIELDLENS_SCHEMA_VERSION + +export type ScanRecord = { + id: string + rawValue: string + format: string + duplicate: boolean + capturedAt: string + schemaVersion: SchemaVersion +} + +export type PalletSession = { + id: string + workflowType: 'ean' | 'carton-label' + targetCount: number + startedAt: string + completedAt?: string + status: 'active' | 'complete' + conforms?: boolean + resultStatus?: 'pass' | 'fail' | 'review' + schemaVersion: SchemaVersion +} + +export type PalletObservation = { + id: string + palletSessionId: string + cartonIndex: number + ean?: string + fieldsJson?: Record + rawText?: string + confidence?: number + capturedAt: string + schemaVersion: SchemaVersion +} + +export type PalletException = { + id: string + palletSessionId: string + observationId?: string + field: string + expectedValue?: string + actualValue?: string + exceptionType: string + schemaVersion: SchemaVersion +} + +export type OutboxEvent = { + id: string + aggregateId: string + eventType: + | 'scan.captured' + | 'pallet.session.started' + | 'pallet.observation.captured' + | 'pallet.session.completed' + schemaVersion: SchemaVersion + payload: unknown + createdAt: string +} + +export type DeviceRegistration = { + installationId: string + stationId?: string + label?: string + appVersion?: string + schemaVersion: SchemaVersion +} + +export type Station = { + id: string + siteId: string + name: string + stationType: string + active: boolean +} + +export type SyncRequest = { + deviceId: string + schemaVersion: SchemaVersion + events: OutboxEvent[] +} + +export type SyncAcknowledgement = { + eventId: string + status: 'accepted' | 'duplicate' | 'rejected' + error?: string +} + +export type SyncResponse = { + schemaVersion: SchemaVersion + acknowledgements: SyncAcknowledgement[] +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function isOutboxEvent(value: unknown): value is OutboxEvent { + if (!isObject(value)) return false + return typeof value.id === 'string' + && typeof value.aggregateId === 'string' + && typeof value.eventType === 'string' + && value.schemaVersion === FIELDLENS_SCHEMA_VERSION + && typeof value.createdAt === 'string' + && 'payload' in value +} + +export function isSyncRequest(value: unknown): value is SyncRequest { + if (!isObject(value)) return false + return typeof value.deviceId === 'string' + && value.schemaVersion === FIELDLENS_SCHEMA_VERSION + && Array.isArray(value.events) + && value.events.length <= 100 + && value.events.every(isOutboxEvent) +} diff --git a/packages/contracts/tsconfig.json b/packages/contracts/tsconfig.json new file mode 100644 index 0000000..5c5d393 --- /dev/null +++ b/packages/contracts/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/fieldlens-core/.env.example b/packages/fieldlens-core/.env.example new file mode 100644 index 0000000..d156752 --- /dev/null +++ b/packages/fieldlens-core/.env.example @@ -0,0 +1,2 @@ +# FieldLens Core requires no environment variables by default. +# Host applications can define API/auth/provider configuration. diff --git a/packages/fieldlens-core/.github/workflows/ci.yml b/packages/fieldlens-core/.github/workflows/ci.yml new file mode 100644 index 0000000..749a2b4 --- /dev/null +++ b/packages/fieldlens-core/.github/workflows/ci.yml @@ -0,0 +1,19 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + - run: npm install + - run: npm test + - run: npm run lint + - run: npm run build diff --git a/packages/fieldlens-core/.gitignore b/packages/fieldlens-core/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/packages/fieldlens-core/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/packages/fieldlens-core/.oxlintrc.json b/packages/fieldlens-core/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/packages/fieldlens-core/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/packages/fieldlens-core/ARROWSTACK_PLAN.md b/packages/fieldlens-core/ARROWSTACK_PLAN.md new file mode 100644 index 0000000..6838f1e --- /dev/null +++ b/packages/fieldlens-core/ARROWSTACK_PLAN.md @@ -0,0 +1,545 @@ +# Arrowstack Integration Plan + +This document describes the planned path from the public, local-first FieldLens Core to a production FieldLens application running on Arrowstack. + +The core repository stays reusable and infrastructure-neutral. Arrowstack-specific authentication, remote persistence, tenancy, integrations, deployment configuration, and operational tooling should live in the host application or deployment layer rather than being coupled into capture logic. + +## Target architecture + +```text + Arrowstack + │ + Keycloak OIDC + │ + ▼ +┌──────────────────────────────────────────────┐ +│ FieldLens PWA │ +│ │ +│ React / Vite │ +│ barcode scanner │ +│ EAN pallet verification │ +│ carton-label OCR / conformity │ +│ IndexedDB │ +│ │ +│ local records ───────► durable sync outbox │ +└──────────────────────────┬───────────────────┘ + │ HTTPS + ▼ +┌──────────────────────────────────────────────┐ +│ FieldLens API │ +│ │ +│ JWT validation / RBAC │ +│ sync ingestion │ +│ pallet sessions │ +│ stations / devices │ +│ reference-data provider boundary │ +│ integration adapters │ +└──────────────────────────┬───────────────────┘ + │ + ┌─────────┴─────────┐ + ▼ ▼ + PostgreSQL integrations + │ + ┌─────────┼─────────┐ + ▼ ▼ ▼ + ERP reporting MCP +``` + +## Design principles + +1. **Offline capture remains authoritative during operation.** Scanner, EAN, and OCR workflows must continue functioning without network access. +2. **Remote persistence is additive.** A failed login refresh, unavailable API, database outage, or integration outage must not stop local capture. +3. **No infrastructure secrets reach the browser.** Database, ERP, MCP, deployment, and provider credentials remain server-side. +4. **Sync is idempotent.** Replaying an outbox item must never create duplicate server records. +5. **The core stays portable.** Arrowstack integration should wrap or extend FieldLens Core rather than fork scanner logic. +6. **Human and machine authentication stay separate.** Browser sessions use OIDC Authorization Code + PKCE; service-to-service integrations use dedicated machine identities. +7. **Production deployment stays portable.** Container images should work under Arrowstack's current application platform and a later Kubernetes deployment without application redesign. + +## Repository boundary + +FieldLens Core should continue to contain: + +- barcode decoding and validation +- camera/device handling +- EAN pallet logic +- label detection and OCR +- conformity calculations +- IndexedDB persistence +- offline UX +- provider interfaces +- pure tests + +The Arrowstack host application should own: + +- Keycloak configuration +- API implementation +- PostgreSQL persistence +- tenancy and RBAC +- sync/outbox transport +- station/device registration +- server-side integrations +- Docker/runtime configuration +- deployment manifests +- secrets +- monitoring and audit data + +A likely production repository layout is: + +```text +fieldlens/ +├── apps/ +│ ├── web/ +│ │ └── consumes FieldLens Core +│ └── api/ +│ ├── auth/ +│ ├── sync/ +│ ├── scans/ +│ ├── pallets/ +│ ├── stations/ +│ └── integrations/ +├── packages/ +│ ├── contracts/ +│ └── fieldlens-core/ +├── migrations/ +├── Dockerfile +└── README.md +``` + +The core can initially be consumed from Git and later published as a package if that becomes useful. + +## Phase 1 — Stable core boundary + +Goal: make the current browser application explicitly integration-neutral. + +### Work + +- Keep capture records written locally first. +- Preserve generated UUIDs as stable record identifiers. +- Remove direct service-specific sync from scanner code. +- Keep external reference lookups behind the provider registry. +- Define shared serializable contracts for scans, pallet sessions, OCR observations, and conformity results. +- Add schema/version information to records that will cross the network boundary. + +### Acceptance criteria + +- All existing capture workflows work with no backend configured. +- Tests, lint, and build pass. +- No remote provider is required to start or complete a scan. +- Public core contains no deployment credentials or account-specific integration secrets. + +## Phase 2 — Durable sync outbox + +Goal: make browser-to-server synchronization reliable and retry-safe. + +The local model should evolve from a simple local/synced flag into an explicit outbox. + +```text +capture + │ + ▼ +IndexedDB transaction + ├── domain record + └── outbox event + │ + ▼ + connectivity available + │ + ▼ + batch sync request + │ + ▼ + idempotent API write + │ + ▼ + acknowledge event IDs + │ + ▼ + mark local events sent +``` + +### Suggested outbox record + +```ts +type OutboxRecord = { + id: string + aggregateId: string + eventType: string + schemaVersion: number + payload: unknown + createdAt: string + attempts: number + nextAttemptAt?: string + syncedAt?: string +} +``` + +### API shape + +```http +POST /api/v1/sync +Authorization: Bearer +Content-Type: application/json +``` + +Example request: + +```json +{ + "deviceId": "device-uuid", + "events": [ + { + "id": "event-uuid", + "eventType": "scan.captured", + "schemaVersion": 1, + "payload": {} + } + ] +} +``` + +The server should use the event ID or domain record UUID as an idempotency key. + +### Acceptance criteria + +- Offline scans queue without error. +- Reconnecting automatically resumes sync. +- Replaying the same batch is safe. +- Partial batch failure does not lose successful acknowledgements. +- Operators can see pending/synced/error state without being blocked by it. + +## Phase 3 — Arrowstack authentication + +Goal: authenticate humans and machines without changing offline capture behavior. + +### Browser authentication + +Use Keycloak: + +- Authorization Code flow +- PKCE +- short-lived access tokens +- refresh/session handling outside scanner logic +- API validates issuer, signature, expiry, and intended audience/client + +The PWA should be able to launch and operate locally even when an existing authenticated session cannot immediately refresh. Remote sync waits until authentication is available. + +### Machine authentication + +Server-side integrations should use dedicated Keycloak service accounts and `client_credentials`. + +Examples: + +- FieldLens API to another Arrowstack service +- Arrowstack MCP to FieldLens API +- background integration workers + +Machine credentials must never be exposed to the PWA. + +### Initial roles + +A minimal role model could be: + +```text +fieldlens-operator +fieldlens-supervisor +fieldlens-admin +fieldlens-integration +``` + +Role enforcement belongs at the API boundary. + +## Phase 4 — PostgreSQL data model + +Goal: make synchronized capture data centrally queryable and auditable. + +### Core hierarchy + +```text +tenant + └── site + └── station + └── device + └── scan events +``` + +### Suggested entities + +#### tenants + +- id +- name +- created_at + +#### sites + +- id +- tenant_id +- name +- timezone +- created_at + +#### stations + +- id +- site_id +- name +- station_type +- active +- created_at + +#### devices + +- id +- station_id +- installation_id +- label +- last_seen_at +- app_version + +#### scans + +- id +- tenant_id +- site_id +- station_id +- device_id +- operator_subject +- raw_value +- format +- duplicate +- captured_at +- received_at +- schema_version + +#### pallet_sessions + +- id +- tenant_id +- site_id +- station_id +- device_id +- operator_subject +- workflow_type +- target_count +- started_at +- completed_at +- status +- conforms +- result_status + +#### pallet_observations + +- id +- pallet_session_id +- carton_index +- ean +- fields_json +- raw_text +- confidence +- captured_at + +#### pallet_exceptions + +- id +- pallet_session_id +- observation_id +- field +- expected_value +- actual_value +- exception_type + +### Data ownership + +Tenant/site/station identity should be derived from trusted server-side registration and token context where possible, rather than accepting arbitrary tenant identifiers from the browser. + +## Phase 5 — Device and station registration + +Goal: make a PWA installation identifiable without turning device identity into a secret. + +On first run: + +1. Generate a persistent installation UUID. +2. Store it in IndexedDB. +3. After login, register it with the API. +4. An authorized user assigns or confirms its station. +5. The API returns non-secret station metadata. +6. Every synced event includes the installation UUID. + +A reset browser/device can register as a new installation without affecting historical records. + +## Phase 6 — Reference-data providers + +Goal: move environment-specific expected-label lookups behind server-side adapters. + +FieldLens Core already exposes a provider boundary. The Arrowstack host can supply a provider backed by: + +- PostgreSQL +- ERP API +- sorter/packhouse database +- Google Sheets +- another Arrowstack service +- a cached reference dataset + +Preferred production flow: + +```text +FieldLens Core + │ + ▼ +host provider + │ + ▼ +FieldLens API + │ + ├── cached reference data + ├── ERP adapter + └── other server-side provider +``` + +The browser should not need provider credentials. + +Reference lookup failure must degrade to local OCR/conformity rather than block the workflow. + +## Phase 7 — API surface + +Initial endpoints: + +```http +GET /api/health/live +GET /api/health/ready + +POST /api/v1/sync + +GET /api/v1/me +GET /api/v1/stations +POST /api/v1/devices/register + +GET /api/v1/scans +GET /api/v1/pallet-sessions/:id + +GET /api/v1/reference/labels/:ean +``` + +Administrative write endpoints should be added only when needed. + +## Phase 8 — Containerisation and Arrowstack deployment + +Goal: make the application deployable as a normal Arrowstack workload. + +### Web + +Build the Vite application into static assets and serve them from a small web image or through the API image if a single-container deployment is preferred initially. + +### API + +Container requirements: + +- non-root runtime user +- health endpoints +- graceful shutdown +- configuration through environment variables +- no baked-in secrets +- structured logs +- database migrations as an explicit deployment step + +### Database + +Provision PostgreSQL through Arrowstack's tenant/database tooling. + +The runtime database role should have only the permissions needed by the FieldLens database. Schema migrations should use a migration role rather than requiring broad privileges from the normal API runtime identity. + +### Deployment stages + +1. Build image. +2. Run tests. +3. Run migrations. +4. Deploy API. +5. Verify readiness. +6. Deploy web. +7. Run a smoke sync from a test device. +8. Promote/retain the healthy revision. + +The first production target can use Arrowstack's current application deployment platform. The container contract should stay compatible with a later Kubernetes/K3s deployment. + +## Phase 9 — Observability and audit + +Capture at least: + +- API request/error rate +- sync batch success/failure +- pending client outbox count where available +- database health +- active installations +- last-seen station/device timestamps +- application version +- reference-provider latency/failures + +Do not log: + +- access tokens +- authorization headers +- Keycloak client secrets +- database URLs with passwords +- provider credentials +- complete environment dumps + +Audit-sensitive actions such as station reassignment, administrative corrections, and integration changes should have actor, timestamp, and before/after metadata. + +## Phase 10 — Arrowstack MCP integration + +FieldLens can later expose a narrow machine-facing API or MCP surface for operational tooling. + +Useful read operations: + +- list active stations +- summarize recent scans +- show unsynced/error conditions +- fetch pallet conformity results +- list recent exceptions + +Possible controlled write operations: + +- register/disable a station +- update reference data +- acknowledge or annotate an exception + +Machine access should use a dedicated Keycloak client and explicit FieldLens roles. MCP access must not imply unrestricted database access. + +## Delivery sequence + +Recommended implementation order: + +```text +1. shared contracts +2. IndexedDB outbox +3. sync client +4. API skeleton + health +5. PostgreSQL migrations +6. idempotent /sync endpoint +7. Keycloak browser auth +8. device/station registration +9. reference provider API +10. Arrowstack container deployment +11. admin/operations views +12. MCP integration +``` + +This sequence gets durable central data working before adding secondary integrations or dashboards. + +## First production milestone + +The first Arrowstack-backed release is complete when: + +- the PWA installs and works offline +- scans and pallet sessions persist locally first +- users authenticate through Keycloak when online +- queued data syncs idempotently to PostgreSQL +- a device is associated with a station +- the API enforces tenant/station access +- reference lookups can be supplied by the API but are optional +- no server credentials are present in browser bundles +- application images deploy through Arrowstack +- health checks, logs, and database migrations are operational +- CI runs tests, lint, and build before deployment + +At that point FieldLens is no longer a standalone scanner with optional integrations; it is a local-first Arrowstack application whose capture path remains resilient when the platform or network is unavailable. diff --git a/packages/fieldlens-core/README.md b/packages/fieldlens-core/README.md new file mode 100644 index 0000000..ac36410 --- /dev/null +++ b/packages/fieldlens-core/README.md @@ -0,0 +1,62 @@ +# FieldLens Core + +FieldLens Core is the reusable, local-first capture engine extracted from the FieldLens prototype. + +It provides browser-based barcode capture, EAN pallet verification, carton-label OCR/conformity workflows, offline IndexedDB storage, device/orientation handling, and PWA support without requiring a hosted backend. + +## Goals + +- Keep capture workflows usable offline. +- Keep scanner, OCR, validation, and persistence logic independent from any specific backend. +- Expose clear integration seams for APIs, ERPs, databases, and other providers. +- Avoid shipping service credentials or infrastructure-specific configuration in the browser. + +## Stack + +- React + TypeScript + Vite +- Dexie / IndexedDB +- ZXing +- Three.js +- Vite PWA +- Vitest + +## Development + +```bash +npm install +npm run dev +``` + +Camera access requires HTTPS except on `localhost`. + +## Quality checks + +```bash +npm test +npm run lint +npm run build +``` + +## Local-first model + +```text +capture workflow + ↓ + IndexedDB + ↓ +integration boundary + ├─ API / database + ├─ ERP + ├─ reporting + └─ custom provider +``` + +The public core intentionally contains no deployment-specific configuration and no direct account integration. Production hosts can add authentication, remote persistence, sync/outbox processing, tenancy, and provider adapters without making capture dependent on network availability. + +## Arrowstack plan + +See [ARROWSTACK_PLAN.md](./ARROWSTACK_PLAN.md) for the production integration plan covering Keycloak authentication, offline sync/outbox, PostgreSQL, station/device registration, deployment, observability, and MCP integration. + +## Origin + +Extracted from the private `dmparrow/net` FieldLens prototype. That repository remains unchanged and separate from this public core. diff --git a/packages/fieldlens-core/index.html b/packages/fieldlens-core/index.html new file mode 100644 index 0000000..f3228c6 --- /dev/null +++ b/packages/fieldlens-core/index.html @@ -0,0 +1,19 @@ + + + + + + + + + + + + + FieldLens Capture + + +
+ + + diff --git a/packages/fieldlens-core/package.json b/packages/fieldlens-core/package.json new file mode 100644 index 0000000..4a3e57f --- /dev/null +++ b/packages/fieldlens-core/package.json @@ -0,0 +1,39 @@ +{ + "name": "@fieldlens/core", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "oxlint", + "preview": "vite preview", + "test": "vitest run" + }, + "dependencies": { + "@zxing/browser": "^0.2.1", + "@zxing/library": "^0.23.0", + "bwip-js": "^4.11.4", + "dexie": "^4.4.5", + "dexie-react-hooks": "^4.4.0", + "lucide-react": "^1.33.0", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "three": "^0.180.0" + }, + "devDependencies": { + "@types/bwip-js": "^3.2.3", + "@types/node": "^24.13.3", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@types/three": "^0.180.0", + "@vitejs/plugin-react": "^6.0.4", + "fake-indexeddb": "^6.2.5", + "oxlint": "^1.75.0", + "typescript": "~6.0.2", + "vite": "^8.2.0", + "vite-plugin-pwa": "^1.3.0", + "vitest": "^4.1.11" + }, + "description": "Vendored production copy of the local-first FieldLens capture core" +} diff --git a/packages/fieldlens-core/public/apple-touch-icon.png b/packages/fieldlens-core/public/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..cf222a285536ce00277073a3cf7cb60d7f341dcf GIT binary patch literal 525 zcmeAS@N?(olHy`uVBq!ia0vp^TR@nD2}o{QKQWbof$@r`i(^Q|t+#i5xtbg#8Xoez ze{g9@>;=g^j_q&R<|<~WmrU7q@w~{4`!BxOoMzi7dCyJY(yEuYME7jk#?rxDsK9rm z(L#Xzk%J77(*S2dg>fiwS)uH2Ci8ox|3=$6pY$>g$Q)jl{~T-r%(w*=Uu*VxeVaYI zdxq;1*$8W>1u$s`88B-`Q0y6>b<;l|R-b+M-`$L)j3*nvFD~PY2@_z4*zCCC!f%<> zKR*@LpD^80jL;2{WrVmIA&g+*P|(3Vuhs5emBqKtK>q#nZcE*=gn3ra}00``2jxcDAcC;x07!F5hsP3(093u2)Oe qh8{SF2xqW6QNjzvfkniVa%R09I(*ATY}$d*%;4$j=d#Wzp$P!qU&!wO literal 0 HcmV?d00001 diff --git a/packages/fieldlens-core/public/favicon-16.png b/packages/fieldlens-core/public/favicon-16.png new file mode 100644 index 0000000000000000000000000000000000000000..19cc32f29c0bc129c8d161c6108296db440eaa5b GIT binary patch literal 165 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61SBU+%rFB|Ii4<#Ar-fhMZ%|~E3g_EPT=sk zc7Q4U?%fprbnV}=M?{vKX=A^xXKY~bXYb_0Ym8bOfxuDR_sRRYGK+E=cJp@2D_r7V zCwRg0e8N$wju}BJw-0dmdlwiRII~4gV_hvXZ$_HpJwt(Dk8jKjE7qtzOesrs0@};q M>FVdQ&MBb@0B^Q9j{pDw literal 0 HcmV?d00001 diff --git a/packages/fieldlens-core/public/favicon-32.png b/packages/fieldlens-core/public/favicon-32.png new file mode 100644 index 0000000000000000000000000000000000000000..e843bddac657953a5780eef3f50266ceed6152e8 GIT binary patch literal 177 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1SJ1Ryj={WDm+~rLn>}1i-b=}U!aEBvPw4$15M5KUma<-mTiIeWT%%?y1pkp*3YP7-?AY&AtB(xCDjuWzOE)c%ujALU$L5b!(-VVhozMQhJ9vr boQw?YzWQ6akF2f(x`Dyd)z4*}Q$iB}4(dV~ literal 0 HcmV?d00001 diff --git a/packages/fieldlens-core/public/favicon.svg b/packages/fieldlens-core/public/favicon.svg new file mode 100644 index 0000000..30f9d51 --- /dev/null +++ b/packages/fieldlens-core/public/favicon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/fieldlens-core/public/fieldlens-horizontal.svg b/packages/fieldlens-core/public/fieldlens-horizontal.svg new file mode 100644 index 0000000..85410c1 --- /dev/null +++ b/packages/fieldlens-core/public/fieldlens-horizontal.svg @@ -0,0 +1,11 @@ + + + + + + + + + + FieldLens + diff --git a/packages/fieldlens-core/public/fieldlens-mark.svg b/packages/fieldlens-core/public/fieldlens-mark.svg new file mode 100644 index 0000000..97ece08 --- /dev/null +++ b/packages/fieldlens-core/public/fieldlens-mark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/fieldlens-core/public/fieldlens-wordmark.svg b/packages/fieldlens-core/public/fieldlens-wordmark.svg new file mode 100644 index 0000000..feadd1c --- /dev/null +++ b/packages/fieldlens-core/public/fieldlens-wordmark.svg @@ -0,0 +1 @@ +FieldLens diff --git a/packages/fieldlens-core/public/icons.svg b/packages/fieldlens-core/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/packages/fieldlens-core/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/fieldlens-core/public/pwa-192.png b/packages/fieldlens-core/public/pwa-192.png new file mode 100644 index 0000000000000000000000000000000000000000..5fd8e0b15adefa36343998631a7251b9e40b989e GIT binary patch literal 552 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE2}s`E_d9@rf$_bki(^Q|t+#g_xtbgp94_|k zZ1$d7)|CE%P4=2}N!xw2Ph#QurJmy3O+JMRU}csj$m z&qgr}ViH1mj<(8=nLzcNU}@6e@qz(96>cQ_TFj_byw#x7{nJ>;2P(8U8=dVF4P|z`&ru zz`y}Am2@UKdXTK5iT1agZUI@*;?5PK!DcMKFB~glVsKz!5MW?nVPIe+z{Ibx!HY$C zg`>|+feq>Vwyf<|v|vOIIwXIQ!z5Zy!IT7+DZB2klds8|@vK(-e(k|)>wlg-dXSTV z->`eW!6}X_UT0Yj$Afz%Bm@-1Fd{+}yRC3hLJT zrnh^&WUyI8LB17AVo^ZLat#c~NeM|dKvlnW`3-}!yt;KSu5PP7`+x<9LL@!-nfPTJ za5)4?8_`J>Ne_&9AlRTuD(Cg3U*R2^zh&P_vM?NI_F(?Q$^ui+K!gLZsD%jqU-#>Ao;za{u(2^N$TY6qGRbMb-vDh8(`ddqhEzmvv4FO#reKr*r@S literal 0 HcmV?d00001 diff --git a/packages/fieldlens-core/src/App.css b/packages/fieldlens-core/src/App.css new file mode 100644 index 0000000..d1857f7 --- /dev/null +++ b/packages/fieldlens-core/src/App.css @@ -0,0 +1,223 @@ +:root { + --ink: #17211f; + --paper: #f7f7f2; + --line: #d7dbd2; + --yellow: #f4d35e; + --red: #c43e35; + --red-soft: #f8e7e2; + --green: #25735b; +} + +.app-shell { min-height: 100vh; min-height: 100dvh; display: grid; grid-template-columns: 248px minmax(0, 1fr); } +.sidebar { position: sticky; top: 0; height: 100vh; height: 100dvh; display: flex; flex-direction: column; padding: calc(24px + env(safe-area-inset-top)) calc(16px + env(safe-area-inset-right)) calc(24px + env(safe-area-inset-bottom)) calc(16px + env(safe-area-inset-left)); background: var(--ink); color: white; } +.brand { display: flex; align-items: center; gap: 12px; width: 100%; padding: 0 8px 26px; color: white; background: none; border: 0; text-align: left; } +.brand-mark { display: grid; place-items: center; width: 42px; height: 42px; border-radius: 7px; color: var(--ink); background: var(--yellow); } +.brand strong, .brand small { display: block; letter-spacing: 0; } +.brand strong { font-size: 17px; } +.brand small { margin-top: 3px; color: #aeb8b4; font-size: 9px; font-weight: 700; } +.sidebar nav { display: grid; gap: 5px; } +.sidebar nav button { display: grid; grid-template-columns: 24px 1fr auto; align-items: center; gap: 10px; min-height: 46px; padding: 0 12px; border: 0; border-radius: 6px; color: #bfc7c4; background: transparent; text-align: left; font-weight: 700; } +.sidebar nav button:hover, .sidebar nav button.active { color: white; background: #2a3633; } +.sidebar nav button.active { box-shadow: inset 3px 0 var(--yellow); } +.sidebar nav b { display: grid; place-items: center; min-width: 24px; height: 22px; padding: 0 6px; border-radius: 11px; color: var(--ink); background: var(--yellow); font-size: 11px; } +.sidebar-status { display: flex; align-items: center; gap: 10px; margin-top: auto; padding: 14px 10px; border-top: 1px solid #394542; color: #c4cdca; } +.sidebar-status strong, .sidebar-status small { display: block; } +.sidebar-status strong { font-size: 12px; } +.sidebar-status small { margin-top: 2px; color: #899591; font-size: 10px; } +main { min-width: 0; min-height: 100vh; min-height: 100dvh; background: var(--paper); } +.loading-view { display: grid; min-height: 100vh; min-height: 100dvh; place-items: center; color: #68746f; font-size: 11px; font-weight: 900; text-transform: uppercase; } +.mobile-nav { display: none; } + +.scanner-view { min-height: 100vh; min-height: 100dvh; display: grid; grid-template-rows: minmax(520px, 1fr) auto; background: var(--ink); } +.camera-stage { position: relative; min-height: 520px; overflow: hidden; background: #26312e; isolation: isolate; } +.camera-stage video { width: 100%; height: 100%; position: absolute; inset: 0; object-fit: cover; } +.camera-shade { position: absolute; inset: 0; background: linear-gradient(180deg, rgba(12,18,17,.72), transparent 30%, transparent 65%, rgba(12,18,17,.72)); pointer-events: none; } +.camera-heading { position: absolute; z-index: 2; top: 0; left: 0; right: 0; display: flex; justify-content: space-between; align-items: flex-start; padding: calc(34px + env(safe-area-inset-top)) calc(40px + env(safe-area-inset-right)) 34px calc(40px + env(safe-area-inset-left)); color: white; } +.camera-heading h1 { margin: 5px 0 0; font-family: Georgia, serif; font-size: 37px; font-weight: 500; letter-spacing: 0; } +.eyebrow { color: #68746f; font-size: 10px; font-weight: 900; letter-spacing: 0; } +.camera-heading .eyebrow { color: var(--yellow); } +.camera-actions { display: flex; align-items: center; gap: 8px; } +.local-badge { display: flex; align-items: center; gap: 7px; padding: 7px 10px; border: 1px solid rgba(255,255,255,.24); border-radius: 4px; background: rgba(23,33,31,.6); font-size: 10px; font-weight: 900; } +.local-badge span { width: 7px; height: 7px; border-radius: 50%; background: #66cf9d; box-shadow: 0 0 0 3px rgba(102,207,157,.15); } +.scanner-detecting .local-badge span { animation: status-pulse 1.4s ease-out infinite; } +.scanner-duplicate .local-badge span, .scanner-permission-denied .local-badge span, .scanner-camera-error .local-badge span { background: #ed736a; box-shadow: 0 0 0 3px rgba(237,115,106,.16); } +.install-button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 31px; padding: 0 10px; border: 1px solid var(--yellow); border-radius: 4px; color: var(--ink); background: var(--yellow); font-size: 10px; font-weight: 900; } +.camera-tools { position: absolute; z-index: 3; top: calc(112px + env(safe-area-inset-top)); right: max(40px, env(safe-area-inset-right)); display: flex; align-items: center; gap: 7px; } +.camera-select { display: flex; align-items: center; gap: 6px; min-height: 36px; padding: 0 8px; border: 1px solid rgba(255,255,255,.24); border-radius: 4px; color: white; background: rgba(23,33,31,.72); } +.camera-select select { width: min(26vw, 180px); border: 0; outline: 0; color: white; background: transparent; font-size: 10px; font-weight: 800; } +.camera-select option { color: var(--ink); background: white; } +.camera-tool-button { display: grid; width: 36px; height: 36px; place-items: center; border: 1px solid rgba(255,255,255,.24); border-radius: 4px; color: white; background: rgba(23,33,31,.72); } +.camera-tool-button[aria-pressed="true"] { color: var(--ink); background: var(--yellow); border-color: var(--yellow); } +.camera-tool-button[aria-expanded="true"] { color: var(--ink); background: var(--yellow); border-color: var(--yellow); } +.camera-tool-button svg { width: 17px; } +.auto-toggle { display: inline-flex; min-height: 36px; align-items: center; gap: 7px; padding: 0 10px; border: 1px solid rgba(255,255,255,.24); border-radius: 4px; color: white; background: rgba(23,33,31,.72); font-size: 10px; font-weight: 900; } +.auto-toggle span { width: 8px; height: 8px; border-radius: 50%; background: #78837f; } +.auto-toggle[aria-pressed="true"] span { background: #66cf9d; box-shadow: 0 0 0 3px rgba(102,207,157,.15); } +.quality-panel { position: fixed; z-index: 25; right: max(24px, env(safe-area-inset-right)); bottom: max(24px, env(safe-area-inset-bottom)); display: grid; grid-template-columns: 1fr 1fr; gap: 12px; width: min(360px, calc(100% - 40px)); padding: 15px; border: 1px solid rgba(255,255,255,.24); border-radius: 6px; color: white; background: rgba(23,33,31,.94); box-shadow: 0 14px 36px rgba(0,0,0,.28); } +.quality-panel label { display: grid; min-width: 0; gap: 6px; } +.quality-panel label > span { color: #c9d1ce; font-size: 9px; font-weight: 900; text-transform: uppercase; } +.quality-panel input, .quality-panel select { min-width: 0; width: 100%; min-height: 38px; padding: 0 9px; border: 1px solid #53615d; border-radius: 4px; color: white; background: #2a3733; font-size: 11px; } +.quality-panel option { color: var(--ink); background: white; } +.quality-panel .quality-range, .quality-panel .quality-check { grid-column: 1 / -1; } +.quality-range span { display: flex; justify-content: space-between; } +.quality-range input { min-height: 28px; padding: 0; accent-color: var(--yellow); } +.quality-check { display: flex !important; min-height: 36px; grid-template-columns: auto 1fr; align-items: center; } +.quality-check input { width: 18px; min-height: 18px; margin: 0; accent-color: var(--yellow); } +.quality-check span { text-transform: none !important; } +.scan-frame { position: absolute; z-index: 2; top: 50%; left: 50%; width: min(82%, 620px); min-height: 132px; max-height: 205px; aspect-ratio: 3 / 1; transform: translate(-50%, -50%); } +.scan-frame.matrix { width: min(58%, 340px); min-height: 220px; max-height: 340px; aspect-ratio: 1; } +.scan-frame span { position: absolute; width: clamp(34px, 10%, 52px); height: clamp(34px, 24%, 52px); border-color: var(--yellow); border-style: solid; } +.scan-frame span:nth-child(1) { left: 0; top: 0; border-width: 4px 0 0 4px; } +.scan-frame span:nth-child(2) { right: 0; top: 0; border-width: 4px 4px 0 0; } +.scan-frame span:nth-child(3) { left: 0; bottom: 0; border-width: 0 0 4px 4px; } +.scan-frame span:nth-child(4) { right: 0; bottom: 0; border-width: 0 4px 4px 0; } +.scanner-detecting .scan-frame span { animation: rail-breathe 1.8s ease-in-out infinite; } +.scanner-captured .scan-frame span { border-color: #66cf9d; } +.scanner-duplicate .scan-frame span { border-color: #ed736a; } +.quality-settings-open .scan-frame { opacity: .18; } +.detection-point { position: absolute; z-index: 4; width: 13px; height: 13px; border: 2px solid white; border-radius: 50%; background: #66cf9d; box-shadow: 0 0 0 4px rgba(102,207,157,.24); transform: translate(-50%, -50%); pointer-events: none; animation: detection-pop .24s ease-out both; } +.camera-instruction { position: absolute; z-index: 2; left: 24px; right: 24px; bottom: 28px; margin: 0; color: white; text-align: center; font-size: 13px; font-weight: 800; } +.scan-result { display: grid; grid-template-columns: 48px minmax(0, 1fr) auto; align-items: center; gap: 16px; min-height: 116px; padding: 22px 40px; color: var(--ink); background: var(--paper); border-top: 5px solid var(--yellow); } +.scan-result.duplicate { background: var(--red-soft); border-color: var(--red); } +.result-icon { display: grid; place-items: center; width: 46px; height: 46px; border-radius: 6px; color: var(--ink); background: var(--yellow); } +.duplicate .result-icon { color: white; background: var(--red); } +.result-copy { min-width: 0; } +.result-copy strong, .result-copy small { display: block; } +.result-copy strong { margin: 5px 0 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 700 17px/1.2 "IBM Plex Mono", "Courier New", monospace; } +.result-copy small { color: #65716d; font-size: 11px; } +.duplicate .eyebrow { color: var(--red); } +.result-actions { display: flex; align-items: center; gap: 8px; } +.primary-button, .quiet-button { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 42px; padding: 0 16px; border-radius: 5px; font-size: 12px; font-weight: 900; letter-spacing: 0; } +.primary-button { color: var(--ink); background: var(--yellow); border: 1px solid #d7b842; box-shadow: 0 3px 0 #b99a2d; } +.primary-button:active { transform: translateY(2px); box-shadow: 0 1px 0 #b99a2d; } +.quiet-button { color: var(--ink); background: white; border: 1px solid var(--line); } + +.workspace-view { max-width: 1180px; margin: 0 auto; padding: 48px clamp(22px, 5vw, 64px) 80px; } +.view-heading { display: flex; justify-content: space-between; align-items: flex-end; gap: 28px; margin-bottom: 34px; } +.view-actions { display: flex; gap: 8px; } +.view-heading h1 { margin: 7px 0 8px; font-family: Georgia, serif; font-size: clamp(32px, 4vw, 48px); font-weight: 500; letter-spacing: 0; } +.view-heading p { margin: 0; color: #66716d; font-size: 14px; } +.metric-strip { display: grid; grid-template-columns: repeat(3, 1fr); margin-bottom: 28px; border: 1px solid var(--line); background: white; } +.metric-strip div { padding: 20px 24px; border-right: 1px solid var(--line); } +.metric-strip div:last-child { border-right: 0; } +.metric-strip strong, .metric-strip span { display: block; } +.metric-strip strong { font-family: Georgia, serif; font-size: 30px; font-weight: 500; } +.metric-strip span { margin-top: 4px; color: #6d7773; font-size: 10px; font-weight: 800; text-transform: uppercase; } +.history-list { border-top: 1px solid var(--ink); } +.history-swipe-row { position: relative; overflow: hidden; border-bottom: 1px solid var(--line); background: var(--red); } +.history-actions { position: absolute; inset: 0 0 0 auto; display: grid; grid-template-columns: repeat(2, 74px); } +.history-action { display: grid; place-items: center; align-content: center; gap: 4px; width: 74px; border: 0; color: white; font-size: 9px; font-weight: 900; } +.history-action svg { width: 18px; height: 18px; } +.copy-action { background: var(--green); } +.delete-action { background: var(--red); } +.history-row { position: relative; display: grid; grid-template-columns: 42px minmax(0,1fr) auto 36px; align-items: center; gap: 16px; min-height: 76px; padding: 12px 14px; background: white; touch-action: pan-y; transition: transform .18s ease; } +.history-row-main { min-width: 0; } +.history-row-main strong, .history-row-main small { display: block; } +.history-row-main strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: "IBM Plex Mono", "Courier New", monospace; font-size: 12px; } +.history-row-main small { margin-top: 4px; color: #6d7773; font-size: 9px; } +.history-meta { display: grid; gap: 2px; color: #707a76; font-size: 9px; text-align: right; } +.history-meta b { color: var(--green); } +.history-delete-button { display: grid; width: 36px; height: 36px; place-items: center; border: 0; border-radius: 4px; color: #7b8581; background: transparent; } +.history-delete-button:hover { color: var(--red); background: var(--red-soft); } +.undo-toast { position: fixed; z-index: 35; left: calc(248px + 24px); bottom: 24px; display: flex; align-items: center; gap: 12px; padding: 11px 13px; border: 1px solid #40504b; border-radius: 6px; color: white; background: var(--ink); box-shadow: 0 10px 30px rgba(0,0,0,.24); font-size: 10px; } +.undo-toast button { border: 0; color: var(--yellow); background: transparent; font-size: 10px; font-weight: 900; } +.code-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; } +.barcode-canvas { min-height: 190px; display: grid; place-items: center; overflow: auto; padding: 18px; border: 1px solid var(--line); background: white; } +.settings-layout { display: grid; grid-template-columns: minmax(0, 1fr) 340px; gap: 24px; } +.settings-section { border: 1px solid var(--line); background: white; } +.settings-section h2 { margin: 0; padding: 16px 18px; border-bottom: 1px solid var(--line); font-size: 13px; } +.settings-icon svg { width: 18px; } +.settings-section:nth-child(2) { grid-column: 1; } +.settings-device { grid-column: 2; grid-row: 1 / span 2; } +.setting-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 22px; min-height: 76px; padding: 14px 18px; border-bottom: 1px solid var(--line); } +.setting-row:last-child { border-bottom: 0; } +.setting-row label strong, .setting-row label small, .setting-row > div strong, .setting-row > div small { display: block; } +.setting-row strong { font-size: 12px; } +.setting-row small { margin-top: 4px; color: #75807b; font-size: 10px; line-height: 1.35; } +.setting-row b { color: var(--green); font-size: 10px; } +.setting-row select, .compact-input { width: 190px; min-height: 38px; padding: 0 9px; border: 1px solid var(--line); border-radius: 4px; background: white; } +.setting-range { display: grid; gap: 7px; } +.setting-range input { width: 190px; accent-color: var(--yellow); } +.device-facts { display: grid; gap: 12px; padding: 18px; } +.device-facts div { padding-bottom: 12px; border-bottom: 1px solid var(--line); } +.device-facts div:last-child { padding-bottom: 0; border-bottom: 0; } +.device-facts strong, .device-facts small { display: block; } +.device-facts strong { font-size: 11px; } +.device-facts small { margin-top: 3px; color: #77817d; font-size: 9px; } +.device-facts b { color: var(--green); font-size: 9px; text-transform: uppercase; } + +.install-dialog-shell { width: 100%; max-width: 400px; padding: max(20px, env(safe-area-inset-top)) max(20px, env(safe-area-inset-right)) max(20px, env(safe-area-inset-bottom)) max(20px, env(safe-area-inset-left)); border: 0; color: transparent; background: transparent; } +.install-dialog-shell::backdrop { background: rgba(10,16,15,.72); } +.install-dialog { position: relative; width: min(100%, 360px); padding: 30px 26px 24px; border: 1px solid var(--line); border-radius: 7px; color: var(--ink); background: var(--paper); box-shadow: 0 18px 60px rgba(0,0,0,.28); } +.dialog-close { position: absolute; top: 10px; right: 10px; display: grid; width: 34px; height: 34px; place-items: center; border: 0; color: #596460; background: transparent; } +.dialog-close svg { width: 18px; } +.install-dialog-icon { width: 30px; height: 30px; color: var(--green); } +.install-dialog h2 { margin: 13px 0 20px; font-family: Georgia, serif; font-size: 25px; font-weight: 500; } +.install-dialog p { display: flex; align-items: center; gap: 11px; margin: 11px 0; color: #56615d; font-size: 13px; } +.install-dialog p svg { flex: 0 0 auto; color: var(--green); } +.update-banner { position: fixed; z-index: 40; right: 24px; bottom: max(24px, env(safe-area-inset-bottom)); display: grid; grid-template-columns: 38px minmax(0, 1fr) auto; align-items: center; gap: 12px; width: min(520px, calc(100vw - 48px)); padding: 12px; border: 1px solid #40504b; border-radius: 7px; color: white; background: var(--ink); box-shadow: 0 14px 42px rgba(0,0,0,.28); } +.update-banner.error { border-color: #e2837b; } +.update-icon { display: grid; width: 38px; height: 38px; place-items: center; border-radius: 5px; color: var(--ink); background: var(--yellow); } +.update-icon svg { width: 19px; } +.update-copy { min-width: 0; } +.update-copy strong, .update-copy small { display: block; } +.update-copy strong { font-size: 12px; } +.update-copy small { margin-top: 3px; color: #b8c2be; font-size: 10px; line-height: 1.35; } +.update-banner button { display: inline-flex; min-height: 40px; align-items: center; justify-content: center; gap: 6px; padding: 0 12px; border: 1px solid #d7b842; border-radius: 5px; color: var(--ink); background: var(--yellow); font-size: 10px; font-weight: 900; } +.update-banner button svg { width: 15px; } +.update-banner .spinning { animation: update-spin .8s linear infinite; } +body:has(.update-banner) .undo-toast { bottom: 104px; } +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } + +button:focus-visible, select:focus-visible { outline: 3px solid white; outline-offset: 2px; } +.primary-button:focus-visible, .quiet-button:focus-visible, .dialog-close:focus-visible { outline-color: var(--green); } + +@keyframes status-pulse { 0% { box-shadow: 0 0 0 0 rgba(102,207,157,.5); } 70%, 100% { box-shadow: 0 0 0 7px rgba(102,207,157,0); } } +@keyframes rail-breathe { 0%, 100% { opacity: .55; } 50% { opacity: 1; } } +@keyframes detection-pop { from { opacity: 0; transform: translate(-50%, -50%) scale(.5); } to { opacity: 1; transform: translate(-50%, -50%) scale(1); } } +@keyframes update-spin { to { transform: rotate(360deg); } } + +@media (max-width: 760px) { + .app-shell { display: block; padding-bottom: calc(70px + env(safe-area-inset-bottom)); } + .sidebar { display: none; } + .mobile-nav { position: fixed; z-index: 20; left: 0; right: 0; bottom: 0; display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); height: calc(70px + env(safe-area-inset-bottom)); padding-bottom: env(safe-area-inset-bottom); border-top: 1px solid #34413d; background: var(--ink); } + .mobile-nav button { display: grid; min-width: 0; place-items: center; align-content: center; gap: 4px; padding: 0 2px; border: 0; color: #8f9a96; background: transparent; font-size: 8px; font-weight: 800; } + .mobile-nav button span { display: block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .mobile-nav button.active { color: var(--yellow); } + .scanner-view { min-height: calc(100vh - 70px - env(safe-area-inset-bottom)); min-height: calc(100dvh - 70px - env(safe-area-inset-bottom)); grid-template-rows: minmax(calc(430px + env(safe-area-inset-top)), 1fr) auto; } + .camera-stage { min-height: calc(430px + env(safe-area-inset-top)); } + .camera-heading { padding: calc(24px + env(safe-area-inset-top)) max(20px, env(safe-area-inset-right)) 24px max(20px, env(safe-area-inset-left)); } + .camera-heading h1 { font-size: 29px; } + .camera-actions { flex-direction: column; align-items: flex-end; } + .camera-tools { top: calc(126px + env(safe-area-inset-top)); left: max(20px, env(safe-area-inset-left)); right: max(20px, env(safe-area-inset-right)); flex-wrap: wrap; justify-content: flex-end; } + .quality-panel { left: max(20px, env(safe-area-inset-left)); right: max(20px, env(safe-area-inset-right)); bottom: calc(86px + env(safe-area-inset-bottom)); width: auto; } + .camera-select select { width: min(34vw, 140px); } + .scan-frame { width: 88%; } + .scan-frame.matrix { width: min(70%, 300px); min-height: 210px; } + .scan-result { grid-template-columns: 42px minmax(0, 1fr); padding: 17px 18px; } + .scan-result > .primary-button, .scan-result > .result-actions { grid-column: 1 / -1; width: 100%; } + .result-actions .primary-button, .result-actions .quiet-button { flex: 1; } + .workspace-view { padding: 30px 18px 50px; } + .view-heading { display: grid; align-items: start; margin-bottom: 24px; } + .view-actions { display: grid; grid-template-columns: 1fr 1fr; } + .view-heading .primary-button, .view-heading .quiet-button { width: 100%; } + .metric-strip { grid-template-columns: 1fr; } + .metric-strip div { display: flex; justify-content: space-between; align-items: center; padding: 13px 16px; border-right: 0; border-bottom: 1px solid var(--line); } + .metric-strip div:last-child { border-bottom: 0; } + .metric-strip strong { font-size: 23px; } + .history-row { grid-template-columns: 36px minmax(0, 1fr) 36px; gap: 12px; } + .history-meta { grid-column: 2 / 4; display: flex; justify-content: space-between; text-align: left; } + .undo-toast { left: 18px; right: 18px; bottom: calc(86px + env(safe-area-inset-bottom)); } + .update-banner { left: 18px; right: 18px; bottom: calc(86px + env(safe-area-inset-bottom)); grid-template-columns: 38px minmax(0, 1fr); width: auto; } + .update-banner button { grid-column: 1 / -1; width: 100%; } + body:has(.update-banner) .undo-toast { bottom: calc(198px + env(safe-area-inset-bottom)); } + .code-grid { grid-template-columns: 1fr; } + .barcode-canvas { min-height: 145px; } + .settings-layout { grid-template-columns: 1fr; gap: 18px; } + .settings-section:nth-child(2), .settings-device { grid-column: 1; grid-row: auto; } + .setting-row select, .compact-input, .setting-range input { width: 120px; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; } +} \ No newline at end of file diff --git a/packages/fieldlens-core/src/App.tsx b/packages/fieldlens-core/src/App.tsx new file mode 100644 index 0000000..475ecc7 --- /dev/null +++ b/packages/fieldlens-core/src/App.tsx @@ -0,0 +1,244 @@ +import { useLiveQuery } from 'dexie-react-hooks' +import { ClipboardCheck, CloudDownload, FlaskConical, History, LoaderCircle, ScanLine, Settings, Share, Smartphone, SquarePlus, Wifi, WifiOff, X } from 'lucide-react' +import { lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react' +import { useRegisterSW } from 'virtual:pwa-register/react' + +import './App.css' +import { database } from './lib/database' + +const ScannerView = lazy(() => import('./features/scanner/ScannerView').then((module) => ({ default: module.ScannerView }))) +const EanPalletView = lazy(() => import('./features/ean-pallet/EanPalletView').then((module) => ({ default: module.EanPalletView }))) +const LabelCheckView = lazy(() => import('./features/label-check/LabelCheckView').then((module) => ({ default: module.LabelCheckView }))) +const HistoryView = lazy(() => import('./features/history/HistoryView').then((module) => ({ default: module.HistoryView }))) +const CodeLab = lazy(() => import('./features/codes/CodeLab').then((module) => ({ default: module.CodeLab }))) +const SettingsView = lazy(() => import('./features/settings/SettingsView').then((module) => ({ default: module.SettingsView }))) + +type View = 'scanner' | 'ean-pallet' | 'label-check' | 'history' | 'lab' | 'settings' +type NavigationItem = { id: View; label: string; mobileLabel?: string; icon: typeof ScanLine } + +const UPDATE_CHECK_INTERVAL = 60 * 60 * 1000 + +interface BeforeInstallPromptEvent extends Event { + prompt: () => Promise + userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }> +} + +const navigation: NavigationItem[] = [ + { id: 'scanner', label: 'Scanner', icon: ScanLine }, + { id: 'ean-pallet', label: 'EAN pallet', mobileLabel: 'EAN', icon: ScanLine }, + { id: 'label-check', label: 'Carton labels', mobileLabel: 'OCR', icon: ClipboardCheck }, + { id: 'history', label: 'History', icon: History }, + { id: 'lab', label: 'Code lab', mobileLabel: 'Lab', icon: FlaskConical }, + { id: 'settings', label: 'Settings', icon: Settings }, +] + +function App() { + const [view, setView] = useState('scanner') + const [installPrompt, setInstallPrompt] = useState(null) + const [showIosInstall, setShowIosInstall] = useState(false) + const [isOnline, setIsOnline] = useState(navigator.onLine) + const [storageStatus, setStorageStatus] = useState<'checking' | 'persistent' | 'best-effort' | 'unsupported'>('checking') + const [isInstalled, setIsInstalled] = useState( + () => window.matchMedia('(display-mode: standalone)').matches || Boolean((navigator as Navigator & { standalone?: boolean }).standalone), + ) + const [isUpdating, setIsUpdating] = useState(false) + const [updateError, setUpdateError] = useState('') + const [swRegistration, setSwRegistration] = useState(null) + const installButtonRef = useRef(null) + const installDialogRef = useRef(null) + const scanCount = useLiveQuery(() => database.scans.count(), []) ?? 0 + const isIos = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1) + const { + needRefresh: [needRefresh], + updateServiceWorker, + } = useRegisterSW({ + onRegisteredSW: (_serviceWorkerUrl, registration) => setSwRegistration(registration ?? null), + }) + + const applyUpdate = useCallback(async () => { + if (isUpdating) return + setIsUpdating(true) + setUpdateError('') + try { + await updateServiceWorker(true) + } catch { + setUpdateError('Update could not be installed. Check your connection and retry.') + setIsUpdating(false) + } + }, [isUpdating, updateServiceWorker]) + + useEffect(() => { + let disposed = false + const requestPersistentStorage = async () => { + if (!navigator.storage?.persisted || !navigator.storage.persist) { + if (!disposed) setStorageStatus('unsupported') + return + } + try { + const persistent = await navigator.storage.persisted() || await navigator.storage.persist() + if (!disposed) setStorageStatus(persistent ? 'persistent' : 'best-effort') + } catch { + if (!disposed) setStorageStatus('best-effort') + } + } + void requestPersistentStorage() + return () => { disposed = true } + }, []) + + useEffect(() => { + const handleInstallPrompt = (event: Event) => { + event.preventDefault() + setInstallPrompt(event as BeforeInstallPromptEvent) + } + const handleInstalled = () => { + setInstallPrompt(null) + setIsInstalled(true) + } + const handleOnline = () => setIsOnline(true) + const handleOffline = () => setIsOnline(false) + + window.addEventListener('beforeinstallprompt', handleInstallPrompt) + window.addEventListener('appinstalled', handleInstalled) + window.addEventListener('online', handleOnline) + window.addEventListener('offline', handleOffline) + return () => { + window.removeEventListener('beforeinstallprompt', handleInstallPrompt) + window.removeEventListener('appinstalled', handleInstalled) + window.removeEventListener('online', handleOnline) + window.removeEventListener('offline', handleOffline) + } + }, []) + + useEffect(() => { + const dialog = installDialogRef.current + if (!dialog) return + if (showIosInstall && !dialog.open) dialog.showModal() + if (!showIosInstall && dialog.open) dialog.close() + }, [showIosInstall]) + + useEffect(() => { + if (!swRegistration) return + const checkForUpdate = () => { + if (navigator.onLine) void swRegistration.update().catch(() => undefined) + } + const interval = window.setInterval(checkForUpdate, UPDATE_CHECK_INTERVAL) + window.addEventListener('online', checkForUpdate) + return () => { + window.clearInterval(interval) + window.removeEventListener('online', checkForUpdate) + } + }, [swRegistration]) + + useEffect(() => { + if (!needRefresh || isUpdating) return + if (document.visibilityState === 'hidden') { + const backgroundTimer = window.setTimeout(() => void applyUpdate(), 0) + return () => window.clearTimeout(backgroundTimer) + } + const updateWhenHidden = () => { + if (document.visibilityState === 'hidden') void applyUpdate() + } + + document.addEventListener('visibilitychange', updateWhenHidden) + return () => { + document.removeEventListener('visibilitychange', updateWhenHidden) + } + }, [applyUpdate, isUpdating, needRefresh]) + + const installApp = async () => { + if (!installPrompt) { + setShowIosInstall(true) + return + } + + await installPrompt.prompt() + const { outcome } = await installPrompt.userChoice + setInstallPrompt(null) + if (outcome === 'accepted') setIsInstalled(true) + } + + const installAction = !isInstalled && (installPrompt || isIos) ? ( + + ) : null + + return ( + <> +
+ + +
+ Loading workspace
}> + {view === 'scanner' && } + {view === 'ean-pallet' && } + {view === 'label-check' && setView('settings')} />} + {view === 'history' && } + {view === 'lab' && } + {view === 'settings' && ( + + )} + + + + + + + { event.preventDefault(); event.currentTarget.close() }} onClose={() => { setShowIosInstall(false); installButtonRef.current?.focus() }} onClick={(event) => { if (event.target === event.currentTarget) event.currentTarget.close() }}> +
+ + +

Install FieldLens

+

Tap Share in Safari.

+

Choose Add to Home Screen.

+
+
+ + {needRefresh && ( + + )} + + ) +} + +export default App diff --git a/packages/fieldlens-core/src/detection-rotation.css b/packages/fieldlens-core/src/detection-rotation.css new file mode 100644 index 0000000..bd117e3 --- /dev/null +++ b/packages/fieldlens-core/src/detection-rotation.css @@ -0,0 +1,64 @@ +/* Manual detection-only correction for phones with OS/browser rotation lock. + * The camera element, camera cover rectangle and app layout stay untouched. + * deviceOrientation.ts remaps each rendered EAN detection box into corrected + * normalized coordinates and publishes them through these custom properties. */ +html[data-detection-rotation='90'] .carton-video-coordinate-space .carton-batch-box, +html[data-detection-rotation='270'] .carton-video-coordinate-space .carton-batch-box { + left: var(--fieldlens-box-left) !important; + top: var(--fieldlens-box-top) !important; + width: var(--fieldlens-box-width) !important; + height: var(--fieldlens-box-height) !important; +} + +.fieldlens-detection-rotate { + position: absolute; + z-index: 12; + right: max(10px, env(safe-area-inset-right)); + bottom: 10px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; + min-width: 58px; + height: 36px; + padding: 0 9px; + border: 1px solid rgba(255,255,255,.28); + border-radius: 5px; + color: white; + background: rgba(17,29,25,.82); + box-shadow: 0 5px 16px rgba(0,0,0,.18); + backdrop-filter: blur(8px); + font: inherit; +} + +.fieldlens-detection-rotate > span { + font-size: 18px; + line-height: 1; +} + +.fieldlens-detection-rotate > small { + color: #d5dfdb; + font-size: 7px; + font-weight: 900; + letter-spacing: .035em; +} + +.fieldlens-detection-rotate[data-rotation]:not([data-rotation='0']) { + color: var(--ink); + border-color: var(--yellow); + background: var(--yellow); +} + +.fieldlens-detection-rotate[data-rotation]:not([data-rotation='0']) > small { + color: #493e12; +} + +@media (pointer: coarse) and (orientation: landscape) and (max-height: 500px) { + .fieldlens-detection-rotate { + right: max(8px, env(safe-area-inset-right)); + bottom: 8px; + height: 32px; + min-width: 54px; + padding: 0 8px; + } +} diff --git a/packages/fieldlens-core/src/features/codes/CodeLab.css b/packages/fieldlens-core/src/features/codes/CodeLab.css new file mode 100644 index 0000000..2b3a736 --- /dev/null +++ b/packages/fieldlens-core/src/features/codes/CodeLab.css @@ -0,0 +1,147 @@ +.code-lab-tabs { + display: inline-flex; + gap: 4px; + margin: -12px 0 20px; + padding: 4px; + border: 1px solid var(--line); + border-radius: 7px; + background: white; +} + +.code-lab-tabs button { + min-height: 36px; + padding: 0 13px; + border: 0; + border-radius: 5px; + color: #6d7773; + background: transparent; + font-size: 10px; + font-weight: 900; +} + +.code-lab-tabs button.active { + color: var(--ink); + background: var(--yellow); +} + +.ean-lab-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 18px; +} + +.ean-lab-carton { + display: grid; + place-items: center; + min-height: 270px; + padding: 28px; + border-radius: 8px; + background: #4b4a43; + box-shadow: inset 0 0 0 1px rgba(255,255,255,.08); +} + +.ean-lab-label { + width: min(100%, 520px); + aspect-ratio: 2.25 / 1; + padding: 14px 16px; + border: 1px solid #d9d9d2; + background: white; + box-shadow: 0 5px 18px rgba(0,0,0,.2); + color: #111; + font-family: Arial, Helvetica, sans-serif; +} + +.ean-lab-label-head { + display: flex; + justify-content: space-between; + gap: 12px; + padding-bottom: 8px; + border-bottom: 2px solid #111; +} + +.ean-lab-label-head strong { + font-size: 15px; + letter-spacing: .02em; +} + +.ean-lab-label-head span { + font-size: 10px; + font-weight: 800; +} + +.ean-lab-fields { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 7px 12px; + padding-top: 10px; +} + +.ean-lab-fields span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 11px; + font-weight: 700; +} + +.ean-lab-fields b { + display: block; + margin-bottom: 1px; + color: #555; + font-size: 7px; + font-weight: 800; + letter-spacing: .035em; + text-transform: uppercase; +} + +.ean-lab-temp { + grid-column: span 2; +} + +@media (max-width: 760px) { + .code-lab-tabs { + width: 100%; + margin: -4px 0 14px; + } + + .code-lab-tabs button { + flex: 1; + } + + .ean-lab-grid { + grid-template-columns: 1fr; + gap: 12px; + } + + .ean-lab-carton { + min-height: 0; + padding: 14px; + } + + .ean-lab-label { + width: 100%; + padding: 11px 12px; + } + + .ean-lab-label-head strong { + font-size: 12px; + } + + .ean-lab-label-head span { + font-size: 8px; + } + + .ean-lab-fields { + gap: 5px 8px; + padding-top: 7px; + } + + .ean-lab-fields span { + font-size: 8.5px; + } + + .ean-lab-fields b { + font-size: 5.8px; + } +} \ No newline at end of file diff --git a/packages/fieldlens-core/src/features/codes/CodeLab.tsx b/packages/fieldlens-core/src/features/codes/CodeLab.tsx new file mode 100644 index 0000000..f916eb8 --- /dev/null +++ b/packages/fieldlens-core/src/features/codes/CodeLab.tsx @@ -0,0 +1,162 @@ +import bwipjs from 'bwip-js' +import { RefreshCw } from 'lucide-react' +import { useEffect, useRef, useState } from 'react' + +import { appendGtinCheckDigit, randomDigits } from '../../lib/barcodes' +import './CodeLab.css' + +type LabTab = 'barcodes' | 'ean' + +type CodeFixture = { + id: string + name: string + bcid: string + value: string +} + +type EanLabelFixture = { + id: string + carton: number + ppin: string + grower: string + variety: string + grade: string + packer: string + pickDate: string + packDate: string + size: string + count: string + region: string + tempPc: string +} + +function makeFixtures(): CodeFixture[] { + const gtin14 = appendGtinCheckDigit(randomDigits(13)) + const lot = `LOT-${randomDigits(6)}` + return [ + { id: crypto.randomUUID(), name: 'EAN-13', bcid: 'ean13', value: appendGtinCheckDigit(randomDigits(12)) }, + { id: crypto.randomUUID(), name: 'EAN-8', bcid: 'ean8', value: appendGtinCheckDigit(randomDigits(7)) }, + { id: crypto.randomUUID(), name: 'UPC-A', bcid: 'upca', value: appendGtinCheckDigit(randomDigits(11)) }, + { id: crypto.randomUUID(), name: 'ITF-14', bcid: 'itf14', value: gtin14 }, + { id: crypto.randomUUID(), name: 'Code 128', bcid: 'code128', value: `FL-${randomDigits(10)}` }, + { id: crypto.randomUUID(), name: 'GS1 Digital Link QR', bcid: 'qrcode', value: `https://id.gs1.org/01/${gtin14}/10/${lot}` }, + ] +} + +function makeEanFixtures(): EanLabelFixture[] { + const pallet = { + ppin: randomDigits(8), + grower: randomDigits(5), + variety: 'HASS AVOCADO', + grade: 'CLASS 1', + packer: `PK${randomDigits(4)}`, + pickDate: '09/09/2026', + packDate: '11/09/2026', + size: '24', + count: '24', + region: 'BAY OF PLENTY', + tempPc: randomDigits(8), + } + + return Array.from({ length: 6 }, (_, index) => ({ + id: crypto.randomUUID(), + carton: index + 1, + ...pallet, + })) +} + +function CodeCard({ fixture }: { fixture: CodeFixture }) { + const canvasRef = useRef(null) + + useEffect(() => { + if (!canvasRef.current) return + try { + bwipjs.toCanvas(canvasRef.current, { + bcid: fixture.bcid, + text: fixture.value, + scale: 3, + height: fixture.bcid === 'qrcode' ? 24 : 13, + includetext: fixture.bcid !== 'qrcode', + textxalign: 'center', + backgroundcolor: 'FFFFFF', + paddingwidth: 8, + paddingheight: 8, + }) + } catch { + // A failed fixture remains labeled so generator errors are visible during testing. + } + }, [fixture]) + + return ( +
+
{fixture.name}VALID SAMPLE
+
+ {fixture.value} +
+ ) +} + +function EanLabelCard({ fixture }: { fixture: EanLabelFixture }) { + return ( +
+
+
+ EXPORT AVOCADOS + CARTON {String(fixture.carton).padStart(2, '0')} +
+
+ PPIN{fixture.ppin} + Grower{fixture.grower} + Variety{fixture.variety} + Grade{fixture.grade} + Packer{fixture.packer} + Region{fixture.region} + Pick Date{fixture.pickDate} + Pack Date{fixture.packDate} + Size{fixture.size} + Count{fixture.count} + TempPC{fixture.tempPc} +
+
+
+ ) +} + +export function CodeLab() { + const [tab, setTab] = useState('barcodes') + const [fixtures, setFixtures] = useState(makeFixtures) + const [eanFixtures, setEanFixtures] = useState(makeEanFixtures) + + const regenerate = () => { + if (tab === 'ean') setEanFixtures(makeEanFixtures()) + else setFixtures(makeFixtures()) + } + + return ( +
+
+
+ VALIDATION BENCH +

Code lab

+

{tab === 'ean' + ? 'Synthetic carton labels for testing pallet detection, OCR and conformity from another screen.' + : 'Open this view on another screen and scan each symbol from the PWA.'}

+
+ +
+ +
+ + +
+ + {tab === 'barcodes' ? ( +
{fixtures.map((fixture) => )}
+ ) : ( +
{eanFixtures.map((fixture) => )}
+ )} +
+ ) +} \ No newline at end of file diff --git a/packages/fieldlens-core/src/features/ean-pallet/EanPalletView.css b/packages/fieldlens-core/src/features/ean-pallet/EanPalletView.css new file mode 100644 index 0000000..da2f789 --- /dev/null +++ b/packages/fieldlens-core/src/features/ean-pallet/EanPalletView.css @@ -0,0 +1 @@ +.ean-pallet-view{padding:clamp(18px,3vw,38px);max-width:1200px;margin:0 auto}.ean-pallet-view>header{display:flex;justify-content:space-between;gap:24px;align-items:flex-start;margin-bottom:24px}.ean-pallet-view h1{margin:.25rem 0;font-size:clamp(1.7rem,4vw,2.6rem)}.ean-pallet-view p{color:#7f8a96;max-width:720px}.ean-local-badge{padding:8px 12px;border:1px solid #2b7667;border-radius:999px;color:#59c7ad;white-space:nowrap}.ean-setup-card,.ean-progress,.ean-result{background:#111820;border:1px solid #25303a;border-radius:18px;padding:22px}.ean-setup-card{display:flex;align-items:end;gap:16px;max-width:520px}.ean-setup-card label{display:grid;gap:8px;flex:1}.ean-setup-card input{background:#0b1015;border:1px solid #36434e;border-radius:10px;color:#fff;padding:12px;font:inherit}.ean-primary{display:inline-flex;gap:8px;align-items:center;justify-content:center;border:0;border-radius:10px;padding:12px 16px;background:#5be0bf;color:#07110e;font-weight:800;cursor:pointer}.ean-scan-layout{display:grid;grid-template-columns:minmax(0,2fr) minmax(260px,1fr);gap:18px}.ean-camera{position:relative;overflow:hidden;min-height:55vh;border-radius:18px;background:#030506;border:1px solid #25303a}.ean-camera video{width:100%;height:100%;min-height:55vh;object-fit:cover}.ean-reticle{position:absolute;inset:25% 12%;border:2px solid #5be0bf;border-radius:16px;box-shadow:0 0 0 9999px #0005}.ean-progress{display:flex;flex-direction:column;gap:14px}.ean-progress>strong{font-size:2.2rem}.ean-progress-track{height:8px;background:#27313a;border-radius:999px;overflow:hidden}.ean-progress-track i{display:block;height:100%;background:#5be0bf;transition:width .2s}.ean-progress small{color:#7f8a96}.ean-error{color:#ff8d86!important}.ean-result{display:flex;gap:18px;max-width:760px}.ean-result>svg{width:48px;height:48px;flex:none}.ean-result.pass>svg{color:#5be0bf}.ean-result.fail>svg{color:#ff8d86}.ean-result ul{list-style:none;padding:0;margin:18px 0;display:grid;gap:8px}.ean-result li{display:flex;justify-content:space-between;background:#0b1015;border-radius:8px;padding:10px 12px}@media(max-width:760px){.ean-pallet-view>header{display:block}.ean-local-badge{display:inline-block;margin-top:8px}.ean-scan-layout{grid-template-columns:1fr}.ean-camera,.ean-camera video{min-height:48vh}.ean-setup-card{align-items:stretch;flex-direction:column}} diff --git a/packages/fieldlens-core/src/features/ean-pallet/EanPalletView.tsx b/packages/fieldlens-core/src/features/ean-pallet/EanPalletView.tsx new file mode 100644 index 0000000..6cc7202 --- /dev/null +++ b/packages/fieldlens-core/src/features/ean-pallet/EanPalletView.tsx @@ -0,0 +1,186 @@ +import { BrowserMultiFormatReader, type IScannerControls } from '@zxing/browser' +import { CheckCircle2, RefreshCw, ScanLine, XCircle } from 'lucide-react' +import { useEffect, useMemo, useRef, useState } from 'react' + +import { + finishEanPalletSession, + saveEanPalletScans, + startEanPalletSession, +} from '../../lib/database' +import { extractEanFromScan, summarizeEanScans } from '../../lib/eanPallet' +import './EanPalletView.css' + +type Step = 'setup' | 'scan' | 'result' + +export function EanPalletView() { + const videoRef = useRef(null) + const controlsRef = useRef(null) + const armedRef = useRef(false) + const sessionIdRef = useRef('') + const scansRef = useRef([]) + const [step, setStep] = useState('setup') + const [targetCount, setTargetCount] = useState(88) + const [scans, setScans] = useState([]) + const [armed, setArmed] = useState(false) + const [cameraError, setCameraError] = useState('') + const [message, setMessage] = useState('Ready') + const summary = useMemo(() => summarizeEanScans(scans), [scans]) + + useEffect(() => { + if (step !== 'scan') return + const video = videoRef.current + if (!video) return + + let disposed = false + setCameraError('') + const reader = new BrowserMultiFormatReader() + void reader.decodeFromConstraints( + { audio: false, video: { facingMode: { ideal: 'environment' } } }, + video, + (result) => { + if (!result || !armedRef.current) return + const ean = extractEanFromScan(result.getText()) + if (!ean) { + setMessage('Barcode read, but no valid EAN/UPC/GTIN was found.') + return + } + + armedRef.current = false + setArmed(false) + const nextScans = [...scansRef.current, ean] + scansRef.current = nextScans + setScans(nextScans) + setMessage(`Captured ${ean}`) + navigator.vibrate?.(45) + + if (sessionIdRef.current) { + void saveEanPalletScans(sessionIdRef.current, nextScans).catch(() => { + setMessage('Local storage failed. Do not leave this screen until the pallet is recorded.') + }) + } + + if (nextScans.length >= targetCount) { + const nextSummary = summarizeEanScans(nextScans) + if (sessionIdRef.current) { + void finishEanPalletSession(sessionIdRef.current, { + conforms: nextSummary.conforms, + consensusEan: nextSummary.consensusEan, + uniqueEanCount: nextSummary.counts.length, + }).catch(() => undefined) + } + setStep('result') + } + }, + ).then((controls) => { + if (disposed) controls.stop() + else controlsRef.current = controls + }).catch((error) => { + if (!disposed) setCameraError(error instanceof Error ? error.message : 'Camera could not be started.') + }) + + return () => { + disposed = true + controlsRef.current?.stop() + controlsRef.current = null + } + }, [step, targetCount]) + + const start = async () => { + const safeCount = Math.max(1, Math.min(500, Math.round(targetCount || 1))) + setTargetCount(safeCount) + const session = await startEanPalletSession(safeCount) + sessionIdRef.current = session.id + scansRef.current = [] + setScans([]) + setMessage('Aim at the first carton barcode.') + armedRef.current = true + setArmed(true) + setStep('scan') + } + + const armNext = () => { + setMessage('Aim at the next carton barcode.') + armedRef.current = true + setArmed(true) + } + + const reset = () => { + sessionIdRef.current = '' + scansRef.current = [] + setScans([]) + setMessage('Ready') + armedRef.current = false + setArmed(false) + setStep('setup') + } + + return ( +
+
+
+ LOCAL-FIRST PALLET CHECK +

EAN pallet verification

+

Capture one carton at a time. Each accepted read is persisted locally before it is eligible for sync.

+
+ Works offline +
+ + {step === 'setup' && ( +
+ + +
+ )} + + {step === 'scan' && ( +
+
+
+ )} + + {step === 'result' && ( +
+ {summary.conforms ? : } +
+ {summary.conforms ? 'PALLET EAN PASS' : 'EAN MISMATCH FOUND'} +

{summary.total} cartons captured

+

{summary.conforms + ? `All cartons resolved to ${summary.consensusEan}.` + : `${summary.counts.length} different trade item codes were captured. Review before release.`}

+
    + {summary.counts.map((entry) =>
  • {entry.ean}{entry.count}
  • )} +
+ +
+
+ )} +
+ ) +} diff --git a/packages/fieldlens-core/src/features/history/HistoryView.css b/packages/fieldlens-core/src/features/history/HistoryView.css new file mode 100644 index 0000000..b8d9b99 --- /dev/null +++ b/packages/fieldlens-core/src/features/history/HistoryView.css @@ -0,0 +1,381 @@ +.history-view { + --history-surface: #fff; + --history-muted: #6d7773; + --history-soft: #eef1eb; + --history-green-soft: #e7f1ed; +} + +.history-view .view-heading { + padding-bottom: 22px; + border-bottom: 1px solid var(--line); +} + +.history-view .view-heading p { + max-width: 520px; +} + +.history-view .view-actions { + flex-wrap: wrap; + justify-content: flex-end; +} + +.history-view .history-clear-button:not(:disabled) { + color: var(--red); +} + +.history-view .history-clear-button:not(:disabled):hover { + border-color: #e0b0aa; + background: var(--red-soft); +} + +.history-view .metric-strip { + gap: 10px; + margin-bottom: 18px; + border: 0; + background: transparent; +} + +.history-view .metric-strip > div { + min-width: 0; + padding: 17px 18px; + border: 1px solid var(--line); + border-radius: 7px; + background: var(--history-surface); +} + +.history-view .metric-strip > div:last-child { + border-right: 1px solid var(--line); +} + +.history-view .metric-strip strong { + color: var(--ink); + font-size: 28px; + line-height: 1; +} + +.history-view .metric-strip span { + margin-top: 7px; + color: var(--history-muted); + font-size: 9px; + letter-spacing: .035em; +} + +.history-view .history-list { + overflow: hidden; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--history-surface); + box-shadow: 0 1px 0 rgba(23, 33, 31, .03); +} + +.history-view .history-swipe-row { + border-bottom: 1px solid var(--line); +} + +.history-view .history-swipe-row:last-child { + border-bottom: 0; +} + +.history-view .history-row { + grid-template-columns: 42px minmax(0, 1fr) auto 38px; + min-height: 78px; + padding: 12px 13px; + background: var(--history-surface); +} + +.history-view .history-row:hover { + background: #fbfcf9; +} + +.history-view .history-row.duplicate { + background: #fff9f7; + box-shadow: inset 3px 0 var(--red); +} + +.history-view .history-row.duplicate:hover { + background: #fdf1ed; +} + +.history-view .history-status { + display: grid; + width: 36px; + height: 36px; + place-items: center; + border: 1px solid #c9ddd5; + border-radius: 6px; + color: var(--green); + background: var(--history-green-soft); +} + +.history-view .history-status svg { + width: 18px; + height: 18px; + stroke-width: 2.25; +} + +.history-view .duplicate .history-status { + border-color: #e8beb8; + color: var(--red); + background: var(--red-soft); +} + +.history-view .history-value { + min-width: 0; +} + +.history-view .history-value strong, +.history-view .history-value span { + display: block; +} + +.history-view .history-value strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--ink); + font: 700 13px/1.35 "IBM Plex Mono", "Courier New", monospace; +} + +.history-view .history-value span { + width: max-content; + max-width: 100%; + margin-top: 5px; + padding: 3px 6px; + overflow: hidden; + border-radius: 4px; + color: #66716d; + background: var(--history-soft); + font-size: 8px; + font-weight: 900; + letter-spacing: .04em; + text-overflow: ellipsis; + text-transform: uppercase; + white-space: nowrap; +} + +.history-view .history-meta { + min-width: 126px; + align-content: center; + gap: 6px; +} + +.history-view .status-label { + justify-self: end; + width: max-content; + padding: 4px 7px; + border-radius: 999px; + color: var(--green); + background: var(--history-green-soft); + font-size: 8px; + font-weight: 900; + letter-spacing: .05em; +} + +.history-view .duplicate .status-label { + color: var(--red); + background: var(--red-soft); +} + +.history-view .history-meta time { + color: #7a8581; + font-size: 9px; + white-space: nowrap; +} + +.history-view .swipe-menu { + display: grid; + width: 36px; + height: 36px; + place-items: center; + padding: 0; + border: 0; + border-radius: 5px; + color: #77817d; + background: transparent; +} + +.history-view .swipe-menu:hover, +.history-view .swipe-menu[aria-expanded='true'] { + color: var(--ink); + background: var(--history-soft); +} + +.history-view .swipe-menu svg { + width: 18px; + height: 18px; +} + +.history-view .history-action { + transition: filter .15s ease; +} + +.history-view .history-action:hover { + filter: brightness(.92); +} + +.history-view .empty-state { + min-height: 260px; + padding: 42px 20px; + border: 0; + background: white; +} + +.history-view .empty-state > svg { + width: 34px; + height: 34px; + color: var(--green); +} + +.history-view .empty-state strong { + margin-top: 12px; + font-family: Georgia, serif; + font-size: 21px; + font-weight: 500; +} + +.history-view .empty-state span { + margin-top: 5px; + color: var(--history-muted); + font-size: 10px; +} + +.history-view .undo-toast svg { + width: 14px; + height: 14px; +} + +.history-view .undo-toast button { + display: inline-flex; + align-items: center; + gap: 5px; +} + +@media (max-width: 760px) { + .history-view { + padding-top: calc(20px + env(safe-area-inset-top)); + } + + .history-view .view-heading { + padding-bottom: 16px; + } + + .history-view .view-actions { + display: grid; + grid-template-columns: 1fr 1fr; + width: 100%; + gap: 8px; + } + + .history-view .view-actions .quiet-button { + min-height: 40px; + padding: 0 10px; + font-size: 10px; + } + + .history-view .metric-strip { + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 7px; + margin-bottom: 13px; + } + + .history-view .metric-strip > div { + display: block; + min-height: 68px; + padding: 11px 10px; + border: 1px solid var(--line); + } + + .history-view .metric-strip > div:last-child { + border-bottom: 1px solid var(--line); + } + + .history-view .metric-strip strong { + font-size: 23px; + } + + .history-view .metric-strip span { + margin-top: 5px; + font-size: 7px; + line-height: 1.2; + } + + .history-view .history-row { + grid-template-columns: 34px minmax(0, 1fr) 32px; + gap: 10px; + min-height: 76px; + padding: 11px 10px; + } + + .history-view .history-status { + width: 32px; + height: 32px; + } + + .history-view .history-status svg { + width: 16px; + height: 16px; + } + + .history-view .history-value strong { + font-size: 11px; + } + + .history-view .history-value span { + margin-top: 4px; + font-size: 7px; + } + + .history-view .history-meta { + grid-column: 2 / 3; + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-top: -3px; + } + + .history-view .status-label { + justify-self: auto; + flex: 0 0 auto; + padding: 3px 6px; + font-size: 7px; + } + + .history-view .history-meta time { + min-width: 0; + overflow: hidden; + font-size: 8px; + text-overflow: ellipsis; + } + + .history-view .swipe-menu { + grid-column: 3; + grid-row: 1 / span 2; + align-self: center; + width: 32px; + height: 36px; + } + + .history-view .empty-state { + min-height: 220px; + } +} + +@media (max-width: 390px) { + .history-view .metric-strip > div { + padding: 10px 7px; + } + + .history-view .metric-strip strong { + font-size: 21px; + } + + .history-view .metric-strip span { + font-size: 6.5px; + } + + .history-view .history-meta time { + max-width: 145px; + } +} diff --git a/packages/fieldlens-core/src/features/history/HistoryView.tsx b/packages/fieldlens-core/src/features/history/HistoryView.tsx new file mode 100644 index 0000000..540018c --- /dev/null +++ b/packages/fieldlens-core/src/features/history/HistoryView.tsx @@ -0,0 +1,166 @@ +import { useLiveQuery } from 'dexie-react-hooks' +import { AlertTriangle, Barcode, Check, CircleCheck, Copy, Download, MoreHorizontal, RotateCcw, Trash2 } from 'lucide-react' +import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react' + +import { clearScanHistory, countDistinctScanValues, database, deleteScan, restoreScan, serializeScanHistory, type ScanRecord } from '../../lib/database' +import './HistoryView.css' + +const ACTION_WIDTH = 148 + +function HistoryRow({ scan, isOpen, setOpen, onDelete }: { scan: ScanRecord; isOpen: boolean; setOpen: (open: boolean) => void; onDelete: () => void }) { + const pointerStartRef = useRef<{ x: number; y: number; offset: number } | null>(null) + const horizontalDragRef = useRef(null) + const copyTimerRef = useRef(null) + const [dragOffset, setDragOffset] = useState(null) + const [copied, setCopied] = useState(false) + const offset = dragOffset ?? (isOpen ? -ACTION_WIDTH : 0) + + useEffect(() => () => { + if (copyTimerRef.current) window.clearTimeout(copyTimerRef.current) + }, []) + + const startDrag = (event: ReactPointerEvent) => { + if (event.button !== 0 || (event.target as HTMLElement).closest('button')) return + event.currentTarget.setPointerCapture(event.pointerId) + pointerStartRef.current = { x: event.clientX, y: event.clientY, offset: isOpen ? -ACTION_WIDTH : 0 } + horizontalDragRef.current = null + } + + const moveDrag = (event: ReactPointerEvent) => { + const start = pointerStartRef.current + if (!start) return + const deltaX = event.clientX - start.x + const deltaY = event.clientY - start.y + if (horizontalDragRef.current === null && Math.max(Math.abs(deltaX), Math.abs(deltaY)) > 6) { + horizontalDragRef.current = Math.abs(deltaX) > Math.abs(deltaY) + } + if (!horizontalDragRef.current) return + event.preventDefault() + setDragOffset(Math.min(0, Math.max(-ACTION_WIDTH, start.offset + deltaX))) + } + + const finishDrag = (event: ReactPointerEvent) => { + if (horizontalDragRef.current) setOpen(offset < -48) + setDragOffset(null) + pointerStartRef.current = null + horizontalDragRef.current = null + if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId) + } + + const copyValue = async () => { + await navigator.clipboard.writeText(scan.rawValue) + setCopied(true) + setOpen(false) + if (copyTimerRef.current) window.clearTimeout(copyTimerRef.current) + copyTimerRef.current = window.setTimeout(() => setCopied(false), 1400) + } + + return ( +
+
+ + +
+
+
{scan.isDuplicate ? : }
+
+ {scan.rawValue} + {scan.format} +
+
+ {scan.isDuplicate ? 'DUPLICATE' : 'UNIQUE'} + +
+ +
+
+ ) +} + +export function HistoryView() { + const scans = useLiveQuery(() => database.scans.orderBy('scannedAt').reverse().toArray(), []) ?? [] + const duplicates = scans.filter((scan) => scan.isDuplicate).length + const uniqueValues = countDistinctScanValues(scans) + const [openScanId, setOpenScanId] = useState(null) + const [deletedScan, setDeletedScan] = useState(null) + + useEffect(() => { + if (!deletedScan) return + const timer = window.setTimeout(() => setDeletedScan(null), 5000) + return () => window.clearTimeout(timer) + }, [deletedScan]) + + const removeScan = (scan: ScanRecord) => { + setOpenScanId(null) + setDeletedScan(scan) + void deleteScan(scan.id) + } + + const undoDelete = () => { + if (!deletedScan) return + void restoreScan(deletedScan) + setDeletedScan(null) + } + + const exportHistory = () => { + const url = URL.createObjectURL(new Blob([serializeScanHistory(scans)], { type: 'application/json' })) + const link = document.createElement('a') + link.href = url + link.download = `fieldlens-scans-${new Date().toISOString().slice(0, 10)}.json` + link.click() + URL.revokeObjectURL(url) + } + + return ( +
+
+
+ DEVICE REGISTER +

Capture history

+

Records stay available offline in this browser.

+
+
+ + +
+
+ +
+
{scans.length}Total captures
+
{duplicates}Duplicates flagged
+
{uniqueValues}Unique values
+
+ +
+ {!scans.length &&
No scans recordedOpen Scanner to capture the first code.
} + {scans.map((scan) => ( + setOpenScanId(open ? scan.id : null)} onDelete={() => removeScan(scan)} key={scan.id} /> + ))} +
+ + {deletedScan && ( +
+ Scan deleted + +
+ )} +
+ ) +} diff --git a/packages/fieldlens-core/src/features/label-check/ArPalletOverlay.css b/packages/fieldlens-core/src/features/label-check/ArPalletOverlay.css new file mode 100644 index 0000000..edf6806 --- /dev/null +++ b/packages/fieldlens-core/src/features/label-check/ArPalletOverlay.css @@ -0,0 +1,51 @@ +.ar-overlay { position: absolute; z-index: 5; inset: 0; pointer-events: none; overflow: hidden; } +.ar-overlay-canvas { position: absolute; inset: 0; width: 100%; height: 100%; } + +.ar-overlay-hud { position: absolute; top: 12px; left: 12px; display: grid; gap: 2px; min-width: 126px; max-width: 195px; padding: 8px 10px; border: 1px solid rgba(255,255,255,.24); border-radius: 6px; color: white; background: rgba(23,33,31,.7); backdrop-filter: blur(8px); box-shadow: 0 4px 18px rgba(0,0,0,.16); } +.ar-mode-badge { color: var(--yellow); font-size: 7px; font-weight: 900; letter-spacing: .08em; } +.ar-overlay-hud strong { font: 800 17px/1.1 "IBM Plex Mono", "Courier New", monospace; } +.ar-overlay-hud small { color: #d5ddda; font-size: 7px; line-height: 1.35; } + +.ar-detection-readout { position: absolute; top: 12px; left: 50%; transform: translateX(-50%); display: grid; justify-items: center; gap: 2px; max-width: min(42%, 250px); padding: 8px 10px; border: 1px solid rgba(255,255,255,.22); border-radius: 6px; color: white; background: rgba(23,33,31,.78); backdrop-filter: blur(8px); box-shadow: 0 4px 18px rgba(0,0,0,.16); } +.ar-detection-readout strong { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 800 11px/1.1 "IBM Plex Mono", "Courier New", monospace; } +.ar-detection-readout span { font-size: 7px; font-weight: 900; letter-spacing: .04em; text-transform: uppercase; } +.ar-detection-readout.candidate span { color: var(--yellow); } +.ar-detection-readout.accepted span { color: #66cf9d; } +.ar-detection-readout.invalid span { color: #ed736a; } + +.ar-memory-map { position: absolute; top: 12px; right: 12px; display: grid; gap: 4px; width: 82px; padding: 6px; border: 1px solid rgba(255,255,255,.22); border-radius: 6px; color: white; background: rgba(23,33,31,.68); backdrop-filter: blur(8px); box-shadow: 0 4px 18px rgba(0,0,0,.16); } +.ar-memory-map > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 6px; font-weight: 900; letter-spacing: .05em; text-align: center; } +.ar-memory-map > small { color: #d5ddda; font-size: 7px; text-align: center; } +.ar-memory-field { position: relative; height: 90px; overflow: hidden; border: 1px solid rgba(255,255,255,.16); border-radius: 3px; background-color: rgba(255,255,255,.035); background-image: linear-gradient(rgba(255,255,255,.08) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.08) 1px, transparent 1px); background-size: 12.5% 10%; } +.ar-memory-field i { position: absolute; width: 5px; height: 5px; transform: translate(-50%, -50%); border: 1px solid rgba(255,255,255,.7); border-radius: 50%; background: #66cf9d; box-shadow: 0 0 0 2px rgba(102,207,157,.16); } + +.ar-unavailable { position: absolute; top: 12px; right: 12px; max-width: 210px; padding: 8px 10px; border-radius: 5px; color: white; background: rgba(23,33,31,.78); font-size: 8px; line-height: 1.35; } + +.ean-setup-card .ean-custom-row { grid-template-columns: auto minmax(150px, 190px) auto; justify-content: center; } +.ean-setup-card .ean-custom-row select { width: 100%; min-height: 40px; padding: 0 9px; border: 1px solid var(--line); border-radius: 5px; color: var(--ink); background: white; font-size: 11px; font-weight: 700; } +.ean-setup-card .ean-custom-row .primary-button { min-height: 40px; white-space: nowrap; } + +.ean-ar-toggle { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 14px; width: min(100%, 452px); margin-top: 2px; padding: 11px 13px; border: 1px solid var(--line); border-radius: 6px; background: var(--paper); text-align: left; cursor: pointer; transition: border-color .16s ease, background .16s ease; } +.ean-ar-toggle:hover { border-color: #b7beb8; background: white; } +.ean-ar-toggle input { position: relative; order: 2; width: 40px; height: 22px; margin: 0; appearance: none; border: 1px solid #aeb6b1; border-radius: 999px; background: #dce0db; cursor: pointer; transition: background .16s ease, border-color .16s ease; } +.ean-ar-toggle input::after { content: ''; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: white; box-shadow: 0 1px 3px rgba(0,0,0,.22); transition: transform .16s ease; } +.ean-ar-toggle input:checked { border-color: var(--green); background: var(--green); } +.ean-ar-toggle input:checked::after { transform: translateX(18px); } +.ean-ar-toggle input:focus-visible { outline: 3px solid var(--green); outline-offset: 2px; } +.ean-ar-toggle span { min-width: 0; } +.ean-ar-toggle strong, .ean-ar-toggle small { display: block; } +.ean-ar-toggle strong { font-size: 10px; } +.ean-ar-toggle small { margin-top: 3px; color: #74807b; font-size: 8px; line-height: 1.35; } + +@media (max-width: 760px) { + .ar-overlay-hud { top: 9px; left: 9px; min-width: 116px; max-width: 155px; padding: 7px 8px; } + .ar-overlay-hud strong { font-size: 15px; } + .ar-memory-map { top: 9px; right: 9px; width: 68px; padding: 5px; } + .ar-memory-field { height: 70px; } + .ar-memory-field i { width: 4px; height: 4px; } + .ar-detection-readout { top: 72px; left: 50%; width: calc(100% - 188px); min-width: 138px; max-width: 220px; } + .ean-setup-card .ean-custom-row { grid-template-columns: minmax(0, 1fr) auto; width: 100%; } + .ean-setup-card .ean-custom-row label { grid-column: 1 / -1; } + .ean-setup-card .ean-custom-row select { min-width: 0; } + .ean-ar-toggle { width: 100%; } +} diff --git a/packages/fieldlens-core/src/features/label-check/ArPalletOverlay.tsx b/packages/fieldlens-core/src/features/label-check/ArPalletOverlay.tsx new file mode 100644 index 0000000..f6fb54b --- /dev/null +++ b/packages/fieldlens-core/src/features/label-check/ArPalletOverlay.tsx @@ -0,0 +1,308 @@ +import { useEffect, useRef, useState } from 'react' +import * as THREE from 'three' + +import './ArPalletOverlay.css' + +export type ArDetectionState = 'candidate' | 'accepted' | 'invalid' + +export type ArDetection = { + ean: string + state: ArDetectionState + sourceWidth: number + sourceHeight: number + box: { + x: number + y: number + width: number + height: number + } +} + +type ArRememberedPosition = { + scanNumber: number + ean: string + sourceWidth: number + sourceHeight: number + box: ArDetection['box'] +} + +type PointLike = { + getX?: () => number + getY?: () => number + x?: number + y?: number +} + +function pointCoordinate(point: PointLike, axis: 'x' | 'y') { + const getter = axis === 'x' ? point.getX : point.getY + const fallback = axis === 'x' ? point.x : point.y + return typeof getter === 'function' ? getter.call(point) : typeof fallback === 'number' ? fallback : 0 +} + +export function createArDetection( + ean: string, + state: ArDetectionState, + points: PointLike[] | undefined, + sourceWidth: number, + sourceHeight: number, +): ArDetection | null { + if (!sourceWidth || !sourceHeight || !points?.length) return null + + const xs = points.map((point) => pointCoordinate(point, 'x')) + const ys = points.map((point) => pointCoordinate(point, 'y')) + const minX = Math.min(...xs) + const maxX = Math.max(...xs) + const minY = Math.min(...ys) + const maxY = Math.max(...ys) + const rawWidth = Math.max(1, maxX - minX) + const rawHeight = Math.max(1, maxY - minY) + const width = Math.max(rawWidth + sourceWidth * 0.04, sourceWidth * 0.18) + const height = Math.max(rawHeight + sourceHeight * 0.035, sourceHeight * 0.09) + const centerX = (minX + maxX) / 2 + const centerY = (minY + maxY) / 2 + const x = Math.max(0, Math.min(sourceWidth - width, centerX - width / 2)) + const y = Math.max(0, Math.min(sourceHeight - height, centerY - height / 2)) + + return { + ean, + state, + sourceWidth, + sourceHeight, + box: { x, y, width, height }, + } +} + +const STATUS_COLOR: Record = { + candidate: 0xf4d35e, + accepted: 0x66cf9d, + invalid: 0xed736a, +} + +function projectBox( + item: Pick, + hostWidth: number, + hostHeight: number, +) { + const scale = Math.max(hostWidth / item.sourceWidth, hostHeight / item.sourceHeight) + const renderedWidth = item.sourceWidth * scale + const renderedHeight = item.sourceHeight * scale + const offsetX = (hostWidth - renderedWidth) / 2 + const offsetY = (hostHeight - renderedHeight) / 2 + const left = offsetX + item.box.x * scale + const top = offsetY + item.box.y * scale + return { + left, + top, + right: left + item.box.width * scale, + bottom: top + item.box.height * scale, + } +} + +function lineVertices(item: Pick, hostWidth: number, hostHeight: number) { + const { left, top, right, bottom } = projectBox(item, hostWidth, hostHeight) + return new Float32Array([ + left, hostHeight - top, 0, + right, hostHeight - top, 0, + right, hostHeight - bottom, 0, + left, hostHeight - bottom, 0, + ]) +} + +export function ArPalletOverlay({ + enabled, + detection, + scanned, + target, + uniqueEans, +}: { + enabled: boolean + detection: ArDetection | null + scanned: number + target: number + uniqueEans: number +}) { + const hostRef = useRef(null) + const canvasRef = useRef(null) + const rendererRef = useRef(null) + const sceneRef = useRef(null) + const cameraRef = useRef(null) + const lineRef = useRef(null) + const materialRef = useRef(null) + const memoryGroupRef = useRef(null) + const lastScannedRef = useRef(0) + const [available, setAvailable] = useState(true) + const [remembered, setRemembered] = useState([]) + + useEffect(() => { + if (scanned === 0) { + lastScannedRef.current = 0 + setRemembered([]) + return + } + + if (scanned < lastScannedRef.current) { + lastScannedRef.current = scanned + setRemembered((current) => current.filter((position) => position.scanNumber <= scanned)) + return + } + + if (scanned > lastScannedRef.current && detection?.state === 'accepted') { + const next: ArRememberedPosition = { + scanNumber: scanned, + ean: detection.ean, + sourceWidth: detection.sourceWidth, + sourceHeight: detection.sourceHeight, + box: { ...detection.box }, + } + lastScannedRef.current = scanned + setRemembered((current) => [...current.filter((position) => position.scanNumber < scanned), next]) + return + } + + lastScannedRef.current = scanned + }, [detection, scanned]) + + useEffect(() => { + if (!enabled) return + const host = hostRef.current + const canvas = canvasRef.current + if (!host || !canvas) return + + try { + const renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true }) + renderer.setClearColor(0x000000, 0) + renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)) + const scene = new THREE.Scene() + const camera = new THREE.OrthographicCamera(0, 1, 1, 0, -10, 10) + camera.position.z = 1 + const geometry = new THREE.BufferGeometry() + const material = new THREE.LineBasicMaterial({ color: STATUS_COLOR.candidate, transparent: true, opacity: 0.98 }) + const line = new THREE.LineLoop(geometry, material) + const memoryGroup = new THREE.Group() + line.visible = false + scene.add(memoryGroup) + scene.add(line) + + rendererRef.current = renderer + sceneRef.current = scene + cameraRef.current = camera + lineRef.current = line + materialRef.current = material + memoryGroupRef.current = memoryGroup + setAvailable(true) + + const render = () => { + const width = Math.max(1, host.clientWidth) + const height = Math.max(1, host.clientHeight) + renderer.setSize(width, height, false) + camera.left = 0 + camera.right = width + camera.top = height + camera.bottom = 0 + camera.updateProjectionMatrix() + renderer.render(scene, camera) + } + + const resizeObserver = new ResizeObserver(render) + resizeObserver.observe(host) + render() + + return () => { + resizeObserver.disconnect() + geometry.dispose() + material.dispose() + for (const child of [...memoryGroup.children]) { + memoryGroup.remove(child) + if (child instanceof THREE.LineLoop) { + child.geometry.dispose() + if (child.material instanceof THREE.Material) child.material.dispose() + } + } + renderer.dispose() + rendererRef.current = null + sceneRef.current = null + cameraRef.current = null + lineRef.current = null + materialRef.current = null + memoryGroupRef.current = null + } + } catch { + setAvailable(false) + } + }, [enabled]) + + useEffect(() => { + if (!enabled || !available) return + const host = hostRef.current + const renderer = rendererRef.current + const scene = sceneRef.current + const camera = cameraRef.current + const line = lineRef.current + const material = materialRef.current + const memoryGroup = memoryGroupRef.current + if (!host || !renderer || !scene || !camera || !line || !material || !memoryGroup) return + + const hostWidth = Math.max(1, host.clientWidth) + const hostHeight = Math.max(1, host.clientHeight) + + for (const child of [...memoryGroup.children]) { + memoryGroup.remove(child) + if (child instanceof THREE.LineLoop) { + child.geometry.dispose() + if (child.material instanceof THREE.Material) child.material.dispose() + } + } + + for (const position of remembered) { + const geometry = new THREE.BufferGeometry() + geometry.setAttribute('position', new THREE.BufferAttribute(lineVertices(position, hostWidth, hostHeight), 3)) + const rememberedMaterial = new THREE.LineBasicMaterial({ color: STATUS_COLOR.accepted, transparent: true, opacity: 0.34 }) + memoryGroup.add(new THREE.LineLoop(geometry, rememberedMaterial)) + } + + if (!detection) { + line.visible = false + renderer.render(scene, camera) + return + } + + line.geometry.setAttribute('position', new THREE.BufferAttribute(lineVertices(detection, hostWidth, hostHeight), 3)) + line.geometry.computeBoundingSphere() + material.color.setHex(STATUS_COLOR[detection.state]) + line.visible = true + renderer.render(scene, camera) + }, [available, detection, enabled, remembered]) + + if (!enabled) return null + + return ( + + ) +} diff --git a/packages/fieldlens-core/src/features/label-check/CartonConfigPicker.css b/packages/fieldlens-core/src/features/label-check/CartonConfigPicker.css new file mode 100644 index 0000000..42ee81f --- /dev/null +++ b/packages/fieldlens-core/src/features/label-check/CartonConfigPicker.css @@ -0,0 +1,16 @@ +.ean-config-picker { display: grid; width: min(100%, 452px); gap: 8px; text-align: left; } +.ean-config-picker > label { color: #66716d; font-size: 9px; font-weight: 900; text-transform: uppercase; } +.ean-config-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; } +.ean-config-row .compact-input { width: 100%; min-width: 0; max-width: none; } +.ean-config-row .primary-button { white-space: nowrap; } +.ean-config-editor { display: grid; grid-template-columns: minmax(0, 1fr) 110px auto; gap: 8px; padding: 10px; border: 1px solid var(--line); border-radius: 6px; background: var(--paper); } +.ean-config-editor .compact-input { width: 100%; min-width: 0; max-width: none; } +.ean-config-editor .quiet-button { white-space: nowrap; } +.ean-config-note { color: #6f7b76; font-size: 9px; line-height: 1.35; } + +@media (max-width: 520px) { + .ean-config-row { grid-template-columns: 1fr; } + .ean-config-row .primary-button { width: 100%; } + .ean-config-editor { grid-template-columns: minmax(0, 1fr) 92px; } + .ean-config-editor .quiet-button { grid-column: 1 / -1; width: 100%; } +} diff --git a/packages/fieldlens-core/src/features/label-check/CartonConfigPicker.tsx b/packages/fieldlens-core/src/features/label-check/CartonConfigPicker.tsx new file mode 100644 index 0000000..d443883 --- /dev/null +++ b/packages/fieldlens-core/src/features/label-check/CartonConfigPicker.tsx @@ -0,0 +1,108 @@ +import { useMemo, useState } from 'react' + +import { loadCartonConfigs, upsertCartonConfig } from '../../lib/cartonConfigs' +import './CartonConfigPicker.css' + +const PRESETS = [ + { value: 160, label: '160 · 8 × 20' }, + { value: 176, label: '176 · 8 × 22' }, +] as const + +function initialSelection(value: number, saved: ReturnType) { + const preset = PRESETS.find((option) => option.value === value) + if (preset) return `preset:${preset.value}` + const config = saved.find((option) => option.count === value) + return config ? `saved:${config.id}` : 'custom' +} + +export function CartonConfigPicker({ + value, + onChange, + onStart, +}: { + value: number + onChange: (count: number) => void + onStart: () => void +}) { + const [savedConfigs, setSavedConfigs] = useState(loadCartonConfigs) + const [selection, setSelection] = useState(() => initialSelection(value, loadCartonConfigs())) + const [name, setName] = useState('') + const [message, setMessage] = useState('') + const custom = selection === 'custom' + + const selectedSavedConfig = useMemo( + () => selection.startsWith('saved:') ? savedConfigs.find((config) => `saved:${config.id}` === selection) : undefined, + [savedConfigs, selection], + ) + + const choose = (nextSelection: string) => { + setSelection(nextSelection) + setMessage('') + if (nextSelection === 'custom') { + onChange(0) + return + } + if (nextSelection.startsWith('preset:')) { + onChange(Number(nextSelection.slice('preset:'.length))) + return + } + const config = savedConfigs.find((candidate) => `saved:${candidate.id}` === nextSelection) + if (config) onChange(config.count) + } + + const saveCustom = () => { + const cleanName = name.trim() + if (!cleanName || value < 1 || value > 500) { + setMessage('Add a name and carton count first.') + return + } + const saved = upsertCartonConfig(savedConfigs, cleanName, value) + setSavedConfigs(saved.configs) + setSelection(`saved:${saved.config.id}`) + setName('') + setMessage(`${saved.config.name} saved on this device.`) + } + + return ( +
+ +
+ + +
+ + {custom && ( +
+ setName(event.target.value)} + /> + onChange(Number(event.target.value))} + /> + +
+ )} + + {!custom && selectedSavedConfig && Saved locally · {selectedSavedConfig.count} cartons} + {message && {message}} +
+ ) +} diff --git a/packages/fieldlens-core/src/features/label-check/CartonLabelScan.css b/packages/fieldlens-core/src/features/label-check/CartonLabelScan.css new file mode 100644 index 0000000..31ccd72 --- /dev/null +++ b/packages/fieldlens-core/src/features/label-check/CartonLabelScan.css @@ -0,0 +1,276 @@ +.label-stepper-three { grid-template-columns: repeat(3, 1fr); } + +.carton-section-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + padding: 12px 14px; + border-bottom: 1px solid var(--line); + background: white; +} +.carton-section-head > div { min-width: 0; } +.carton-section-head span, +.carton-section-actions span { + display: block; + color: #73807b; + font-size: 8px; + font-weight: 900; + text-transform: uppercase; +} +.carton-section-head strong { display: block; margin-top: 2px; font-size: 14px; } +.carton-section-head small, +.carton-section-actions small { display: block; margin-top: 3px; color: #78837f; font-size: 8px; line-height: 1.35; } +.carton-section-head .quiet-button { flex: 0 0 auto; min-height: 38px; } + +.carton-batch-stats { grid-template-columns: repeat(4, minmax(0,1fr)); } +.carton-batch-stats > div:nth-child(3) { display: block; } + +.carton-label-camera { min-height: 540px; } +.carton-batch-camera { overflow: hidden; background: #17211f; } +.carton-batch-camera video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; } +.carton-video-coordinate-space { + position: absolute; + z-index: 5; + pointer-events: none; + transform-origin: 0 0; +} + +.carton-batch-box { + position: absolute; + z-index: 5; + min-width: 18px; + min-height: 9px; + border: 2px solid rgba(244,211,94,.95); + border-radius: 4px; + box-shadow: 0 0 0 1px rgba(0,0,0,.22), 0 4px 12px rgba(0,0,0,.16); + pointer-events: none; + transition: left .12s linear, top .12s linear, width .12s linear, height .12s linear, border-color .12s ease, background .12s ease; +} +.carton-batch-box > span { + position: absolute; + right: -2px; + top: -18px; + min-width: 18px; + height: 16px; + padding: 0 4px; + border-radius: 3px 3px 0 0; + color: #17211f; + background: var(--yellow); + font: 900 8px/16px "IBM Plex Mono", "Courier New", monospace; + text-align: center; +} +.carton-batch-box.queued { border-style: dashed; background: rgba(244,211,94,.07); } +.carton-batch-box.reading { border-width: 3px; background: rgba(244,211,94,.12); } +.carton-batch-box.accepted { border-color: #58d3a7; background: rgba(39,153,112,.1); } +.carton-batch-box.accepted > span { color: white; background: #208763; } +.carton-batch-box.accepted.remembered { + border-style: dashed; + border-color: rgba(88,211,167,.78); + background: rgba(39,153,112,.055); + box-shadow: 0 0 0 1px rgba(12,69,52,.16); + opacity: .72; +} +.carton-batch-box.accepted.remembered > span { opacity: .86; } +.carton-batch-box.retry { border-color: #f0bd56; background: rgba(240,189,86,.12); } +.carton-batch-box.retry > span { background: #f0bd56; } +.carton-batch-box.ignored { border-color: rgba(184,194,190,.7); border-style: dashed; background: rgba(135,148,143,.08); opacity: .66; } +.carton-batch-box.ignored > span { color: white; background: rgba(91,104,99,.9); } + +.carton-batch-instruction { + position: absolute; + z-index: 6; + top: 13px; + left: 50%; + display: grid; + max-width: calc(100% - 28px); + padding: 8px 11px; + transform: translateX(-50%); + border: 1px solid rgba(255,255,255,.16); + border-radius: 6px; + color: white; + background: rgba(18,29,26,.76); + backdrop-filter: blur(8px); + text-align: center; + pointer-events: none; +} +.carton-batch-instruction > span { color: var(--yellow); font-size: 8px; font-weight: 900; text-transform: uppercase; } +.carton-batch-instruction strong { margin-top: 2px; font-size: 12px; } +.carton-batch-instruction small { margin-top: 2px; color: #d6dfdc; font-size: 8px; line-height: 1.35; } +.carton-batch-instruction em { + justify-self: center; + margin-top: 5px; + padding: 2px 6px; + border: 1px solid rgba(88,211,167,.28); + border-radius: 999px; + color: #9de8cf; + background: rgba(13,63,49,.5); + font-size: 6.5px; + font-style: normal; + font-weight: 900; + letter-spacing: .035em; + text-transform: uppercase; +} + +.carton-batch-ocr-status { + position: absolute; + z-index: 8; + left: 12px; + bottom: 12px; + width: min(230px, calc(100% - 24px)); + padding: 8px 9px; + border: 1px solid rgba(255,255,255,.18); + border-radius: 6px; + color: white; + background: rgba(17,29,25,.84); + backdrop-filter: blur(8px); +} +.carton-batch-ocr-status strong, +.carton-batch-ocr-status > span { display: block; } +.carton-batch-ocr-status strong { font-size: 9px; } +.carton-batch-ocr-status > span { margin: 3px 0 7px; color: #cbd6d2; font-size: 8px; } +.carton-batch-ocr-status .label-progress { width: 100%; } + +.carton-live-fields { + position: absolute; + z-index: 7; + top: 70px; + right: 10px; + width: min(34%, 230px); + max-height: 118px; + overflow: hidden; + border: 1px solid rgba(255,255,255,.2); + border-radius: 6px; + background: rgba(17,29,25,.8); + box-shadow: 0 8px 22px rgba(0,0,0,.18); + backdrop-filter: blur(7px); + color: white; +} +.carton-live-fields > span { display: block; padding: 6px 8px; border-bottom: 1px solid rgba(255,255,255,.12); color: #c5d0cc; font-size: 6.5px; font-weight: 900; text-transform: uppercase; } +.carton-live-fields > div { display: grid; grid-template-columns: 52px minmax(0,1fr); gap: 6px; align-items: center; padding: 4px 8px; border-top: 1px solid rgba(255,255,255,.08); } +.carton-live-fields > div:first-of-type { border-top: 0; } +.carton-live-fields b { color: #aebbb6; font-size: 6.5px; text-transform: uppercase; } +.carton-live-fields strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 700 8.5px/1.2 "IBM Plex Mono", "Courier New", monospace; } + +.carton-section-actions { + display: grid; + grid-template-columns: minmax(0,1fr) auto auto; + align-items: center; + gap: 9px; + padding: 11px 13px; + border-top: 1px solid var(--line); + background: white; +} +.carton-section-actions > div { min-width: 0; } +.carton-section-actions .primary-button, +.carton-section-actions .quiet-button { min-height: 42px; white-space: nowrap; } + +.carton-label-camera.scanner-starting::before { + content: ''; + position: absolute; + z-index: 10; + inset: 0; + background: rgba(18,27,25,.62); +} +.carton-label-camera.scanner-starting::after { + content: 'Preparing camera…'; + position: absolute; + z-index: 11; + top: 50%; + left: 50%; + padding: 12px 15px; + transform: translate(-50%, -50%); + border: 1px solid rgba(255,255,255,.2); + border-radius: 6px; + color: white; + background: rgba(23,33,31,.92); + font-size: 10px; + font-weight: 900; + white-space: nowrap; +} + +.carton-final-summary { grid-template-columns: repeat(4, 1fr); } +.carton-majority-fields { border-top: 1px solid var(--line); } +.carton-majority-head, +.carton-majority-fields > div:not(.carton-majority-head) { display: grid; grid-template-columns: 150px minmax(0,1fr) 100px; gap: 12px; align-items: center; padding: 10px 16px; } +.carton-majority-head { color: #6f7b76; background: var(--paper); font-size: 8px; font-weight: 900; text-transform: uppercase; } +.carton-majority-fields > div:not(.carton-majority-head) { min-height: 46px; border-top: 1px solid var(--line); font-size: 10px; } +.carton-majority-fields strong { font-size: 10px; } +.carton-majority-fields b { justify-self: end; padding: 4px 7px; border-radius: 999px; background: var(--paper); font-size: 8px; } + +.carton-issues { border-top: 1px solid var(--line); } +.carton-issues > header { display: flex; justify-content: space-between; gap: 12px; padding: 11px 16px; background: #fff5c8; } +.carton-issues > header strong { font-size: 10px; } +.carton-issues > header span { color: #6d5a13; font-size: 8px; font-weight: 900; } +.carton-issues > div { display: grid; grid-template-columns: 82px 100px 90px 90px minmax(0,1fr) 44px minmax(0,1fr); gap: 8px; align-items: center; min-height: 48px; padding: 8px 16px; border-top: 1px solid var(--line); font-size: 9px; } +.carton-issues > div.mismatch { background: #fff8f7; } +.carton-issues > div.unread { background: #fffdf1; } +.carton-issues > div > span { font-weight: 900; } +.carton-issues > div > em { color: #697570; font-size: 8px; font-style: normal; } +.carton-issues > div > small { color: #77817d; font-size: 7px; font-weight: 900; text-transform: uppercase; } +.carton-issues > div > b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 700 9px/1.2 "IBM Plex Mono", "Courier New", monospace; } +.carton-issues > p { margin: 0; padding: 10px 16px; color: #697570; font-size: 9px; } + +.carton-conformity-card.review { border-top: 5px solid var(--yellow); } +.carton-conformity-card.review .label-result-icon { color: var(--ink); background: var(--yellow); } + +@media (max-width: 760px) { + .label-stepper-three, + .label-stepper.label-stepper-three { grid-template-columns: repeat(3, minmax(0,1fr)); } + + /* Feature CSS is lazy-loaded: own the standalone iOS safe-area after LabelCheckView.css. */ + @supports selector(:has(*)) { + .carton-label-view:has(.carton-label-scan-panel) { + width: 100%; + max-width: none; + min-height: 100dvh; + padding: env(safe-area-inset-top) 0 0 !important; + gap: 0; + background: var(--ink); + } + .carton-label-view:has(.carton-label-scan-panel) .label-check-heading, + .carton-label-view:has(.carton-label-scan-panel) .label-stepper { display: none; } + .carton-label-view:has(.carton-label-scan-panel) .carton-label-scan-panel { border: 0; border-radius: 0; } + } + + .carton-section-head { padding: 9px 10px; } + .carton-section-head strong { font-size: 12px; } + .carton-section-head small { max-width: 250px; font-size: 7px; } + .carton-section-head .quiet-button { min-height: 34px; padding: 0 9px; font-size: 9px; } + + .carton-batch-stats { grid-template-columns: repeat(4, minmax(0,1fr)); } + .carton-batch-stats > div { padding: 7px 6px; } + .carton-batch-stats span { font-size: 6px; } + .carton-batch-stats strong { font-size: 11px; } + + .carton-label-camera { + min-height: max(410px, calc(100dvh - env(safe-area-inset-top) - env(safe-area-inset-bottom) - 286px)) !important; + } + .carton-batch-instruction { top: 9px; max-width: calc(100% - 18px); } + .carton-batch-instruction strong { font-size: 11px; } + .carton-batch-instruction small { font-size: 7px; } + .carton-batch-instruction em { margin-top: 4px; font-size: 5.8px; } + .carton-live-fields { top: 68px; right: 8px; width: 40%; max-height: 102px; } + .carton-live-fields > span { padding: 5px 6px; font-size: 6px; } + .carton-live-fields > div { grid-template-columns: 42px minmax(0,1fr); gap: 4px; padding: 3px 6px; } + .carton-live-fields b { font-size: 6px; } + .carton-live-fields strong { font-size: 7.5px; } + .carton-batch-ocr-status { left: 8px; bottom: 8px; width: min(210px, calc(100% - 16px)); padding: 7px 8px; } + + .carton-section-actions { grid-template-columns: 1fr auto; gap: 7px; padding: 9px 10px calc(9px + env(safe-area-inset-bottom)); } + .carton-section-actions > div { grid-column: 1 / -1; } + .carton-section-actions .primary-button { min-height: 44px; } + .carton-section-actions .quiet-button { min-height: 44px; padding: 0 10px; } + + .carton-final-summary { grid-template-columns: 1fr 1fr; } + .carton-majority-head { display: none; } + .carton-majority-fields > div:not(.carton-majority-head) { grid-template-columns: 92px minmax(0,1fr) auto; padding: 9px 12px; } + .carton-majority-fields b { font-size: 7px; } + + .carton-issues > div { grid-template-columns: 72px minmax(0,1fr); gap: 4px 8px; padding: 9px 12px; } + .carton-issues > div > span, + .carton-issues > div > em, + .carton-issues > div > small { grid-column: 1; } + .carton-issues > div > strong, + .carton-issues > div > b { grid-column: 2; } +} diff --git a/packages/fieldlens-core/src/features/label-check/LabelCheckView.css b/packages/fieldlens-core/src/features/label-check/LabelCheckView.css new file mode 100644 index 0000000..ab37554 --- /dev/null +++ b/packages/fieldlens-core/src/features/label-check/LabelCheckView.css @@ -0,0 +1,172 @@ +.label-check-view { display: grid; gap: 18px; } +.label-check-heading { margin-bottom: 0; } +.label-check-heading .view-actions { align-self: center; } + +.label-stepper { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; margin: 0; padding: 0; overflow: hidden; list-style: none; border: 1px solid var(--line); border-radius: 6px; background: var(--line); } +.label-stepper-five { grid-template-columns: repeat(5, 1fr); } +.label-stepper li { display: flex; align-items: center; gap: 8px; min-width: 0; min-height: 46px; padding: 0 12px; color: #7a8581; background: white; font-size: 9px; font-weight: 900; text-transform: uppercase; } +.label-stepper li span { display: grid; flex: 0 0 auto; width: 22px; height: 22px; place-items: center; border: 1px solid var(--line); border-radius: 50%; background: var(--paper); font-size: 9px; } +.label-stepper li.active { color: var(--ink); box-shadow: inset 0 -3px var(--yellow); } +.label-stepper li.done span { color: white; background: var(--green); border-color: var(--green); } + +.label-scan-panel, .label-capture-card, .label-processing-card, .label-result-card, .label-setup-card, .ean-setup-card, .ean-conformity-card { overflow: hidden; border: 1px solid var(--line); border-radius: 7px; background: white; } + +.ean-setup-card { display: grid; justify-items: center; gap: 13px; padding: 28px; text-align: center; } +.ean-setup-card > svg { color: var(--green); } +.ean-setup-card h2 { margin: 0; font-family: Georgia, serif; font-size: 28px; font-weight: 500; } +.ean-setup-card p { max-width: 610px; margin: 0; color: #68746f; font-size: 12px; line-height: 1.5; } +.ean-preset-grid { display: grid; width: min(100%, 452px); grid-template-columns: repeat(2, 1fr); gap: 10px; margin-top: 5px; } +.ean-preset-grid button { display: grid; height: 74px; align-content: center; gap: 3px; } +.ean-preset-grid strong { font-size: 24px; line-height: 1; } +.ean-preset-grid span { font-size: 9px; } +.ean-custom-row { display: grid; width: min(100%, 452px); grid-template-columns: 1fr 92px auto; align-items: center; gap: 8px; } +.ean-custom-row label { color: #66716d; font-size: 9px; font-weight: 900; text-align: left; text-transform: uppercase; } +.ean-custom-row input { width: 100%; min-height: 40px; padding: 0 9px; border: 1px solid var(--line); border-radius: 5px; font: 700 13px/1 "IBM Plex Mono", "Courier New", monospace; } +.ean-custom-row .quiet-button { min-height: 40px; } + +.ean-session-strip { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)) auto; gap: 1px; background: var(--line); } +.ean-session-strip > div { min-width: 0; padding: 11px 13px; background: white; } +.ean-session-strip span, .ean-distribution span, .ean-final-summary span { display: block; color: #74807b; font-size: 8px; font-weight: 900; text-transform: uppercase; } +.ean-session-strip strong { display: block; margin-top: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 700 14px/1.2 "IBM Plex Mono", "Courier New", monospace; } +.ean-session-strip button { align-self: center; min-height: 38px; margin: 7px; } +.ean-session-strip button:disabled { opacity: .45; } +.ean-progress-track { height: 6px; overflow: hidden; background: #e6e9e4; } +.ean-progress-track span { display: block; height: 100%; background: var(--yellow); transition: width .18s ease; } + +.label-camera-stage { position: relative; min-height: 500px; overflow: hidden; background: #202c29; isolation: isolate; } +.label-camera-stage video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; } +.label-camera-shade { position: absolute; z-index: 2; inset: 0; background: linear-gradient(180deg, rgba(23,33,31,.52), transparent 24%, transparent 68%, rgba(23,33,31,.72)); pointer-events: none; } +.label-scan-frame { position: absolute; z-index: 3; top: 50%; left: 50%; width: min(76%, 620px); height: 170px; transform: translate(-50%, -50%); } +.label-scan-frame span { position: absolute; width: 48px; height: 48px; border-color: var(--yellow); border-style: solid; } +.label-scan-frame span:nth-child(1) { left: 0; top: 0; border-width: 4px 0 0 4px; } +.label-scan-frame span:nth-child(2) { right: 0; top: 0; border-width: 4px 4px 0 0; } +.label-scan-frame span:nth-child(3) { left: 0; bottom: 0; border-width: 0 0 4px 4px; } +.label-scan-frame span:nth-child(4) { right: 0; bottom: 0; border-width: 0 4px 4px 0; } +.label-camera-copy { position: absolute; z-index: 6; left: 24px; right: 24px; bottom: 20px; display: grid; justify-items: center; gap: 4px; color: white; text-align: center; pointer-events: none; } +.label-camera-copy svg { width: 18px; color: var(--yellow); } +.label-camera-copy strong { font-size: 14px; } +.label-camera-copy small { max-width: 520px; color: #d7dfdc; font-size: 10px; line-height: 1.4; } + +.ean-live-warning { display: flex; gap: 10px; align-items: center; padding: 11px 14px; color: #6d5a13; background: #fff5c8; border-top: 1px solid #eadb91; font-size: 10px; } +.ean-live-warning svg, .label-error svg { flex: 0 0 auto; width: 16px; } +.label-error { display: flex; align-items: center; gap: 10px; padding: 11px 14px; color: #7f2924; background: var(--red-soft); border-top: 1px solid #ecc4bd; font-size: 10px; } +.label-error button { margin-left: auto; border: 0; color: #7f2924; background: transparent; font-weight: 900; } + +.label-capture-card, .label-processing-card { display: grid; justify-items: center; gap: 13px; padding: 30px; text-align: center; } +.label-capture-card > svg, .label-processing-card > svg { color: var(--green); } +.label-capture-card h2, .label-processing-card h2, .label-result-card h2, .ean-conformity-card h2 { margin: 0; font-family: Georgia, serif; font-size: 28px; font-weight: 500; } +.label-capture-card p, .label-processing-card p, .label-result-card p, .ean-conformity-card p { max-width: 620px; margin: 0; color: #68746f; font-size: 12px; line-height: 1.5; } +.label-ean-chip { display: inline-flex; align-items: center; gap: 8px; padding: 8px 11px; border: 1px solid var(--line); border-radius: 999px; background: var(--paper); } +.label-ean-chip span { color: #74807b; font-size: 8px; font-weight: 900; } +.label-ean-chip strong { font: 700 11px/1 "IBM Plex Mono", "Courier New", monospace; } +.label-photo-button { cursor: pointer; } +.label-photo-button input { display: none; } +.label-photo-preview { width: min(100%, 620px); max-height: 420px; object-fit: contain; border: 1px solid var(--line); border-radius: 6px; background: #eef0eb; } +.label-progress { width: min(100%, 520px); height: 7px; overflow: hidden; border-radius: 999px; background: #e6e9e4; } +.label-progress span { display: block; height: 100%; background: var(--yellow); transition: width .2s ease; } + +.label-result-card.pass, .ean-conformity-card.pass { border-top: 5px solid var(--green); } +.label-result-card.fail, .ean-conformity-card.fail { border-top: 5px solid var(--red); } +.label-result-card.review, .label-result-card.not-found { border-top: 5px solid var(--yellow); } +.label-result-heading { display: grid; grid-template-columns: 48px 1fr; gap: 15px; padding: 20px; align-items: start; } +.label-result-icon { display: grid; place-items: center; width: 46px; height: 46px; border-radius: 6px; background: var(--paper); } +.pass .label-result-icon { color: white; background: var(--green); } +.fail .label-result-icon { color: white; background: var(--red); } +.review .label-result-icon, .not-found .label-result-icon { color: var(--ink); background: var(--yellow); } + +.ean-distribution { border-top: 1px solid var(--line); } +.ean-distribution > div { display: grid; grid-template-columns: 130px minmax(0, 1fr) auto; align-items: center; gap: 12px; min-height: 54px; padding: 9px 16px; border-top: 1px solid var(--line); } +.ean-distribution > div:first-child { border-top: 0; } +.ean-distribution strong { overflow: hidden; text-overflow: ellipsis; font: 700 13px/1.2 "IBM Plex Mono", "Courier New", monospace; } +.ean-distribution b { padding: 4px 7px; border-radius: 999px; background: var(--paper); font-size: 9px; white-space: nowrap; } +.ean-distribution .consensus { background: #f8f8f2; } +.ean-ocr-choice { display: grid; grid-template-columns: 1fr minmax(220px, 330px); align-items: center; gap: 16px; padding: 14px 16px; border-top: 1px solid var(--line); } +.ean-ocr-choice strong, .ean-ocr-choice small { display: block; } +.ean-ocr-choice strong { font-size: 11px; } +.ean-ocr-choice small { margin-top: 3px; color: #75807b; font-size: 9px; line-height: 1.35; } +.ean-ocr-choice select { min-height: 40px; width: 100%; padding: 0 9px; border: 1px solid var(--line); border-radius: 5px; background: white; } + +.label-comparison-table { border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); } +.label-comparison-head, .label-comparison-row { display: grid; grid-template-columns: 1fr 1.2fr 1.2fr .8fr; gap: 12px; align-items: center; padding: 11px 16px; } +.label-comparison-head { color: #6f7b76; background: var(--paper); font-size: 8px; font-weight: 900; text-transform: uppercase; } +.label-comparison-row { min-height: 48px; border-top: 1px solid var(--line); font-size: 10px; } +.label-comparison-row:first-of-type { border-top: 0; } +.label-comparison-row b { justify-self: start; padding: 4px 7px; border-radius: 999px; font-size: 8px; text-transform: uppercase; } +.label-comparison-row.match b { color: #1e654f; background: #e4f3ec; } +.label-comparison-row.mismatch b { color: #8e2c27; background: var(--red-soft); } +.label-comparison-row.unread b { color: #6d5a13; background: #fff5c8; } + +.ean-final-summary { display: grid; grid-template-columns: repeat(4, 1fr); border-top: 1px solid var(--line); background: var(--paper); } +.ean-final-summary span { padding: 12px 14px; border-right: 1px solid var(--line); } +.ean-final-summary span:last-child { border-right: 0; } +.ean-final-summary strong { display: block; margin-top: 3px; color: var(--ink); font-size: 11px; } +.label-result-meta { display: flex; flex-wrap: wrap; gap: 14px; padding: 12px 20px; color: #697570; font-size: 9px; } +.label-result-meta strong { color: var(--ink); } +.label-ocr-details { margin: 0 20px 16px; border: 1px solid var(--line); border-radius: 5px; } +.label-ocr-details summary { padding: 10px 11px; cursor: pointer; font-size: 9px; font-weight: 900; } +.label-ocr-details pre { margin: 0; padding: 12px; overflow: auto; border-top: 1px solid var(--line); background: var(--paper); white-space: pre-wrap; font-size: 9px; line-height: 1.45; } +.label-result-actions { display: flex; justify-content: flex-end; gap: 8px; padding: 16px 20px 20px; } +.label-setup-card { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 16px; padding: 20px; } +.label-setup-card strong { font-size: 13px; } +.label-setup-card p { margin: 4px 0 0; color: #68746f; font-size: 10px; } + +@media (max-width: 760px) { + .label-check-view { gap: 12px; } + .label-check-heading { gap: 10px; } + .label-check-heading h1 { font-size: 32px; } + .label-check-heading p { font-size: 11px; line-height: 1.4; } + .label-check-heading .view-actions { display: flex; flex-wrap: wrap; } + .label-check-heading .quiet-button { width: auto; min-height: 38px; padding: 0 11px; } + + .label-stepper, .label-stepper-five { grid-template-columns: repeat(5, minmax(0, 1fr)); } + .label-stepper li { display: grid; justify-items: center; justify-content: stretch; min-width: 0; min-height: 44px; gap: 3px; padding: 5px 2px; font-size: 7px; text-align: center; } + .label-stepper li span { width: 19px; height: 19px; font-size: 8px; } + + .ean-setup-card { gap: 12px; padding: 22px 16px; } + .ean-setup-card h2 { font-size: 25px; } + .ean-setup-card p { font-size: 11px; } + .ean-preset-grid { width: 100%; } + .ean-preset-grid button { height: 68px; } + .ean-custom-row { width: 100%; grid-template-columns: 82px 1fr; } + .ean-custom-row label { grid-column: 1 / -1; } + .ean-custom-row .quiet-button { padding: 0 9px; } + + .ean-session-strip { grid-template-columns: 1fr 1fr auto; } + .ean-session-strip > div { padding: 9px 10px; } + .ean-session-strip > div:nth-child(3) { display: none; } + .ean-session-strip strong { font-size: 13px; } + .ean-session-strip button { margin: 5px; padding: 0 9px; font-size: 9px; } + .label-camera-stage { min-height: 480px; } + .label-scan-frame { width: 86%; height: 145px; } + .label-camera-copy { left: 18px; right: 18px; bottom: 16px; } + .label-camera-copy strong { font-size: 13px; } + .label-camera-copy small { font-size: 9px; } + + .label-capture-card, .label-processing-card { padding: 22px 16px; } + .label-capture-card h2, .label-processing-card h2, .label-result-card h2, .ean-conformity-card h2 { font-size: 25px; } + .ean-distribution > div { grid-template-columns: minmax(0, 1fr) auto; } + .ean-distribution span { grid-column: 1 / -1; } + .ean-ocr-choice { grid-template-columns: 1fr; } + .ean-final-summary { grid-template-columns: 1fr 1fr; } + .ean-final-summary span:nth-child(2) { border-right: 0; } + .ean-final-summary span:nth-child(-n+2) { border-bottom: 1px solid var(--line); } + .label-comparison-head { display: none; } + .label-comparison-row { grid-template-columns: 1fr 1fr; gap: 6px 10px; } + .label-comparison-row strong { grid-column: 1 / -1; } + .label-comparison-row b { justify-self: end; } + .label-result-heading { grid-template-columns: 42px 1fr; padding: 16px; } + .label-result-icon { width: 40px; height: 40px; } + .label-result-meta { padding: 10px 16px; } + .label-ocr-details { margin: 0 16px 14px; } + .label-result-actions { display: grid; grid-template-columns: 1fr; padding: 14px 16px 18px; } + .label-result-actions .primary-button, .label-result-actions .quiet-button { width: 100%; } + .label-setup-card { grid-template-columns: 1fr; justify-items: start; } + + @supports selector(:has(*)) { + .label-check-view:has(.label-scan-panel) { max-width: none; padding: 0; gap: 0; } + .label-check-view:has(.label-scan-panel) .label-check-heading, + .label-check-view:has(.label-scan-panel) .label-stepper { display: none; } + .label-check-view:has(.label-scan-panel) .label-scan-panel { border: 0; border-radius: 0; } + .label-check-view:has(.label-scan-panel) .label-camera-stage { min-height: calc(100dvh - 70px - env(safe-area-inset-bottom) - 92px); } + } +} diff --git a/packages/fieldlens-core/src/features/label-check/LabelCheckView.tsx b/packages/fieldlens-core/src/features/label-check/LabelCheckView.tsx new file mode 100644 index 0000000..d716de2 --- /dev/null +++ b/packages/fieldlens-core/src/features/label-check/LabelCheckView.tsx @@ -0,0 +1,920 @@ +import { AlertTriangle, ArrowDown, CheckCircle2, Pause, Play, RefreshCw, RotateCcw, ScanLine, Settings, XCircle } from 'lucide-react' +import { useEffect, useMemo, useRef, useState } from 'react' + +import { + boxIntersectsVideo, + estimateSectionHomography, + getCoverCoordinateSpace, + IDENTITY_HOMOGRAPHY, + invertHomography, + projectBox, + type CoverCoordinateSpace, + type Homography, +} from '../../lib/cartonArTracking' +import { + finishPalletLabelSession, + savePalletLabelScans, + startPalletLabelSession, +} from '../../lib/database' +import { + createCartonLabelScan, + MIN_RECOGNIZED_LABEL_FIELDS, + summarizeCartonLabels, + type CartonLabelScan, + type CartonLabelSection, +} from '../../lib/cartonLabelConformity' +import { + boxCenterDistance, + boxIou, + boxSizeDifference, + cropDetectedLabel, + detectWhiteLabelBoxes, + type LabelBox, + type LabelDetection, +} from '../../lib/cartonLabelDetection' +import { associateLabelDetections } from '../../lib/cartonTrackAssociation' +import { + hasCartonLabelIdentity, + LABEL_FIELD_KEYS, + LABEL_FIELD_LABELS, + recognizeLabel, + warmLabelOcr, + type LabelFields, +} from '../../lib/labelOcr' +import { CartonConfigPicker } from './CartonConfigPicker' +import './LabelCheckView.css' +import './CartonLabelScan.css' + +type Step = 'setup' | 'scan' | 'result' +type TrackStatus = 'tracking' | 'queued' | 'reading' | 'accepted' | 'retry' | 'ignored' + +type LabelTrack = { + id: string + box: LabelBox + anchorBox: LabelBox + stableFrames: number + missedFrames: number + status: TrackStatus + retryAfter: number + recognizedFields?: number + memoryId?: string +} + +type RememberedAnchor = { + id: string + anchorBox: LabelBox + labelNumber: number +} + +type OcrQueueItem = { + trackId: string + anchorBox: LabelBox + section: CartonLabelSection + sessionId: string + image: Blob +} + +const CARTON_COUNT_STORAGE_KEY = 'fieldlens-ean-carton-count' +const DEFAULT_CARTON_COUNT = 160 +const DETECTION_INTERVAL_MS = 360 +const MIN_STABLE_FRAMES = 2 +const MAX_MISSED_FRAMES = 4 +const OCR_RETRY_DELAY_MS = 1600 +const OCR_CONCURRENCY = 2 +const MAX_OCR_QUEUE = 8 +const STEP_INDEX: Record = { + setup: 0, + scan: 1, + result: 2, +} + +function loadCartonCount() { + try { + const stored = Number(localStorage.getItem(CARTON_COUNT_STORAGE_KEY)) + return Number.isFinite(stored) && stored >= 1 && stored <= 500 ? Math.round(stored) : DEFAULT_CARTON_COUNT + } catch { + return DEFAULT_CARTON_COUNT + } +} + +function saveCartonCount(count: number) { + try { + localStorage.setItem(CARTON_COUNT_STORAGE_KEY, String(count)) + } catch { + // The picker remains usable if browser storage is unavailable. + } +} + +function visibleFields(fields?: LabelFields) { + if (!fields) return [] + return LABEL_FIELD_KEYS + .filter((field) => fields[field].trim()) + .map((field) => ({ field, label: LABEL_FIELD_LABELS[field], value: fields[field] })) +} + +function sectionKey(section: CartonLabelSection) { + return `${section.face}-${section.half}` +} + +function sectionLabel(section: CartonLabelSection) { + return `Face ${section.face} · ${section.half === 'top' ? 'Top half' : 'Bottom half'}` +} + +function blendBox(previous: LabelBox, next: LabelBox): LabelBox { + const nextWeight = 0.46 + const previousWeight = 1 - nextWeight + return { + x: previous.x * previousWeight + next.x * nextWeight, + y: previous.y * previousWeight + next.y * nextWeight, + width: previous.width * previousWeight + next.width * nextWeight, + height: previous.height * previousWeight + next.height * nextWeight, + } +} + +function samePhysicalAnchor(left: LabelBox, right: LabelBox) { + const sizeDifference = boxSizeDifference(left, right) + if (sizeDifference > 0.46) return false + const iou = boxIou(left, right) + if (iou >= 0.34) return true + const distance = boxCenterDistance(left, right) + const scale = Math.max(0.018, Math.min(0.052, Math.min(left.width, right.width) * 0.32)) + return distance <= scale +} + +export function LabelCheckView({ onOpenSettings }: { onOpenSettings: () => void }) { + const videoRef = useRef(null) + const stageRef = useRef(null) + const streamRef = useRef(null) + const labelsRef = useRef([]) + const sessionIdRef = useRef('') + const tracksRef = useRef([]) + const trackCounterRef = useRef(0) + const queueRef = useRef([]) + const activeOcrWorkersRef = useRef(0) + const activeTrackIdsRef = useRef(new Set()) + const acceptedAnchorsRef = useRef(new Map()) + const homographyRef = useRef([...IDENTITY_HOMOGRAPHY]) + const sectionRef = useRef({ face: 1, half: 'top' }) + const sectionStartCountRef = useRef(0) + const targetCountRef = useRef(loadCartonCount()) + const finishedRef = useRef(false) + + const [step, setStep] = useState('setup') + const [targetCount, setTargetCount] = useState(loadCartonCount) + const [labels, setLabels] = useState([]) + const [tracks, setTracks] = useState([]) + const [acceptedAnchors, setAcceptedAnchors] = useState([]) + const [homography, setHomography] = useState([...IDENTITY_HOMOGRAPHY]) + const [anchorLandmarks, setAnchorLandmarks] = useState(0) + const [anchorMode, setAnchorMode] = useState<'idle' | 'translation' | 'homography'>('idle') + const [videoSpace, setVideoSpace] = useState({ left: 0, top: 0, width: 0, height: 0 }) + const [section, setSection] = useState({ face: 1, half: 'top' }) + const [sectionStartCount, setSectionStartCount] = useState(0) + const [cameraReady, setCameraReady] = useState(false) + const [cameraError, setCameraError] = useState('') + const [retryKey, setRetryKey] = useState(0) + const [scanPaused, setScanPaused] = useState(false) + const [queueCount, setQueueCount] = useState(0) + const [activeOcrCount, setActiveOcrCount] = useState(0) + const [, setProcessingTrackId] = useState('') + const [progress, setProgress] = useState(0) + const [progressLabel, setProgressLabel] = useState('Preparing OCR') + const [error, setError] = useState('') + const [lastRead, setLastRead] = useState(null) + const [arEnabled, setArEnabled] = useState(true) + + const summary = useMemo(() => summarizeCartonLabels(labels), [labels]) + const currentStepIndex = STEP_INDEX[step] + const lastFields = visibleFields(lastRead?.fields) + const sectionRead = labels.length - sectionStartCount + const sectionGuide = Math.max(1, Math.round(targetCount / 4)) + const visibleTracks = tracks.filter((track) => track.missedFrames === 0) + const retryTracks = visibleTracks.filter((track) => track.status === 'retry').length + const ignoredTracks = visibleTracks.filter((track) => track.status === 'ignored').length + const visibleMemoryIds = new Set(visibleTracks.map((track) => track.memoryId).filter((value): value is string => Boolean(value))) + const rememberedBoxes = acceptedAnchors + .filter((anchor) => !visibleMemoryIds.has(anchor.id)) + .map((anchor) => ({ ...anchor, box: projectBox(homography, anchor.anchorBox) })) + .filter((anchor) => boxIntersectsVideo(anchor.box)) + + const syncTracks = (next: LabelTrack[]) => { + tracksRef.current = next + setTracks(next) + } + + const syncAcceptedAnchors = () => { + setAcceptedAnchors([...acceptedAnchorsRef.current.values()]) + } + + const updateTrack = (trackId: string, update: (track: LabelTrack) => LabelTrack) => { + const next = tracksRef.current.map((track) => track.id === trackId ? update(track) : track) + syncTracks(next) + } + + const updateOcrActivity = () => { + setActiveOcrCount(activeOcrWorkersRef.current) + setProcessingTrackId(activeTrackIdsRef.current.values().next().value ?? '') + } + + const findRememberedAnchor = (anchorBox: LabelBox) => { + return [...acceptedAnchorsRef.current.values()] + .filter((anchor) => samePhysicalAnchor(anchor.anchorBox, anchorBox)) + .sort((left, right) => { + const leftDistance = boxCenterDistance(left.anchorBox, anchorBox) + const rightDistance = boxCenterDistance(right.anchorBox, anchorBox) + if (Math.abs(leftDistance - rightDistance) > 0.002) return leftDistance - rightDistance + return boxSizeDifference(left.anchorBox, anchorBox) - boxSizeDifference(right.anchorBox, anchorBox) + })[0] + } + + const finishPallet = (nextLabels: CartonLabelScan[]) => { + if (finishedRef.current) return + finishedRef.current = true + queueRef.current = [] + setQueueCount(0) + const nextSummary = summarizeCartonLabels(nextLabels) + if (sessionIdRef.current) { + void finishPalletLabelSession(sessionIdRef.current, { + conforms: nextSummary.conforms, + resultStatus: nextSummary.status, + mismatchCartons: nextSummary.mismatchCartons, + majorityFields: nextSummary.majorityFields, + }).catch(() => undefined) + } + setStep('result') + } + + const processOcrItem = async (item: OcrQueueItem) => { + activeTrackIdsRef.current.add(item.trackId) + updateOcrActivity() + + try { + const track = tracksRef.current.find((candidate) => candidate.id === item.trackId) + if (!track || track.status === 'accepted' || track.status === 'ignored') return + + updateTrack(item.trackId, (candidate) => ({ ...candidate, status: 'reading' })) + setProcessingTrackId(item.trackId) + setProgress(0) + setProgressLabel('Reading detected label') + + const ocr = await recognizeLabel(item.image, (nextProgress, status) => { + setProcessingTrackId(item.trackId) + setProgress(nextProgress) + setProgressLabel(status) + }) + + if ( + finishedRef.current + || item.sessionId !== sessionIdRef.current + || sectionKey(item.section) !== sectionKey(sectionRef.current) + ) return + + const scan = createCartonLabelScan(ocr, item.section, item.trackId) + + if (!hasCartonLabelIdentity(ocr)) { + updateTrack(item.trackId, (candidate) => ({ + ...candidate, + status: 'ignored', + stableFrames: 0, + retryAfter: Number.POSITIVE_INFINITY, + recognizedFields: 0, + })) + setProgress(100) + setProgressLabel('Ignored · no valid PPIN or grower number') + return + } + + setLastRead(scan) + + if (scan.recognizedFields < MIN_RECOGNIZED_LABEL_FIELDS) { + updateTrack(item.trackId, (candidate) => ({ + ...candidate, + status: 'retry', + stableFrames: 0, + retryAfter: Date.now() + OCR_RETRY_DELAY_MS, + recognizedFields: scan.recognizedFields, + })) + return + } + + if (labelsRef.current.length >= targetCountRef.current) return + + const currentTrack = tracksRef.current.find((candidate) => candidate.id === item.trackId) + const anchorBox = currentTrack?.anchorBox ?? item.anchorBox + const rememberedDuplicate = findRememberedAnchor(anchorBox) + if (rememberedDuplicate && rememberedDuplicate.id !== item.trackId) { + updateTrack(item.trackId, (candidate) => ({ + ...candidate, + status: 'accepted', + memoryId: rememberedDuplicate.id, + recognizedFields: scan.recognizedFields, + })) + setProgress(100) + setProgressLabel('Already counted · AR position memory') + return + } + + updateTrack(item.trackId, (candidate) => ({ + ...candidate, + status: 'accepted', + memoryId: item.trackId, + recognizedFields: scan.recognizedFields, + })) + + const nextLabels = [...labelsRef.current, scan] + acceptedAnchorsRef.current.set(item.trackId, { + id: item.trackId, + anchorBox, + labelNumber: nextLabels.length, + }) + syncAcceptedAnchors() + labelsRef.current = nextLabels + setLabels(nextLabels) + setProgress(100) + setProgressLabel(`${scan.recognizedFields} validated fields accepted`) + navigator.vibrate?.(45) + + if (sessionIdRef.current) { + void savePalletLabelScans(sessionIdRef.current, nextLabels).catch(() => { + setError('Local persistence failed. This pallet remains available only in the current screen.') + }) + } + + if (nextLabels.length >= targetCountRef.current) finishPallet(nextLabels) + } catch (readError) { + if (item.sessionId === sessionIdRef.current && sectionKey(item.section) === sectionKey(sectionRef.current)) { + updateTrack(item.trackId, (candidate) => ({ + ...candidate, + status: 'retry', + stableFrames: 0, + retryAfter: Date.now() + OCR_RETRY_DELAY_MS, + })) + setError(readError instanceof Error ? readError.message : 'A detected carton label could not be read.') + } + } finally { + activeTrackIdsRef.current.delete(item.trackId) + activeOcrWorkersRef.current = Math.max(0, activeOcrWorkersRef.current - 1) + updateOcrActivity() + setQueueCount(queueRef.current.length) + pumpOcrQueue() + } + } + + const pumpOcrQueue = () => { + if (finishedRef.current) return + + while (activeOcrWorkersRef.current < OCR_CONCURRENCY && queueRef.current.length > 0) { + const item = queueRef.current.shift() + if (!item) break + activeOcrWorkersRef.current += 1 + setQueueCount(queueRef.current.length) + void processOcrItem(item) + } + updateOcrActivity() + } + + const captureAndQueue = async (track: LabelTrack) => { + const video = videoRef.current + if (!video || track.status !== 'tracking' && track.status !== 'retry') return + if (queueRef.current.length + activeOcrWorkersRef.current >= MAX_OCR_QUEUE) return + const currentSection = sectionRef.current + const expectedSectionKey = sectionKey(currentSection) + const expectedSessionId = sessionIdRef.current + + updateTrack(track.id, (candidate) => ({ ...candidate, status: 'queued' })) + + try { + const image = await cropDetectedLabel(video, track.box) + if ( + sectionKey(sectionRef.current) !== expectedSectionKey + || sessionIdRef.current !== expectedSessionId + || finishedRef.current + ) return + queueRef.current.push({ + trackId: track.id, + anchorBox: track.anchorBox, + section: currentSection, + sessionId: expectedSessionId, + image, + }) + setQueueCount(queueRef.current.length) + pumpOcrQueue() + } catch { + updateTrack(track.id, (candidate) => ({ + ...candidate, + status: 'retry', + stableFrames: 0, + retryAfter: Date.now() + OCR_RETRY_DELAY_MS, + })) + } + } + + const reconcileDetections = (detections: LabelDetection[]) => { + const previous = tracksRef.current + const association = associateLabelDetections(previous, detections, homographyRef.current) + const matchedTracks: LabelTrack[] = [] + const correspondences: { anchor: LabelBox; current: LabelBox }[] = [] + + association.matches.forEach((match) => { + const previousTrack = previous[match.trackIndex] + const detection = detections[match.detectionIndex] + const motion = boxCenterDistance(match.expectedBox, detection.box) + const stableMotion = Math.max( + 0.018, + Math.min(0.04, Math.hypot(match.expectedBox.width, match.expectedBox.height) * 0.35), + ) + + matchedTracks.push({ + ...previousTrack, + box: blendBox(previousTrack.box, detection.box), + stableFrames: motion <= stableMotion ? previousTrack.stableFrames + 1 : 1, + missedFrames: 0, + }) + + const reliableForPlane = + previousTrack.status !== 'ignored' + && previousTrack.stableFrames >= MIN_STABLE_FRAMES + && (match.iou >= 0.2 || match.normalizedDistance <= 0.55) + + if (reliableForPlane) { + correspondences.push({ anchor: previousTrack.anchorBox, current: detection.box }) + } + }) + + const estimate = estimateSectionHomography(correspondences, homographyRef.current) + homographyRef.current = estimate.matrix + setHomography(estimate.matrix) + setAnchorLandmarks(estimate.landmarks) + setAnchorMode(estimate.mode) + const inverse = invertHomography(estimate.matrix) + + const nextTracks = [...matchedTracks] + + association.unmatchedDetectionIndices.forEach((detectionIndex) => { + const detection = detections[detectionIndex] + trackCounterRef.current += 1 + const anchorBox = projectBox(inverse, detection.box) + const remembered = findRememberedAnchor(anchorBox) + nextTracks.push({ + id: `label-${trackCounterRef.current}`, + box: detection.box, + anchorBox, + stableFrames: 1, + missedFrames: 0, + status: remembered ? 'accepted' : 'tracking', + retryAfter: 0, + memoryId: remembered?.id, + }) + }) + + association.unmatchedTrackIndices.forEach((index) => { + const track = previous[index] + const missedFrames = track.missedFrames + 1 + if (missedFrames <= MAX_MISSED_FRAMES) nextTracks.push({ ...track, missedFrames }) + }) + + syncTracks(nextTracks) + + const availableQueueSlots = Math.max(0, MAX_OCR_QUEUE - queueRef.current.length - activeOcrWorkersRef.current) + nextTracks + .filter((track) => + track.missedFrames === 0 + && track.stableFrames >= MIN_STABLE_FRAMES + && (track.status === 'tracking' || track.status === 'retry') + && Date.now() >= track.retryAfter, + ) + .sort((left, right) => right.box.width * right.box.height - left.box.width * left.box.height) + .slice(0, Math.min(4, availableQueueSlots)) + .forEach((track) => void captureAndQueue(track)) + } + + useEffect(() => { + if (step !== 'scan') return + void warmLabelOcr().catch(() => undefined) + }, [step]) + + useEffect(() => { + if (step !== 'scan') return + let disposed = false + + const stopCamera = () => { + streamRef.current?.getTracks().forEach((track) => track.stop()) + streamRef.current = null + if (videoRef.current) videoRef.current.srcObject = null + } + + const startCamera = async () => { + stopCamera() + setCameraReady(false) + setCameraError('') + + const stream = await navigator.mediaDevices.getUserMedia({ + audio: false, + video: { + facingMode: { ideal: 'environment' }, + width: { ideal: 1920 }, + height: { ideal: 1080 }, + }, + }) + + if (disposed) { + stream.getTracks().forEach((track) => track.stop()) + return + } + + streamRef.current = stream + const video = videoRef.current + if (!video) return + video.srcObject = stream + await video.play() + if (!disposed) setCameraReady(true) + } + + void startCamera().catch((cameraStartError: unknown) => { + if (!disposed) { + setCameraError(cameraStartError instanceof Error ? cameraStartError.message : 'Camera access failed.') + setCameraReady(false) + } + }) + + return () => { + disposed = true + stopCamera() + } + }, [retryKey, step]) + + useEffect(() => { + if (step !== 'scan' || !cameraReady) return + const video = videoRef.current + const stage = stageRef.current + if (!video || !stage) return + + const updateSpace = () => { + setVideoSpace(getCoverCoordinateSpace( + video.videoWidth, + video.videoHeight, + stage.clientWidth, + stage.clientHeight, + )) + } + + updateSpace() + const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(updateSpace) + observer?.observe(stage) + video.addEventListener('loadedmetadata', updateSpace) + window.addEventListener('resize', updateSpace) + window.addEventListener('orientationchange', updateSpace) + + return () => { + observer?.disconnect() + video.removeEventListener('loadedmetadata', updateSpace) + window.removeEventListener('resize', updateSpace) + window.removeEventListener('orientationchange', updateSpace) + } + }, [cameraReady, step]) + + useEffect(() => { + if (step !== 'scan' || !cameraReady || scanPaused || finishedRef.current) return + + const timer = window.setInterval(() => { + const video = videoRef.current + if (!video) return + reconcileDetections(detectWhiteLabelBoxes(video)) + }, DETECTION_INTERVAL_MS) + + return () => window.clearInterval(timer) + }, [cameraReady, scanPaused, step]) + + const changeCartonCount = (count: number) => { + const nextCount = Math.max(0, Math.min(500, Math.round(count))) + setTargetCount(nextCount) + targetCountRef.current = nextCount + if (nextCount > 0) saveCartonCount(nextCount) + } + + const clearSectionTracking = () => { + queueRef.current = [] + setQueueCount(0) + syncTracks([]) + trackCounterRef.current = 0 + acceptedAnchorsRef.current.clear() + syncAcceptedAnchors() + const identity = [...IDENTITY_HOMOGRAPHY] as Homography + homographyRef.current = identity + setHomography(identity) + setAnchorLandmarks(0) + setAnchorMode('idle') + setProgress(0) + setProgressLabel('Preparing OCR') + setError('') + } + + const startSession = async () => { + if (!Number.isFinite(targetCount) || targetCount < 1 || targetCount > 500) { + setError('Enter a carton count between 1 and 500.') + return + } + + const safeCount = Math.round(targetCount) + setError('') + setLastRead(null) + setTargetCount(safeCount) + targetCountRef.current = safeCount + saveCartonCount(safeCount) + finishedRef.current = false + labelsRef.current = [] + setLabels([]) + sectionRef.current = { face: 1, half: 'top' } + setSection(sectionRef.current) + sectionStartCountRef.current = 0 + setSectionStartCount(0) + setScanPaused(false) + clearSectionTracking() + void warmLabelOcr().catch(() => undefined) + + try { + const session = await startPalletLabelSession(safeCount) + sessionIdRef.current = session.id + } catch { + setError('Could not create the local pallet record. Check browser storage and try again.') + return + } + + setStep('scan') + setRetryKey((value) => value + 1) + } + + const reset = () => { + finishedRef.current = false + labelsRef.current = [] + sessionIdRef.current = '' + queueRef.current = [] + setQueueCount(0) + setLabels([]) + setLastRead(null) + setCameraReady(false) + setCameraError('') + setScanPaused(false) + setProcessingTrackId('') + sectionRef.current = { face: 1, half: 'top' } + setSection(sectionRef.current) + sectionStartCountRef.current = 0 + setSectionStartCount(0) + clearSectionTracking() + setStep('setup') + } + + const undoLastScan = () => { + if (labelsRef.current.length === 0 || activeOcrCount > 0 || queueCount > 0) return + const removed = labelsRef.current.at(-1) + const next = labelsRef.current.slice(0, -1) + labelsRef.current = next + setLabels(next) + setLastRead(next.at(-1) ?? null) + + if (removed?.trackId) { + acceptedAnchorsRef.current.delete(removed.trackId) + syncAcceptedAnchors() + updateTrack(removed.trackId, (track) => ({ + ...track, + status: 'tracking', + memoryId: undefined, + recognizedFields: undefined, + stableFrames: 0, + retryAfter: Date.now() + 500, + })) + } + + if (sessionIdRef.current) void savePalletLabelScans(sessionIdRef.current, next).catch(() => undefined) + setError('Last accepted carton removed.') + } + + const nextSection = () => { + if (activeOcrCount > 0 || queueCount > 0) return + const nextSectionValue: CartonLabelSection = section.half === 'top' + ? { face: section.face, half: 'bottom' } + : { face: section.face + 1, half: 'top' } + + sectionRef.current = nextSectionValue + setSection(nextSectionValue) + sectionStartCountRef.current = labelsRef.current.length + setSectionStartCount(labelsRef.current.length) + clearSectionTracking() + setScanPaused(false) + } + + const mismatchIssues = summary.issues.filter((issue) => issue.status === 'mismatch') + const unreadIssues = summary.issues.filter((issue) => issue.status === 'unread') + const anchorStatus = anchorMode === 'homography' + ? `AR plane locked · ${anchorLandmarks} anchors` + : anchorMode === 'translation' + ? `AR following · ${anchorLandmarks} anchor${anchorLandmarks === 1 ? '' : 's'}` + : acceptedAnchors.length > 0 + ? 'AR position memory coasting' + : 'AR plane acquiring' + + return ( +
+
+
+ PALLET LABEL VERIFICATION +

Pallet label check

+

Batch OCR · sweep each pallet section and compare printed key/value fields

+
+
+ + {step !== 'setup' && } +
+
+ +
    + {['Setup', 'Sweep sections', 'Conformity'].map((label, index) => ( +
  1. index ? 'done' : ''} key={label}>{index + 1} {label}
  2. + ))} +
+ + {step === 'setup' && ( +
+ +

Start pallet check

+

Scan the top half, then the bottom half, move around the pallet and repeat. White labels are detected and OCR'd in batches while you sweep.

+ void startSession()} /> + + {error &&
{error}
} +
+ )} + + {step === 'scan' && ( +
+
+
+ Current sweep + {sectionLabel(section)} + Sweep slowly with some overlap between views so AR can keep the pallet face anchored. +
+ +
+ +
+
Pallet{labels.length} / {targetCount}
+
This section{sectionRead} / ~{sectionGuide}
+
Visible labels{visibleTracks.length}
+
OCR queue{queueCount + activeOcrCount}
+
+
+ +
+
+ )} + + {step === 'result' && ( +
+
+ {summary.status === 'pass' ? : summary.status === 'fail' ? : } +
+ {summary.status === 'pass' ? 'LABEL CONFORMITY PASS' : summary.status === 'fail' ? 'LABEL MISMATCH FOUND' : 'LABEL REVIEW REQUIRED'} +

{summary.total} / {targetCount} cartons read

+

{summary.status === 'pass' + ? 'The recognised key/value fields agree across the pallet.' + : summary.status === 'fail' + ? `${summary.mismatchCartons} carton${summary.mismatchCartons === 1 ? '' : 's'} differ from the completed-pallet majority.` + : `Only ${summary.majorityFields.length} fields had enough OCR evidence to establish a reliable pallet pattern.`}

+
+
+ +
+ Cartons {summary.total} + Compared fields {summary.majorityFields.length} + Mismatch cartons {summary.mismatchCartons} + Avg OCR {Math.round(summary.averageConfidence)}% +
+ +
+
FieldPallet majorityRead support
+ {summary.majorityFields.map((field) => ( +
+ {field.label} + {field.value} + {field.support} / {summary.total} +
+ ))} +
+ + {summary.issues.length > 0 && ( +
+
Exceptions{mismatchIssues.length} mismatch · {unreadIssues.length} unread
+ {summary.issues.slice(0, 24).map((issue) => ( +
+ Carton {issue.cartonNumber} + {issue.label} + {issue.section && Face {issue.section.face} · {issue.section.half}} + Expected majority{issue.expected} + Read{issue.actual || 'Unread'} +
+ ))} + {summary.issues.length > 24 &&

+ {summary.issues.length - 24} more exceptions

} +
+ )} + + {labels.at(-1)?.rawText && ( +
+ Last carton raw OCR +
{labels.at(-1)?.rawText}
+
+ )} + +
+ +
+
+ )} +
+ ) +} diff --git a/packages/fieldlens-core/src/features/scanner/ScannerView.tsx b/packages/fieldlens-core/src/features/scanner/ScannerView.tsx new file mode 100644 index 0000000..6fc736f --- /dev/null +++ b/packages/fieldlens-core/src/features/scanner/ScannerView.tsx @@ -0,0 +1,377 @@ +import { BrowserMultiFormatReader, type IScannerControls } from '@zxing/browser' +import { BarcodeFormat, DecodeHintType } from '@zxing/library' +import { Camera, CheckCircle2, RefreshCw, Settings2, ShieldAlert, SwitchCamera, Volume2, VolumeX, Zap, ZapOff } from 'lucide-react' +import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react' + +import { recordScan, type ScanRecord } from '../../lib/database' +import { detectionSpanPercent, formatName, formatsForMode, scanFormatOptions, validateScanPayload, type PayloadRules, type ScanFormatMode } from '../../lib/barcodes' +import { loadScannerPreferences, saveScannerPreferences } from '../../lib/preferences' +import { disableScanAudio, enableScanAudio, getScanAudio } from '../../lib/scanAudio' + +type ScannerStatus = 'starting' | 'detecting' | 'captured' | 'duplicate' | 'permission-denied' | 'camera-error' | 'storage-error' +type DetectionPoint = { left: number; top: number } +type ConfirmationCandidate = { value: string; format: BarcodeFormat; count: number; lastSeen: number } +type PendingCapture = { rawValue: string; format: string } + +export function ScannerView({ installAction }: { installAction?: ReactNode }) { + const [initialPreferences] = useState(loadScannerPreferences) + const videoRef = useRef(null) + const stageRef = useRef(null) + const controlsRef = useRef(null) + const armedRef = useRef(true) + const autoAdvanceRef = useRef(initialPreferences.autoAdvance) + const soundEnabledRef = useRef(initialPreferences.soundEnabled) + const confirmationFramesRef = useRef(initialPreferences.confirmationFrames) + const minimumSizeRef = useRef(initialPreferences.minimumSize) + const payloadRulesRef = useRef(initialPreferences.payloadRules) + const qualitySettingsOpenRef = useRef(false) + const candidateRef = useRef(null) + const rearmTimerRef = useRef(null) + const lastReadRef = useRef({ value: '', time: 0 }) + const [armed, setArmed] = useState(true) + const [status, setStatus] = useState('starting') + const [cameraError, setCameraError] = useState('') + const [cameras, setCameras] = useState([]) + const [selectedCamera, setSelectedCamera] = useState('') + const [requestedCamera, setRequestedCamera] = useState('') + const [torchAvailable, setTorchAvailable] = useState(false) + const [torchOn, setTorchOn] = useState(false) + const [autoAdvance, setAutoAdvance] = useState(initialPreferences.autoAdvance) + const [soundEnabled, setSoundEnabled] = useState(initialPreferences.soundEnabled) + const [formatMode, setFormatMode] = useState(initialPreferences.formatMode) + const [confirmationFrames, setConfirmationFrames] = useState(initialPreferences.confirmationFrames) + const [minimumSize, setMinimumSize] = useState(initialPreferences.minimumSize) + const [payloadRules, setPayloadRules] = useState(initialPreferences.payloadRules) + const [showQualitySettings, setShowQualitySettings] = useState(false) + const [qualityMessage, setQualityMessage] = useState('') + const [confirmationProgress, setConfirmationProgress] = useState(0) + const [retryKey, setRetryKey] = useState(0) + const [detectionPoints, setDetectionPoints] = useState([]) + const [latest, setLatest] = useState(null) + const [pendingCapture, setPendingCapture] = useState(null) + + const setScannerArmed = useCallback((value: boolean) => { + if (value && rearmTimerRef.current) { + window.clearTimeout(rearmTimerRef.current) + rearmTimerRef.current = null + } + armedRef.current = value + setArmed(value) + if (value) { + setStatus('detecting') + setDetectionPoints([]) + setConfirmationProgress(0) + setQualityMessage('') + candidateRef.current = null + } + }, []) + + const saveCapture = useCallback(async (capture: PendingCapture) => { + try { + const record = await recordScan(capture.rawValue, capture.format) + setPendingCapture(null) + setLatest(record) + setStatus(record.isDuplicate ? 'duplicate' : 'captured') + navigator.vibrate?.(record.isDuplicate ? [80, 70, 80] : 80) + const audioContext = getScanAudio() + if (soundEnabledRef.current && audioContext) { + const oscillator = audioContext.createOscillator() + const gain = audioContext.createGain() + oscillator.frequency.value = record.isDuplicate ? 220 : 660 + gain.gain.setValueAtTime(0.08, audioContext.currentTime) + gain.gain.exponentialRampToValueAtTime(0.001, audioContext.currentTime + 0.16) + oscillator.connect(gain).connect(audioContext.destination) + oscillator.start() + oscillator.stop(audioContext.currentTime + 0.16) + } + + if (autoAdvanceRef.current) { + rearmTimerRef.current = window.setTimeout(() => setScannerArmed(true), 1600) + } + } catch { + setStatus('storage-error') + } + }, [setScannerArmed]) + + const discardPendingCapture = () => { + setPendingCapture(null) + setScannerArmed(true) + } + + useEffect(() => { + const video = videoRef.current + if (!video) return + + const hints = new Map() + hints.set(DecodeHintType.POSSIBLE_FORMATS, formatsForMode(formatMode)) + const reader = new BrowserMultiFormatReader(hints, { + delayBetweenScanAttempts: 120, + delayBetweenScanSuccess: 180, + }) + let disposed = false + setStatus('starting') + setCameraError('') + setTorchAvailable(false) + + void reader.decodeFromConstraints( + { + audio: false, + video: requestedCamera + ? { deviceId: { exact: requestedCamera } } + : { facingMode: { ideal: 'environment' } }, + }, + video, + (result) => { + if (!armedRef.current || qualitySettingsOpenRef.current) return + if (!result) { + if (candidateRef.current) { + candidateRef.current = null + setConfirmationProgress(0) + setQualityMessage('') + } + return + } + + const rawValue = result.getText() + const barcodeFormat = result.getBarcodeFormat() + const now = Date.now() + if (lastReadRef.current.value === rawValue && now - lastReadRef.current.time < 1200) return + + const stage = stageRef.current + const points = result.getResultPoints() + const detectedSize = detectionSpanPercent(points, video.videoWidth, video.videoHeight) + if (detectedSize !== null && detectedSize < minimumSizeRef.current) { + candidateRef.current = null + setConfirmationProgress(0) + setQualityMessage(`Move closer · code is ${Math.round(detectedSize)}% of frame`) + return + } + + const validationError = validateScanPayload(rawValue, barcodeFormat, payloadRulesRef.current) + if (validationError) { + candidateRef.current = null + setConfirmationProgress(0) + setQualityMessage(validationError) + return + } + + const candidate = candidateRef.current + const isSameCandidate = candidate?.value === rawValue && candidate.format === barcodeFormat && now - candidate.lastSeen < 1000 + const nextCount = isSameCandidate ? candidate.count + 1 : 1 + candidateRef.current = { value: rawValue, format: barcodeFormat, count: nextCount, lastSeen: now } + setConfirmationProgress(nextCount) + setQualityMessage(nextCount < confirmationFramesRef.current ? `Hold steady · confirming ${nextCount}/${confirmationFramesRef.current}` : '') + if (nextCount < confirmationFramesRef.current) return + + candidateRef.current = null + lastReadRef.current = { value: rawValue, time: now } + setScannerArmed(false) + + if (stage && video.videoWidth && video.videoHeight && points.length) { + const scale = Math.max(stage.clientWidth / video.videoWidth, stage.clientHeight / video.videoHeight) + const offsetX = (stage.clientWidth - video.videoWidth * scale) / 2 + const offsetY = (stage.clientHeight - video.videoHeight * scale) / 2 + setDetectionPoints(points.map((point) => ({ + left: Math.min(100, Math.max(0, ((offsetX + point.getX() * scale) / stage.clientWidth) * 100)), + top: Math.min(100, Math.max(0, ((offsetY + point.getY() * scale) / stage.clientHeight) * 100)), + }))) + } + + const format = formatName(BarcodeFormat[barcodeFormat] ?? 'Barcode') + const capture = { rawValue, format } + setPendingCapture(capture) + void saveCapture(capture) + }, + ).then((controls) => { + if (disposed) controls.stop() + else { + controlsRef.current = controls + setTorchAvailable(Boolean(controls.switchTorch)) + setStatus('detecting') + + void BrowserMultiFormatReader.listVideoInputDevices().then((devices) => { + if (disposed) return + setCameras(devices) + const activeDevice = controls.streamVideoSettingsGet?.((track) => [track]).deviceId + if (activeDevice) setSelectedCamera(activeDevice) + }) + } + }).catch((error: unknown) => { + const message = error instanceof Error ? error.message : 'Camera access failed.' + setCameraError(message) + setStatus(error instanceof DOMException && error.name === 'NotAllowedError' ? 'permission-denied' : 'camera-error') + }) + + return () => { + disposed = true + controlsRef.current?.stop() + controlsRef.current = null + setTorchOn(false) + } + }, [formatMode, requestedCamera, retryKey, saveCapture, setScannerArmed]) + + useEffect(() => () => { + if (rearmTimerRef.current) window.clearTimeout(rearmTimerRef.current) + }, []) + + const toggleTorch = async () => { + const nextValue = !torchOn + try { + await controlsRef.current?.switchTorch?.(nextValue) + setTorchOn(nextValue) + } catch { + setTorchAvailable(false) + setTorchOn(false) + } + } + + const toggleAutoAdvance = () => { + const nextValue = !autoAdvance + autoAdvanceRef.current = nextValue + setAutoAdvance(nextValue) + saveScannerPreferences({ ...loadScannerPreferences(), autoAdvance: nextValue }) + if (!nextValue && rearmTimerRef.current) { + window.clearTimeout(rearmTimerRef.current) + rearmTimerRef.current = null + } + } + + const toggleSound = () => { + const nextValue = !soundEnabled + if (nextValue) enableScanAudio() + else disableScanAudio() + soundEnabledRef.current = nextValue + setSoundEnabled(nextValue) + saveScannerPreferences({ ...loadScannerPreferences(), soundEnabled: nextValue }) + } + + const updateConfirmationFrames = (value: 2 | 3) => { + confirmationFramesRef.current = value + setConfirmationFrames(value) + saveScannerPreferences({ ...loadScannerPreferences(), confirmationFrames: value }) + candidateRef.current = null + setConfirmationProgress(0) + } + + const updateMinimumSize = (value: number) => { + minimumSizeRef.current = value + setMinimumSize(value) + saveScannerPreferences({ ...loadScannerPreferences(), minimumSize: value }) + } + + const updatePayloadRules = (rules: PayloadRules) => { + payloadRulesRef.current = rules + setPayloadRules(rules) + saveScannerPreferences({ ...loadScannerPreferences(), payloadRules: rules }) + candidateRef.current = null + setConfirmationProgress(0) + } + + const toggleQualitySettings = () => { + const nextValue = !showQualitySettings + qualitySettingsOpenRef.current = nextValue + setShowQualitySettings(nextValue) + candidateRef.current = null + setConfirmationProgress(0) + setQualityMessage('') + } + + const retryCamera = () => { + setScannerArmed(true) + setRetryKey((value) => value + 1) + } + + const statusLabel = { + starting: 'STARTING CAMERA', + detecting: 'AUTO DETECTING', + captured: 'CAPTURED', + duplicate: 'DUPLICATE', + 'permission-denied': 'CAMERA BLOCKED', + 'camera-error': 'CAMERA ERROR', + 'storage-error': 'SAVE FAILED', + }[status] + + return ( +
+
+
+ ) +} diff --git a/packages/fieldlens-core/src/features/settings/SettingsView.tsx b/packages/fieldlens-core/src/features/settings/SettingsView.tsx new file mode 100644 index 0000000..d9f1297 --- /dev/null +++ b/packages/fieldlens-core/src/features/settings/SettingsView.tsx @@ -0,0 +1,92 @@ +import { CloudDownload, Database, RefreshCcw, ScanLine, ShieldCheck } from 'lucide-react' +import { useEffect, useState } from 'react' + +import { scanFormatOptions } from '../../lib/barcodes' +import { DEVICE_HOLD_EVENT, getDeviceHoldOrientation, type DeviceHoldOrientation } from '../../lib/deviceOrientation' +import { loadScannerPreferences, resetScannerPreferences, saveScannerPreferences, type ScannerPreferences } from '../../lib/preferences' +import { disableScanAudio, enableScanAudio } from '../../lib/scanAudio' + +type StorageStatus = 'checking' | 'persistent' | 'best-effort' | 'unsupported' + +type SettingsViewProps = { + storageStatus: StorageStatus + updateAvailable: boolean + isUpdating: boolean + updateError: string + onApplyUpdate: () => Promise +} + +export function SettingsView({ storageStatus, updateAvailable, isUpdating, updateError, onApplyUpdate }: SettingsViewProps) { + const [preferences, setPreferences] = useState(loadScannerPreferences) + const [deviceHold, setDeviceHold] = useState(getDeviceHoldOrientation) + + useEffect(() => { + const update = () => setDeviceHold(getDeviceHoldOrientation()) + window.addEventListener(DEVICE_HOLD_EVENT, update) + window.addEventListener('orientationchange', update) + window.visualViewport?.addEventListener('resize', update) + return () => { + window.removeEventListener(DEVICE_HOLD_EVENT, update) + window.removeEventListener('orientationchange', update) + window.visualViewport?.removeEventListener('resize', update) + } + }, []) + + const updatePreferences = (updates: Partial) => { + const next = { ...preferences, ...updates } + setPreferences(next) + saveScannerPreferences(next) + } + + const updatePayloadRules = (updates: Partial) => { + updatePreferences({ payloadRules: { ...preferences.payloadRules, ...updates } }) + } + + const toggleSound = () => { + const soundEnabled = !preferences.soundEnabled + if (soundEnabled) enableScanAudio() + else disableScanAudio() + updatePreferences({ soundEnabled }) + } + + const storageLabel = storageStatus === 'persistent' ? 'Durable' : storageStatus === 'checking' ? 'Checking' : storageStatus === 'unsupported' ? 'Browser managed' : 'Best effort' + + return ( +
+
+
DEVICE PROFILE

Settings

Core scanner defaults are stored locally on this device.

+ +
+ +
+
+

Capture

Detection and workflow defaults

+
+
Phone orientationDetected from how the device is held.
{deviceHold === 'portrait' ? 'Portrait' : 'Landscape'}
+
Automatic next scanRearm after a successful capture
+
Capture soundPlay distinct success and duplicate tones
+
+
updatePreferences({ minimumSize: Number(event.target.value) })} />
+
+ +
+

Validation

Payload acceptance rules

+
Known standardsValidate checksums and barcode structure
+
updatePayloadRules({ expectedLength: Math.max(0, Number(event.target.value)) })} />
+
updatePayloadRules({ requiredPrefix: event.target.value })} />
+
+ +
+

Local-first storage

Core persistence and integration boundary

+
Browser storageCapture data stays usable without a backend
{storageLabel}
+
Remote integrationRegister a provider or add sync in the host application
Host supplied
+
+ +
+

Application

PWA lifecycle

+
{updateAvailable ? 'Update available' : 'Service worker active'}{updateError || (isUpdating ? 'Installing the latest build…' : updateAvailable ? 'A newer build is ready.' : 'Updates are managed by the PWA service worker.')}
+
+
+
+ ) +} diff --git a/packages/fieldlens-core/src/index.css b/packages/fieldlens-core/src/index.css new file mode 100644 index 0000000..98a0acb --- /dev/null +++ b/packages/fieldlens-core/src/index.css @@ -0,0 +1,17 @@ +:root { + font-family: "Trebuchet MS", "Aptos", sans-serif; + color: #17211f; + background: #e9ebe5; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; +} + +* { box-sizing: border-box; } +html { background: #e9ebe5; } +body { margin: 0; min-width: 320px; min-height: 100vh; } +button, input, select { font: inherit; } +button { cursor: pointer; } +button:disabled { cursor: not-allowed; opacity: .48; } +code { font-family: "IBM Plex Mono", "Courier New", monospace; } +#root { min-height: 100vh; } \ No newline at end of file diff --git a/packages/fieldlens-core/src/ios-safe-area.css b/packages/fieldlens-core/src/ios-safe-area.css new file mode 100644 index 0000000..21131cd --- /dev/null +++ b/packages/fieldlens-core/src/ios-safe-area.css @@ -0,0 +1,18 @@ +/* iOS standalone PWA safe-area handling. Keep operational UI clear of the status bar/notch. */ + +@media (max-width: 760px) { + .workspace-view { + padding-top: calc(24px + env(safe-area-inset-top)); + } + + @supports selector(:has(*)) { + .label-check-view:has(.label-scan-panel) { + padding: env(safe-area-inset-top) 0 0; + background: var(--ink); + } + + .label-check-view:has(.label-scan-panel) .label-camera-stage { + min-height: calc(100dvh - 70px - env(safe-area-inset-bottom) - env(safe-area-inset-top) - 92px); + } + } +} diff --git a/packages/fieldlens-core/src/lib/barcodes.test.ts b/packages/fieldlens-core/src/lib/barcodes.test.ts new file mode 100644 index 0000000..deb9b5a --- /dev/null +++ b/packages/fieldlens-core/src/lib/barcodes.test.ts @@ -0,0 +1,31 @@ +import { BarcodeFormat } from '@zxing/library' +import { describe, expect, it } from 'vitest' + +import { appendGtinCheckDigit, detectionSpanPercent, formatsForMode, validateScanPayload } from './barcodes' + +const validationRules = { enabled: true, expectedLength: 0, requiredPrefix: '' } + +describe('barcode quality rules', () => { + it('generates and validates GTIN check digits', () => { + expect(appendGtinCheckDigit('400638133393')).toBe('4006381333931') + expect(validateScanPayload('4006381333931', BarcodeFormat.EAN_13, validationRules)).toBeNull() + expect(validateScanPayload('4006381333932', BarcodeFormat.EAN_13, validationRules)).toBe('Invalid EAN-13 checksum') + }) + + it('excludes noisy matrix formats from narrower presets', () => { + expect(formatsForMode('standard')).not.toContain(BarcodeFormat.MICRO_QR_CODE) + expect(formatsForMode('products')).not.toContain(BarcodeFormat.QR_CODE) + expect(formatsForMode('all')).toContain(BarcodeFormat.MICRO_QR_CODE) + }) + + it('enforces custom payload rules', () => { + expect(validateScanPayload('ABC123', BarcodeFormat.CODE_128, { enabled: true, expectedLength: 6, requiredPrefix: 'ABC' })).toBeNull() + expect(validateScanPayload('ABC123', BarcodeFormat.CODE_128, { enabled: true, expectedLength: 8, requiredPrefix: '' })).toBe('Expected 8 characters') + }) + + it('measures detection span relative to the source frame', () => { + const points = [{ getX: () => 10, getY: () => 20 }, { getX: () => 30, getY: () => 25 }] + expect(detectionSpanPercent(points, 200, 100)).toBe(10) + expect(detectionSpanPercent([], 200, 100)).toBeNull() + }) +}) \ No newline at end of file diff --git a/packages/fieldlens-core/src/lib/barcodes.ts b/packages/fieldlens-core/src/lib/barcodes.ts new file mode 100644 index 0000000..0dd2547 --- /dev/null +++ b/packages/fieldlens-core/src/lib/barcodes.ts @@ -0,0 +1,96 @@ +import { BarcodeFormat } from '@zxing/library' + +export type ScanFormatMode = 'standard' | 'products' | 'matrix' | 'all' + +export type PayloadRules = { + enabled: boolean + expectedLength: number + requiredPrefix: string +} + +const allFormats = Object.values(BarcodeFormat).filter((value): value is BarcodeFormat => typeof value === 'number') + +const productFormats = [ + BarcodeFormat.CODABAR, + BarcodeFormat.CODE_39, + BarcodeFormat.CODE_93, + BarcodeFormat.CODE_128, + BarcodeFormat.EAN_8, + BarcodeFormat.EAN_13, + BarcodeFormat.ITF, + BarcodeFormat.RSS_14, + BarcodeFormat.RSS_EXPANDED, + BarcodeFormat.UPC_A, + BarcodeFormat.UPC_E, +] + +const matrixFormats = [ + BarcodeFormat.AZTEC, + BarcodeFormat.DATA_MATRIX, + BarcodeFormat.MAXICODE, + BarcodeFormat.PDF_417, + BarcodeFormat.QR_CODE, +] + +export const scanFormatOptions: { value: ScanFormatMode; label: string }[] = [ + { value: 'standard', label: 'Standard codes' }, + { value: 'products', label: 'Product barcodes' }, + { value: 'matrix', label: '2D codes' }, + { value: 'all', label: 'All including Micro QR' }, +] + +export function formatsForMode(mode: ScanFormatMode) { + if (mode === 'products') return productFormats + if (mode === 'matrix') return matrixFormats + if (mode === 'all') return allFormats + return allFormats.filter((format) => format !== BarcodeFormat.MICRO_QR_CODE) +} + +export function detectionSpanPercent(points: { getX: () => number; getY: () => number }[], width: number, height: number) { + if (points.length < 2 || width <= 0 || height <= 0) return null + const xValues = points.map((point) => point.getX()) + const yValues = points.map((point) => point.getY()) + const widthPercent = ((Math.max(...xValues) - Math.min(...xValues)) / width) * 100 + const heightPercent = ((Math.max(...yValues) - Math.min(...yValues)) / height) * 100 + return Math.max(widthPercent, heightPercent) +} + +function hasValidGtinCheckDigit(value: string) { + if (!/^\d+$/.test(value) || value.length < 2) return false + const body = value.slice(0, -1) + const expected = appendGtinCheckDigit(body).at(-1) + return value.at(-1) === expected +} + +export function validateScanPayload(rawValue: string, format: BarcodeFormat, rules: PayloadRules) { + if (!rawValue) return 'Empty payload rejected' + if (!rules.enabled) return null + if (rules.expectedLength > 0 && rawValue.length !== rules.expectedLength) return `Expected ${rules.expectedLength} characters` + if (rules.requiredPrefix && !rawValue.startsWith(rules.requiredPrefix)) return `Expected prefix ${rules.requiredPrefix}` + + if (format === BarcodeFormat.EAN_8 && (!/^\d{8}$/.test(rawValue) || !hasValidGtinCheckDigit(rawValue))) return 'Invalid EAN-8 checksum' + if (format === BarcodeFormat.EAN_13 && (!/^\d{13}$/.test(rawValue) || !hasValidGtinCheckDigit(rawValue))) return 'Invalid EAN-13 checksum' + if (format === BarcodeFormat.UPC_A && (!/^\d{12}$/.test(rawValue) || !hasValidGtinCheckDigit(rawValue))) return 'Invalid UPC-A checksum' + if (format === BarcodeFormat.UPC_E && !/^\d{8}$/.test(rawValue)) return 'Invalid UPC-E payload' + if (format === BarcodeFormat.ITF && (!/^\d{6,14}$/.test(rawValue) || rawValue.length % 2 !== 0)) return 'Invalid ITF payload' + if (format === BarcodeFormat.ITF && rawValue.length === 14 && !hasValidGtinCheckDigit(rawValue)) return 'Invalid GTIN-14 checksum' + if (format === BarcodeFormat.CODE_39 && !/^[0-9A-Z .\-$/+%]+$/.test(rawValue)) return 'Invalid Code 39 characters' + if (format === BarcodeFormat.CODABAR && !/^[A-D][0-9\-$:/.+]+[A-D]$/.test(rawValue)) return 'Invalid Codabar characters' + return null +} + +export function formatName(value: string) { + return value.replaceAll('_', ' ').replace(/\b\w/g, (character) => character.toUpperCase()) +} + +export function appendGtinCheckDigit(base: string) { + const sum = [...base] + .reverse() + .reduce((total, digit, index) => total + Number(digit) * (index % 2 === 0 ? 3 : 1), 0) + return `${base}${(10 - (sum % 10)) % 10}` +} + +export function randomDigits(length: number) { + const values = crypto.getRandomValues(new Uint8Array(length)) + return [...values].map((value) => value % 10).join('') +} diff --git a/packages/fieldlens-core/src/lib/cartonArTracking.test.ts b/packages/fieldlens-core/src/lib/cartonArTracking.test.ts new file mode 100644 index 0000000..9c1b16b --- /dev/null +++ b/packages/fieldlens-core/src/lib/cartonArTracking.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest' + +import { + estimateSectionHomography, + getCoverCoordinateSpace, + IDENTITY_HOMOGRAPHY, + invertHomography, + projectBox, + projectPoint, +} from './cartonArTracking' + +describe('getCoverCoordinateSpace', () => { + it('matches object-fit cover when a landscape camera fills a portrait stage', () => { + const space = getCoverCoordinateSpace(1920, 1080, 390, 600) + + expect(space.height).toBeCloseTo(600) + expect(space.width).toBeGreaterThan(390) + expect(space.left).toBeLessThan(0) + expect(space.top).toBeCloseTo(0) + }) +}) + +describe('section homography', () => { + it('follows camera translation from one persistent label', () => { + const anchor = { x: 0.2, y: 0.3, width: 0.2, height: 0.1 } + const current = { x: 0.27, y: 0.25, width: 0.2, height: 0.1 } + const estimate = estimateSectionHomography([{ anchor, current }], IDENTITY_HOMOGRAPHY) + const projected = projectPoint(estimate.matrix, 0.3, 0.35) + + expect(estimate.mode).toBe('translation') + expect(projected.x).toBeGreaterThan(0.32) + expect(projected.y).toBeLessThan(0.35) + }) + + it('projects remembered label boxes and can map them back into anchor space', () => { + const correspondences = [ + { + anchor: { x: 0.12, y: 0.2, width: 0.18, height: 0.09 }, + current: { x: 0.18, y: 0.22, width: 0.2, height: 0.1 }, + }, + { + anchor: { x: 0.55, y: 0.5, width: 0.17, height: 0.08 }, + current: { x: 0.61, y: 0.52, width: 0.19, height: 0.09 }, + }, + { + anchor: { x: 0.34, y: 0.68, width: 0.16, height: 0.08 }, + current: { x: 0.4, y: 0.7, width: 0.18, height: 0.09 }, + }, + ] + + const estimate = estimateSectionHomography(correspondences, IDENTITY_HOMOGRAPHY) + const remembered = { x: 0.4, y: 0.35, width: 0.18, height: 0.09 } + const projected = projectBox(estimate.matrix, remembered) + const recovered = projectBox(invertHomography(estimate.matrix), projected) + + expect(estimate.landmarks).toBe(3) + expect(projected.x).toBeGreaterThan(remembered.x) + expect(recovered.x).toBeCloseTo(remembered.x, 2) + expect(recovered.y).toBeCloseTo(remembered.y, 2) + }) + + it('does not let one bad label match drag the remembered pallet positions', () => { + const correspondences = [ + { + anchor: { x: 0.15, y: 0.15, width: 0.12, height: 0.06 }, + current: { x: 0.2, y: 0.17, width: 0.12, height: 0.06 }, + }, + { + anchor: { x: 0.55, y: 0.15, width: 0.12, height: 0.06 }, + current: { x: 0.6, y: 0.17, width: 0.12, height: 0.06 }, + }, + { + anchor: { x: 0.15, y: 0.55, width: 0.12, height: 0.06 }, + current: { x: 0.2, y: 0.57, width: 0.12, height: 0.06 }, + }, + { + anchor: { x: 0.55, y: 0.55, width: 0.12, height: 0.06 }, + current: { x: 0.02, y: 0.82, width: 0.12, height: 0.06 }, + }, + ] + + const estimate = estimateSectionHomography(correspondences, IDENTITY_HOMOGRAPHY) + const projected = projectPoint(estimate.matrix, 0.35, 0.35) + + expect(estimate.mode).toBe('translation') + expect(projected.x).toBeGreaterThan(0.36) + expect(projected.x).toBeLessThan(0.4) + expect(projected.y).toBeGreaterThan(0.35) + expect(projected.y).toBeLessThan(0.38) + }) +}) diff --git a/packages/fieldlens-core/src/lib/cartonArTracking.ts b/packages/fieldlens-core/src/lib/cartonArTracking.ts new file mode 100644 index 0000000..3dc2109 --- /dev/null +++ b/packages/fieldlens-core/src/lib/cartonArTracking.ts @@ -0,0 +1,354 @@ +import type { LabelBox } from './cartonLabelDetection' + +export type Homography = [ + number, number, number, + number, number, number, + number, number, number, +] + +export type BoxCorrespondence = { + anchor: LabelBox + current: LabelBox +} + +export type CoverCoordinateSpace = { + left: number + top: number + width: number + height: number +} + +export type CameraDisplayOrientation = 'auto' | 'portrait' | 'landscape' + +export type HomographyEstimate = { + matrix: Homography + landmarks: number + residual: number + mode: 'idle' | 'translation' | 'homography' +} + +export const IDENTITY_HOMOGRAPHY: Homography = [ + 1, 0, 0, + 0, 1, 0, + 0, 0, 1, +] + +function center(box: LabelBox) { + return { x: box.x + box.width / 2, y: box.y + box.height / 2 } +} + +function median(values: number[]) { + if (values.length === 0) return 0 + const sorted = [...values].sort((left, right) => left - right) + const midpoint = Math.floor(sorted.length / 2) + return sorted.length % 2 === 0 ? (sorted[midpoint - 1] + sorted[midpoint]) / 2 : sorted[midpoint] +} + +function solveLinearSystem(matrix: number[][], vector: number[]) { + const size = vector.length + const augmented = matrix.map((row, index) => [...row, vector[index]]) + + for (let column = 0; column < size; column += 1) { + let pivot = column + for (let row = column + 1; row < size; row += 1) { + if (Math.abs(augmented[row][column]) > Math.abs(augmented[pivot][column])) pivot = row + } + + if (Math.abs(augmented[pivot][column]) < 1e-10) return null + const swap = augmented[column] + augmented[column] = augmented[pivot] + augmented[pivot] = swap + + const divisor = augmented[column][column] + for (let cursor = column; cursor <= size; cursor += 1) augmented[column][cursor] /= divisor + + for (let row = 0; row < size; row += 1) { + if (row === column) continue + const factor = augmented[row][column] + if (Math.abs(factor) < 1e-12) continue + for (let cursor = column; cursor <= size; cursor += 1) { + augmented[row][cursor] -= factor * augmented[column][cursor] + } + } + } + + return augmented.map((row) => row[size]) +} + +function leastSquaresSimilarity(correspondences: BoxCorrespondence[]) { + if (correspondences.length < 2) return null + + // Fit x' = a*x - b*y + tx, y' = b*x + a*y + ty from label centres. + // Detection boxes are axis-aligned, so their corners are not reliable planar + // correspondences under perspective. Centres remain substantially more stable. + const normal = Array.from({ length: 4 }, () => Array(4).fill(0)) + const rhs = Array(4).fill(0) + + const accumulate = (row: number[], value: number) => { + for (let left = 0; left < 4; left += 1) { + rhs[left] += row[left] * value + for (let right = 0; right < 4; right += 1) normal[left][right] += row[left] * row[right] + } + } + + correspondences.forEach(({ anchor, current }) => { + const from = center(anchor) + const to = center(current) + accumulate([from.x, -from.y, 1, 0], to.x) + accumulate([from.y, from.x, 0, 1], to.y) + }) + + for (let index = 0; index < 4; index += 1) normal[index][index] += 1e-8 + const solved = solveLinearSystem(normal, rhs) + if (!solved) return null + + const [a, b, tx, ty] = solved + if (![a, b, tx, ty].every(Number.isFinite)) return null + + return [ + a, -b, tx, + b, a, ty, + 0, 0, 1, + ] as Homography +} + +function normalizeHomography(matrix: Homography): Homography { + const divisor = Math.abs(matrix[8]) > 1e-10 ? matrix[8] : 1 + return matrix.map((value) => value / divisor) as Homography +} + +function translateExisting(matrix: Homography, dx: number, dy: number): Homography { + return normalizeHomography([ + matrix[0] + dx * matrix[6], matrix[1] + dx * matrix[7], matrix[2] + dx * matrix[8], + matrix[3] + dy * matrix[6], matrix[4] + dy * matrix[7], matrix[5] + dy * matrix[8], + matrix[6], matrix[7], matrix[8], + ]) +} + +function blendHomography(previous: Homography, next: Homography, weight: number): Homography { + const blended = previous.map((value, index) => value * (1 - weight) + next[index] * weight) as Homography + return normalizeHomography(blended) +} + +function similarityScale(matrix: Homography) { + return Math.hypot(matrix[0], matrix[3]) +} + +export function projectPoint(matrix: Homography, x: number, y: number) { + const denominator = matrix[6] * x + matrix[7] * y + matrix[8] + if (Math.abs(denominator) < 1e-8) return { x, y } + return { + x: (matrix[0] * x + matrix[1] * y + matrix[2]) / denominator, + y: (matrix[3] * x + matrix[4] * y + matrix[5]) / denominator, + } +} + +export function projectBox(matrix: Homography, box: LabelBox): LabelBox { + const points = [ + projectPoint(matrix, box.x, box.y), + projectPoint(matrix, box.x + box.width, box.y), + projectPoint(matrix, box.x + box.width, box.y + box.height), + projectPoint(matrix, box.x, box.y + box.height), + ] + const xs = points.map((point) => point.x) + const ys = points.map((point) => point.y) + const left = Math.min(...xs) + const top = Math.min(...ys) + return { + x: left, + y: top, + width: Math.max(...xs) - left, + height: Math.max(...ys) - top, + } +} + +export function invertHomography(matrix: Homography): Homography { + const [a, b, c, d, e, f, g, h, i] = matrix + const aa = e * i - f * h + const ab = c * h - b * i + const ac = b * f - c * e + const ad = f * g - d * i + const ae = a * i - c * g + const af = c * d - a * f + const ag = d * h - e * g + const ah = b * g - a * h + const ai = a * e - b * d + const determinant = a * aa + b * ad + c * ag + if (Math.abs(determinant) < 1e-9) return [...IDENTITY_HOMOGRAPHY] as Homography + + return normalizeHomography([ + aa / determinant, ab / determinant, ac / determinant, + ad / determinant, ae / determinant, af / determinant, + ag / determinant, ah / determinant, ai / determinant, + ]) +} + +function residualForPair(matrix: Homography, pair: BoxCorrespondence) { + const reference = center(pair.anchor) + const expected = projectPoint(matrix, reference.x, reference.y) + const actual = center(pair.current) + return Math.hypot(expected.x - actual.x, expected.y - actual.y) +} + +function estimateResidual(matrix: Homography, correspondences: BoxCorrespondence[]) { + if (correspondences.length === 0) return 0 + return correspondences.reduce((sum, pair) => sum + residualForPair(matrix, pair), 0) / correspondences.length +} + +function robustSimilarity(correspondences: BoxCorrespondence[]) { + const initial = leastSquaresSimilarity(correspondences) + if (!initial) return null + + const residuals = correspondences.map((pair) => residualForPair(initial, pair)) + const medianResidual = median(residuals) + const threshold = Math.max(0.014, Math.min(0.05, medianResidual * 2.5 + 0.006)) + const inliers = correspondences.filter((_, index) => residuals[index] <= threshold) + + if (inliers.length < 2) return null + const refined = inliers.length === correspondences.length ? initial : leastSquaresSimilarity(inliers) + if (!refined) return null + + return { + matrix: refined, + inliers, + residual: estimateResidual(refined, inliers), + } +} + +export function estimateSectionHomography( + correspondences: BoxCorrespondence[], + previous: Homography, +): HomographyEstimate { + if (correspondences.length === 0) { + return { matrix: previous, landmarks: 0, residual: 0, mode: 'idle' } + } + + if (correspondences.length === 1) { + const pair = correspondences[0] + const referenceCenter = center(pair.anchor) + const predicted = projectPoint(previous, referenceCenter.x, referenceCenter.y) + const actual = center(pair.current) + const dx = actual.x - predicted.x + const dy = actual.y - predicted.y + const distance = Math.hypot(dx, dy) + + // A single label is useful for translation, but not enough evidence to let + // one bad association pull the whole pallet overlay across the screen. + if (distance > 0.14) { + return { matrix: previous, landmarks: 1, residual: distance, mode: 'idle' } + } + + const translated = translateExisting(previous, dx, dy) + return { + matrix: blendHomography(previous, translated, 0.42), + landmarks: 1, + residual: distance, + mode: 'translation', + } + } + + const fitted = robustSimilarity(correspondences) + if (fitted) { + const previousScale = similarityScale(previous) + const nextScale = similarityScale(fitted.matrix) + const scaleRatio = previousScale > 1e-6 ? nextScale / previousScale : 1 + + // Reject abrupt zoom/association jumps. Normal handheld movement between + // detector frames should not resize the pallet by more than ~30% at once. + if ( + Number.isFinite(fitted.residual) + && fitted.residual <= 0.035 + && scaleRatio >= 0.72 + && scaleRatio <= 1.38 + ) { + const weight = fitted.inliers.length >= 4 ? 0.4 : fitted.inliers.length === 3 ? 0.34 : 0.28 + return { + matrix: blendHomography(previous, fitted.matrix, weight), + landmarks: fitted.inliers.length, + residual: fitted.residual, + mode: 'homography', + } + } + } + + // If a fit is ambiguous, follow only the consensus screen translation. This + // preserves tracking through a sweep without allowing a bad label swap to + // rotate or resize every remembered position. + const dx: number[] = [] + const dy: number[] = [] + correspondences.forEach((pair) => { + const referenceCenter = center(pair.anchor) + const predicted = projectPoint(previous, referenceCenter.x, referenceCenter.y) + const actual = center(pair.current) + dx.push(actual.x - predicted.x) + dy.push(actual.y - predicted.y) + }) + const medianDx = median(dx) + const medianDy = median(dy) + const distance = Math.hypot(medianDx, medianDy) + if (distance > 0.14) { + return { matrix: previous, landmarks: correspondences.length, residual: distance, mode: 'idle' } + } + + const translated = translateExisting(previous, medianDx, medianDy) + return { + matrix: blendHomography(previous, translated, 0.36), + landmarks: correspondences.length, + residual: distance, + mode: 'translation', + } +} + +function currentCameraOrientation(): CameraDisplayOrientation { + if (typeof document === 'undefined') return 'auto' + const mode = document.documentElement.dataset.cameraOrientation + return mode === 'portrait' || mode === 'landscape' ? mode : 'auto' +} + +function publishCameraDisplaySpace(space: CoverCoordinateSpace, quarterTurn: boolean) { + if (typeof document === 'undefined') return + const style = document.documentElement.style + style.setProperty('--fieldlens-camera-left', `${space.left}px`) + style.setProperty('--fieldlens-camera-top', `${space.top}px`) + style.setProperty('--fieldlens-camera-width', `${space.width}px`) + style.setProperty('--fieldlens-camera-height', `${space.height}px`) + style.setProperty('--fieldlens-camera-rotation', quarterTurn ? '90deg' : '0deg') +} + +export function getCoverCoordinateSpace( + videoWidth: number, + videoHeight: number, + stageWidth: number, + stageHeight: number, + orientation: CameraDisplayOrientation = currentCameraOrientation(), +): CoverCoordinateSpace { + if (!videoWidth || !videoHeight || !stageWidth || !stageHeight) { + const empty = { left: 0, top: 0, width: stageWidth, height: stageHeight } + publishCameraDisplaySpace(empty, false) + return empty + } + + const stageOrientation: Exclude = stageWidth >= stageHeight ? 'landscape' : 'portrait' + const quarterTurn = orientation !== 'auto' && orientation !== stageOrientation + const visualWidth = quarterTurn ? videoHeight : videoWidth + const visualHeight = quarterTurn ? videoWidth : videoHeight + const scale = Math.max(stageWidth / visualWidth, stageHeight / visualHeight) + + // Keep the CSS box in raw camera coordinates, then rotate the complete plane. + // The overlay uses this same rectangle, so its normalized detections remain + // pixel-aligned with the camera after a forced portrait/landscape quarter-turn. + const width = videoWidth * scale + const height = videoHeight * scale + const space = { + left: (stageWidth - width) / 2, + top: (stageHeight - height) / 2, + width, + height, + } + publishCameraDisplaySpace(space, quarterTurn) + return space +} + +export function boxIntersectsVideo(box: LabelBox) { + return box.x < 1.05 && box.y < 1.05 && box.x + box.width > -0.05 && box.y + box.height > -0.05 +} diff --git a/packages/fieldlens-core/src/lib/cartonConfigs.ts b/packages/fieldlens-core/src/lib/cartonConfigs.ts new file mode 100644 index 0000000..4fe0de6 --- /dev/null +++ b/packages/fieldlens-core/src/lib/cartonConfigs.ts @@ -0,0 +1,56 @@ +export type CartonConfig = { + id: string + name: string + count: number +} + +const STORAGE_KEY = 'fieldlens-ean-carton-configs' + +function isValidConfig(value: unknown): value is CartonConfig { + if (!value || typeof value !== 'object') return false + const candidate = value as Partial + return typeof candidate.id === 'string' + && candidate.id.length > 0 + && typeof candidate.name === 'string' + && candidate.name.trim().length > 0 + && typeof candidate.count === 'number' + && Number.isFinite(candidate.count) + && candidate.count >= 1 + && candidate.count <= 500 +} + +export function loadCartonConfigs(): CartonConfig[] { + try { + const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]') as unknown + if (!Array.isArray(parsed)) return [] + return parsed + .filter(isValidConfig) + .map((config) => ({ ...config, name: config.name.trim(), count: Math.round(config.count) })) + .sort((left, right) => left.name.localeCompare(right.name)) + } catch { + return [] + } +} + +function persistCartonConfigs(configs: CartonConfig[]) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(configs)) + } catch { + // Configs still work for the current screen if browser storage is unavailable. + } +} + +export function upsertCartonConfig(configs: CartonConfig[], name: string, count: number) { + const cleanName = name.trim() + const safeCount = Math.max(1, Math.min(500, Math.round(count))) + const existing = configs.find((config) => config.name.toLowerCase() === cleanName.toLowerCase()) + const config: CartonConfig = existing + ? { ...existing, name: cleanName, count: safeCount } + : { id: crypto.randomUUID(), name: cleanName, count: safeCount } + const next = existing + ? configs.map((candidate) => candidate.id === existing.id ? config : candidate) + : [...configs, config] + next.sort((left, right) => left.name.localeCompare(right.name)) + persistCartonConfigs(next) + return { configs: next, config } +} diff --git a/packages/fieldlens-core/src/lib/cartonLabelConformity.test.ts b/packages/fieldlens-core/src/lib/cartonLabelConformity.test.ts new file mode 100644 index 0000000..101cc6d --- /dev/null +++ b/packages/fieldlens-core/src/lib/cartonLabelConformity.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest' + +import { createCartonLabelScan, summarizeCartonLabels } from './cartonLabelConformity' +import type { LabelOcrResult } from './labelOcr' + +function ocr(overrides: Partial = {}, rawText = ''): LabelOcrResult { + return { + confidence: 92, + rawText, + fields: { + ppin: '', + variety: 'HASS AVOCADO', + grower: '34000', + grade: 'CLASS 1', + packer: 'PK0405', + pickDate: '03/09/2026', + packDate: '07/09/2026', + size: '24', + count: '48', + region: '', + tempPc: '', + ...overrides, + }, + } +} + +describe('summarizeCartonLabels', () => { + it('passes when the extracted carton label values agree', () => { + const summary = summarizeCartonLabels([ + createCartonLabelScan(ocr()), + createCartonLabelScan(ocr()), + createCartonLabelScan(ocr()), + ]) + + expect(summary.status).toBe('pass') + expect(summary.mismatchCartons).toBe(0) + expect(summary.majorityFields.length).toBeGreaterThanOrEqual(4) + }) + + it('uses the completed set majority rather than assuming the first label is correct', () => { + const summary = summarizeCartonLabels([ + createCartonLabelScan(ocr({ packer: 'PK0999' })), + createCartonLabelScan(ocr()), + createCartonLabelScan(ocr()), + ]) + + expect(summary.status).toBe('fail') + expect(summary.mismatchCartons).toBe(1) + expect(summary.issues).toContainEqual(expect.objectContaining({ + cartonNumber: 1, + field: 'packer', + expected: 'PK0405', + actual: 'PK0999', + status: 'mismatch', + })) + }) + + it('accepts strong carton-label structure when PPIN or grower identity is missed', () => { + const structuralIdentity = createCartonLabelScan(ocr({ grower: '' }, 'Variety HASS AVOCADO Count 48 Packer PK0405 Size 24')) + const ppinIdentity = createCartonLabelScan(ocr({ grower: '', ppin: '123456' }, 'PPIN: 123456 Variety HASS AVOCADO Count 48 Packer PK0405 Size 24')) + + expect(structuralIdentity.recognizedFields).toBeGreaterThanOrEqual(4) + expect(ppinIdentity.recognizedFields).toBeGreaterThanOrEqual(4) + }) + + it('drops partial OCR values before they can influence the pallet majority', () => { + const scan = createCartonLabelScan(ocr({ grower: '34', packer: 'PKO40', ppin: '04809' })) + + expect(scan.fields.grower).toBe('') + expect(scan.fields.packer).toBe('') + expect(scan.fields.ppin).toBe('04809') + }) +}) diff --git a/packages/fieldlens-core/src/lib/cartonLabelConformity.ts b/packages/fieldlens-core/src/lib/cartonLabelConformity.ts new file mode 100644 index 0000000..8faa95d --- /dev/null +++ b/packages/fieldlens-core/src/lib/cartonLabelConformity.ts @@ -0,0 +1,191 @@ +import { + LABEL_FIELD_KEYS, + LABEL_FIELD_LABELS, + hasCartonLabelIdentity, + normalizeLabelFieldValue, + sanitizeLabelFields, + type LabelFieldKey, + type LabelFields, + type LabelOcrResult, +} from './labelOcr' + +export const MIN_RECOGNIZED_LABEL_FIELDS = 4 + +export type CartonLabelSection = { + face: number + half: 'top' | 'bottom' +} + +export type CartonLabelScan = { + id: string + capturedAt: string + fields: LabelFields + rawText: string + confidence: number + recognizedFields: number + section?: CartonLabelSection + trackId?: string +} + +export type CartonLabelMajorityField = { + field: LabelFieldKey + label: string + value: string + support: number + observed: number + coverage: number +} + +export type CartonLabelIssue = { + scanId: string + cartonNumber: number + field: LabelFieldKey + label: string + expected: string + actual: string + status: 'mismatch' | 'unread' + section?: CartonLabelSection +} + +export type CartonLabelSummary = { + total: number + status: 'pass' | 'fail' | 'review' + conforms: boolean + majorityFields: CartonLabelMajorityField[] + issues: CartonLabelIssue[] + mismatchCartons: number + averageConfidence: number +} + +export function countRecognizedLabelFields(fields: LabelFields) { + return LABEL_FIELD_KEYS.filter((field) => fields[field].trim()).length +} + +export function createCartonLabelScan( + ocr: LabelOcrResult, + section?: CartonLabelSection, + trackId?: string, +): CartonLabelScan { + const fields = sanitizeLabelFields(ocr.fields) + return { + id: crypto.randomUUID(), + capturedAt: new Date().toISOString(), + fields, + rawText: ocr.rawText, + confidence: ocr.confidence, + recognizedFields: hasCartonLabelIdentity({ ...ocr, fields }) ? countRecognizedLabelFields(fields) : 0, + section, + trackId, + } +} + +function dominantValue(scans: CartonLabelScan[], field: LabelFieldKey) { + const totals = new Map() + let observed = 0 + + scans.forEach((scan) => { + const display = scan.fields[field].trim() + const normalized = normalizeLabelFieldValue(field, display) + if (!normalized) return + observed += 1 + const current = totals.get(normalized) + totals.set(normalized, { count: (current?.count ?? 0) + 1, display: current?.display ?? display }) + }) + + const dominant = [...totals.entries()].sort((a, b) => b[1].count - a[1].count || a[0].localeCompare(b[0]))[0] + if (!dominant) return null + + return { + normalized: dominant[0], + display: dominant[1].display, + count: dominant[1].count, + observed, + } +} + +export function summarizeCartonLabels(scans: CartonLabelScan[]): CartonLabelSummary { + if (scans.length === 0) { + return { + total: 0, + status: 'review', + conforms: false, + majorityFields: [], + issues: [], + mismatchCartons: 0, + averageConfidence: 0, + } + } + + const majorityFields = LABEL_FIELD_KEYS.flatMap((field) => { + const dominant = dominantValue(scans, field) + if (!dominant) return [] + + const coverage = dominant.observed / scans.length + const dominance = dominant.count / dominant.observed + const enoughEvidence = dominant.count >= Math.min(2, scans.length) && coverage >= 0.45 && dominance >= 0.6 + if (!enoughEvidence) return [] + + return [{ + field, + label: LABEL_FIELD_LABELS[field], + value: dominant.display, + support: dominant.count, + observed: dominant.observed, + coverage, + }] + }) + + const issues: CartonLabelIssue[] = [] + + scans.forEach((scan, index) => { + majorityFields.forEach((majority) => { + const actual = scan.fields[majority.field].trim() + const normalizedActual = normalizeLabelFieldValue(majority.field, actual) + const normalizedExpected = normalizeLabelFieldValue(majority.field, majority.value) + + if (!normalizedActual) { + if (majority.coverage >= 0.75) { + issues.push({ + scanId: scan.id, + cartonNumber: index + 1, + field: majority.field, + label: majority.label, + expected: majority.value, + actual: '', + status: 'unread', + section: scan.section, + }) + } + return + } + + if (normalizedActual !== normalizedExpected) { + issues.push({ + scanId: scan.id, + cartonNumber: index + 1, + field: majority.field, + label: majority.label, + expected: majority.value, + actual, + status: 'mismatch', + section: scan.section, + }) + } + }) + }) + + const mismatchCartons = new Set(issues.filter((issue) => issue.status === 'mismatch').map((issue) => issue.scanId)).size + const hasMismatch = mismatchCartons > 0 + const enoughFields = majorityFields.length >= MIN_RECOGNIZED_LABEL_FIELDS + const status: CartonLabelSummary['status'] = hasMismatch ? 'fail' : enoughFields ? 'pass' : 'review' + + return { + total: scans.length, + status, + conforms: status === 'pass', + majorityFields, + issues, + mismatchCartons, + averageConfidence: scans.reduce((total, scan) => total + scan.confidence, 0) / scans.length, + } +} diff --git a/packages/fieldlens-core/src/lib/cartonLabelDetection.test.ts b/packages/fieldlens-core/src/lib/cartonLabelDetection.test.ts new file mode 100644 index 0000000..3c1fff6 --- /dev/null +++ b/packages/fieldlens-core/src/lib/cartonLabelDetection.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' + +import { boxCenterDistance, boxIou, boxSizeDifference, type LabelBox } from './cartonLabelDetection' + +describe('carton label tracking geometry', () => { + const label: LabelBox = { x: 0.2, y: 0.3, width: 0.18, height: 0.08 } + + it('keeps a slightly moved white label associated with the same track', () => { + const moved: LabelBox = { x: 0.215, y: 0.305, width: 0.176, height: 0.082 } + expect(boxIou(label, moved)).toBeGreaterThan(0.7) + expect(boxCenterDistance(label, moved)).toBeLessThan(0.03) + expect(boxSizeDifference(label, moved)).toBeLessThan(0.1) + }) + + it('separates labels in different carton positions', () => { + const other: LabelBox = { x: 0.62, y: 0.3, width: 0.18, height: 0.08 } + expect(boxIou(label, other)).toBe(0) + expect(boxCenterDistance(label, other)).toBeGreaterThan(0.4) + }) +}) diff --git a/packages/fieldlens-core/src/lib/cartonLabelDetection.ts b/packages/fieldlens-core/src/lib/cartonLabelDetection.ts new file mode 100644 index 0000000..d807bca --- /dev/null +++ b/packages/fieldlens-core/src/lib/cartonLabelDetection.ts @@ -0,0 +1,189 @@ +export type LabelBox = { + x: number + y: number + width: number + height: number +} + +export type LabelDetection = { + box: LabelBox + score: number +} + +const WHITE_LUMA = 148 +const MAX_CHROMA = 86 +const MAX_DETECTIONS = 24 + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)) +} + +function overlap(left: LabelBox, right: LabelBox) { + const x1 = Math.max(left.x, right.x) + const y1 = Math.max(left.y, right.y) + const x2 = Math.min(left.x + left.width, right.x + right.width) + const y2 = Math.min(left.y + left.height, right.y + right.height) + const intersection = Math.max(0, x2 - x1) * Math.max(0, y2 - y1) + const union = left.width * left.height + right.width * right.height - intersection + return union > 0 ? intersection / union : 0 +} + +export function boxIou(left: LabelBox, right: LabelBox) { + return overlap(left, right) +} + +export function boxCenterDistance(left: LabelBox, right: LabelBox) { + const leftX = left.x + left.width / 2 + const leftY = left.y + left.height / 2 + const rightX = right.x + right.width / 2 + const rightY = right.y + right.height / 2 + return Math.hypot(leftX - rightX, leftY - rightY) +} + +export function boxSizeDifference(left: LabelBox, right: LabelBox) { + const leftArea = left.width * left.height + const rightArea = right.width * right.height + if (!leftArea || !rightArea) return 1 + return Math.abs(leftArea - rightArea) / Math.max(leftArea, rightArea) +} + +export function detectWhiteLabelBoxes(video: HTMLVideoElement): LabelDetection[] { + const sourceWidth = video.videoWidth + const sourceHeight = video.videoHeight + if (!sourceWidth || !sourceHeight) return [] + + const scale = Math.min(1, 320 / sourceWidth, 480 / sourceHeight) + const width = Math.max(1, Math.round(sourceWidth * scale)) + const height = Math.max(1, Math.round(sourceHeight * scale)) + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + const context = canvas.getContext('2d', { willReadFrequently: true }) + if (!context) return [] + + context.drawImage(video, 0, 0, width, height) + const pixels = context.getImageData(0, 0, width, height).data + const mask = new Uint8Array(width * height) + + for (let index = 0; index < mask.length; index += 1) { + const offset = index * 4 + const red = pixels[offset] + const green = pixels[offset + 1] + const blue = pixels[offset + 2] + const max = Math.max(red, green, blue) + const min = Math.min(red, green, blue) + const luma = red * 0.299 + green * 0.587 + blue * 0.114 + if (luma >= WHITE_LUMA && max - min <= MAX_CHROMA) mask[index] = 1 + } + + const visited = new Uint8Array(mask.length) + const queue = new Int32Array(mask.length) + const detections: LabelDetection[] = [] + const frameArea = width * height + + const enqueue = (next: number, tail: number) => { + if (next < 0 || next >= mask.length || visited[next] || !mask[next]) return tail + visited[next] = 1 + queue[tail] = next + return tail + 1 + } + + for (let start = 0; start < mask.length; start += 1) { + if (!mask[start] || visited[start]) continue + + let head = 0 + let tail = 0 + queue[tail++] = start + visited[start] = 1 + let count = 0 + let minX = width + let minY = height + let maxX = 0 + let maxY = 0 + + while (head < tail) { + const index = queue[head++] + const x = index % width + const y = Math.floor(index / width) + count += 1 + minX = Math.min(minX, x) + minY = Math.min(minY, y) + maxX = Math.max(maxX, x) + maxY = Math.max(maxY, y) + + if (x > 0) tail = enqueue(index - 1, tail) + if (x < width - 1) tail = enqueue(index + 1, tail) + if (y > 0) tail = enqueue(index - width, tail) + if (y < height - 1) tail = enqueue(index + width, tail) + } + + const boxWidth = maxX - minX + 1 + const boxHeight = maxY - minY + 1 + const area = boxWidth * boxHeight + const normalizedArea = area / frameArea + const aspect = boxWidth / Math.max(1, boxHeight) + const fill = count / Math.max(1, area) + + // Keep the detector permissive enough for pallet-distance labels, but drop + // tiny highlights and near-square bright patches before they become tracks. + // These geometry limits are deliberately independent of OCR validity. + if (normalizedArea < 0.00045 || normalizedArea > 0.28) continue + if (aspect < 1.35 || aspect > 4.8) continue + if (fill < 0.34) continue + if (boxWidth < 9 || boxHeight < 5) continue + + const box: LabelBox = { + x: minX / width, + y: minY / height, + width: boxWidth / width, + height: boxHeight / height, + } + const centeredness = 1 - Math.min(1, boxCenterDistance(box, { x: 0.25, y: 0.25, width: 0.5, height: 0.5 })) + detections.push({ box, score: fill * 0.75 + centeredness * 0.25 }) + } + + return detections + .sort((left, right) => right.score - left.score) + .filter((candidate, index, all) => all.slice(0, index).every((kept) => overlap(candidate.box, kept.box) < 0.42)) + .slice(0, MAX_DETECTIONS) +} + +export async function cropDetectedLabel(video: HTMLVideoElement, box: LabelBox): Promise { + const sourceWidth = video.videoWidth + const sourceHeight = video.videoHeight + if (!sourceWidth || !sourceHeight) throw new Error('Camera is not ready yet.') + + const marginX = box.width * 0.08 + const marginY = box.height * 0.12 + const x = clamp(box.x - marginX, 0, 1) + const y = clamp(box.y - marginY, 0, 1) + const right = clamp(box.x + box.width + marginX, 0, 1) + const bottom = clamp(box.y + box.height + marginY, 0, 1) + const cropWidth = Math.max(1, Math.round((right - x) * sourceWidth)) + const cropHeight = Math.max(1, Math.round((bottom - y) * sourceHeight)) + const sourceX = Math.round(x * sourceWidth) + const sourceY = Math.round(y * sourceHeight) + const outputScale = Math.min(2.2, 1200 / cropWidth) + + const canvas = document.createElement('canvas') + canvas.width = Math.max(1, Math.round(cropWidth * outputScale)) + canvas.height = Math.max(1, Math.round(cropHeight * outputScale)) + const context = canvas.getContext('2d') + if (!context) throw new Error('Could not crop the carton label.') + + context.drawImage( + video, + sourceX, + sourceY, + cropWidth, + cropHeight, + 0, + 0, + canvas.width, + canvas.height, + ) + + const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.94)) + if (!blob) throw new Error('Could not crop the carton label.') + return blob +} diff --git a/packages/fieldlens-core/src/lib/cartonTrackAssociation.test.ts b/packages/fieldlens-core/src/lib/cartonTrackAssociation.test.ts new file mode 100644 index 0000000..d2c79bd --- /dev/null +++ b/packages/fieldlens-core/src/lib/cartonTrackAssociation.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' + +import { IDENTITY_HOMOGRAPHY } from './cartonArTracking' +import { associateLabelDetections, type TrackAssociationInput } from './cartonTrackAssociation' + +function track(x: number, status = 'tracking'): TrackAssociationInput { + const box = { x, y: 0.3, width: 0.08, height: 0.04 } + return { box, anchorBox: box, missedFrames: 0, stableFrames: 4, status } +} + +describe('associateLabelDetections', () => { + it('does not let an ambiguous early detection steal a much better neighboring match', () => { + const tracks = [track(0.2, 'accepted'), track(0.31)] + const detections = [ + { box: { x: 0.27, y: 0.301, width: 0.08, height: 0.04 }, score: 0.8 }, + { box: { x: 0.202, y: 0.299, width: 0.08, height: 0.04 }, score: 0.82 }, + ] + + const result = associateLabelDetections(tracks, detections, IDENTITY_HOMOGRAPHY) + const byTrack = new Map(result.matches.map((match) => [match.trackIndex, match.detectionIndex])) + + expect(byTrack.get(0)).toBe(1) + expect(byTrack.get(1)).toBe(0) + }) + + it('leaves implausibly distant detections unmatched instead of swapping tracks', () => { + const tracks = [track(0.2)] + const detections = [ + { box: { x: 0.42, y: 0.3, width: 0.08, height: 0.04 }, score: 0.9 }, + ] + + const result = associateLabelDetections(tracks, detections, IDENTITY_HOMOGRAPHY) + + expect(result.matches).toHaveLength(0) + expect(result.unmatchedTrackIndices).toEqual([0]) + expect(result.unmatchedDetectionIndices).toEqual([0]) + }) +}) diff --git a/packages/fieldlens-core/src/lib/cartonTrackAssociation.ts b/packages/fieldlens-core/src/lib/cartonTrackAssociation.ts new file mode 100644 index 0000000..12a8e12 --- /dev/null +++ b/packages/fieldlens-core/src/lib/cartonTrackAssociation.ts @@ -0,0 +1,129 @@ +import { + boxCenterDistance, + boxIou, + boxSizeDifference, + type LabelBox, + type LabelDetection, +} from './cartonLabelDetection' +import { projectBox, type Homography } from './cartonArTracking' + +export type TrackAssociationInput = { + box: LabelBox + anchorBox: LabelBox + missedFrames: number + stableFrames: number + status: string +} + +export type TrackAssociationMatch = { + trackIndex: number + detectionIndex: number + expectedBox: LabelBox + score: number + iou: number + normalizedDistance: number + sizeDifference: number +} + +type CandidateMatch = TrackAssociationMatch + +function aspectDifference(left: LabelBox, right: LabelBox) { + const leftAspect = left.width / Math.max(0.0001, left.height) + const rightAspect = right.width / Math.max(0.0001, right.height) + return Math.abs(Math.log(Math.max(0.0001, leftAspect) / Math.max(0.0001, rightAspect))) +} + +function matchRadius(box: LabelBox, missedFrames: number) { + const diagonal = Math.hypot(box.width, box.height) + const base = Math.max(0.024, Math.min(0.068, diagonal * 0.58)) + return base * (1 + Math.min(2, missedFrames) * 0.18) +} + +function persistenceBonus(track: TrackAssociationInput) { + const stable = Math.min(track.stableFrames, 6) * 0.035 + if (track.status === 'accepted') return stable + 0.18 + if (track.status === 'queued' || track.status === 'reading') return stable + 0.08 + return stable +} + +export function associateLabelDetections( + tracks: TrackAssociationInput[], + detections: LabelDetection[], + transform: Homography, +) { + const candidates: CandidateMatch[] = [] + + tracks.forEach((track, trackIndex) => { + const expectedBox = track.missedFrames > 0 ? projectBox(transform, track.anchorBox) : track.box + const radius = matchRadius(expectedBox, track.missedFrames) + const maxSizeDifference = track.missedFrames > 0 ? 0.5 : 0.42 + + detections.forEach((detection, detectionIndex) => { + const iou = boxIou(expectedBox, detection.box) + const distance = boxCenterDistance(expectedBox, detection.box) + const sizeDifference = boxSizeDifference(expectedBox, detection.box) + const shapeDifference = aspectDifference(expectedBox, detection.box) + + if (sizeDifference > maxSizeDifference || shapeDifference > 0.42) return + if (iou < 0.12 && distance > radius) return + + const normalizedDistance = distance / Math.max(0.001, radius) + const score = + iou * 4 + - normalizedDistance * 1.65 + - sizeDifference * 0.9 + - shapeDifference * 0.45 + + detection.score * 0.18 + + persistenceBonus(track) + + candidates.push({ + trackIndex, + detectionIndex, + expectedBox, + score, + iou, + normalizedDistance, + sizeDifference, + }) + }) + }) + + candidates.sort((left, right) => right.score - left.score) + + const bestByTrack = new Map() + const bestByDetection = new Map() + candidates.forEach((candidate) => { + if (!bestByTrack.has(candidate.trackIndex)) bestByTrack.set(candidate.trackIndex, candidate) + if (!bestByDetection.has(candidate.detectionIndex)) bestByDetection.set(candidate.detectionIndex, candidate) + }) + + const usedTracks = new Set() + const usedDetections = new Set() + const matches: TrackAssociationMatch[] = [] + + const claim = (candidate: CandidateMatch) => { + if (usedTracks.has(candidate.trackIndex) || usedDetections.has(candidate.detectionIndex)) return false + usedTracks.add(candidate.trackIndex) + usedDetections.add(candidate.detectionIndex) + matches.push(candidate) + return true + } + + // Resolve unambiguous mutual-best pairs first. This prevents a merely decent + // early detection from stealing a track that another detection matches much better. + candidates.forEach((candidate) => { + if ( + bestByTrack.get(candidate.trackIndex) === candidate + && bestByDetection.get(candidate.detectionIndex) === candidate + ) claim(candidate) + }) + + // Fill remaining pairs globally by quality rather than detector iteration order. + candidates.forEach(claim) + + return { + matches, + unmatchedTrackIndices: tracks.map((_, index) => index).filter((index) => !usedTracks.has(index)), + unmatchedDetectionIndices: detections.map((_, index) => index).filter((index) => !usedDetections.has(index)), + } +} diff --git a/packages/fieldlens-core/src/lib/database.test.ts b/packages/fieldlens-core/src/lib/database.test.ts new file mode 100644 index 0000000..7cb2dd9 --- /dev/null +++ b/packages/fieldlens-core/src/lib/database.test.ts @@ -0,0 +1,116 @@ +import 'fake-indexeddb/auto' + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { + acknowledgeOutboxEvent, + clearScanHistory, + countDistinctScanValues, + database, + deleteScan, + failOutboxEvent, + getInstallationId, + getOutboxStats, + getPendingOutbox, + recordScan, + restoreScan, + serializeScanHistory, + startPalletLabelSession, +} from './database' + +describe('scan persistence', () => { + beforeEach(async () => { + await database.delete() + await database.open() + }) + + it('marks repeated values as duplicates inside storage', async () => { + const first = await recordScan('ABC-123', 'Code 128') + const second = await recordScan('ABC-123', 'Code 128') + + expect(first.isDuplicate).toBe(false) + expect(second.isDuplicate).toBe(true) + await expect(database.scans.count()).resolves.toBe(2) + }) + + it('writes the scan and durable outbox event together', async () => { + const scan = await recordScan('SYNC-ME', 'QR Code') + const pending = await getPendingOutbox() + + expect(pending).toHaveLength(1) + expect(pending[0]).toMatchObject({ + aggregateId: scan.id, + eventType: 'scan.captured', + attempts: 0, + schemaVersion: 1, + }) + }) + + it('creates one stable installation UUID per IndexedDB installation', async () => { + const first = await getInstallationId() + const second = await getInstallationId() + expect(second).toBe(first) + }) + + it('keeps failed events pending and applies retry backoff', async () => { + await recordScan('RETRY-ME', 'Code 128') + const [event] = await getPendingOutbox() + expect(event).toBeDefined() + await failOutboxEvent(event!.id, 'network unavailable') + + const stats = await getOutboxStats() + expect(stats.pending).toBe(1) + expect(stats.errors).toBe(1) + expect((await database.outbox.get(event!.id))?.attempts).toBe(1) + }) + + it('marks only explicitly acknowledged events synced', async () => { + await recordScan('ONE', 'Code 128') + await recordScan('TWO', 'Code 128') + const events = await database.outbox.toArray() + await acknowledgeOutboxEvent(events[0]!.id) + const stats = await getOutboxStats() + expect(stats.synced).toBe(1) + expect(stats.pending).toBe(1) + }) + + it('queues a local pallet session without any remote dependency', async () => { + const session = await startPalletLabelSession(160) + expect(session.status).toBe('active') + const event = await database.outbox.where('aggregateId').equals(session.id).first() + expect(event?.eventType).toBe('pallet.session.started') + }) + + it('preserves and compares decoded payloads exactly', async () => { + const spaced = await recordScan(' ABC-123 ', 'Code 128') + const plain = await recordScan('ABC-123', 'Code 128') + expect(spaced.rawValue).toBe(' ABC-123 ') + expect(plain.isDuplicate).toBe(false) + }) + + it('deletes and restores a scan record', async () => { + const scan = await recordScan('RESTORE-ME', 'QR Code') + await deleteScan(scan.id) + await expect(database.scans.get(scan.id)).resolves.toBeUndefined() + await restoreScan(scan) + await expect(database.scans.get(scan.id)).resolves.toEqual(scan) + }) + + it('counts remaining distinct values independently of historical duplicate flags', async () => { + const first = await recordScan('ABC', 'Code 128') + await recordScan('ABC', 'Code 128') + await deleteScan(first.id) + const remaining = await database.scans.toArray() + expect(remaining[0]?.isDuplicate).toBe(true) + expect(countDistinctScanValues(remaining)).toBe(1) + }) + + it('exports exact scan values and metadata as JSON', async () => { + const scan = await recordScan(' 00123\n', 'Code 128') + expect(JSON.parse(serializeScanHistory([scan]))).toEqual([scan]) + }) + + afterEach(async () => { + await clearScanHistory() + }) +}) diff --git a/packages/fieldlens-core/src/lib/database.ts b/packages/fieldlens-core/src/lib/database.ts new file mode 100644 index 0000000..6f8cf9b --- /dev/null +++ b/packages/fieldlens-core/src/lib/database.ts @@ -0,0 +1,386 @@ +import Dexie, { type EntityTable } from 'dexie' +import type { CartonLabelMajorityField, CartonLabelScan } from './cartonLabelConformity' +import type { LabelFields } from './labelOcr' + +export const FIELDLENS_SCHEMA_VERSION = 1 as const + +export type ScanRecord = { + id: string + rawValue: string + format: string + isDuplicate: boolean + scannedAt: string + syncStatus: 'local' | 'synced' +} + +export type EanPalletOcrRecord = { + ean: string + fields: LabelFields + rawText: string + confidence: number + comparisonSource: string + comparisonStatus: 'not-run' | 'pass' | 'fail' | 'review' +} + +export type EanPalletSessionRecord = { + id: string + targetCount: number + scans: string[] + startedAt: string + updatedAt: string + completedAt?: string + status: 'active' | 'complete' + conforms?: boolean + consensusEan?: string + uniqueEanCount?: number + ocr?: EanPalletOcrRecord +} + +export type PalletLabelSessionRecord = { + id: string + targetCount: number + labels: CartonLabelScan[] + startedAt: string + updatedAt: string + completedAt?: string + status: 'active' | 'complete' + conforms?: boolean + resultStatus?: 'pass' | 'fail' | 'review' + mismatchCartons?: number + majorityFields?: CartonLabelMajorityField[] +} + +export type OutboxRecord = { + id: string + aggregateId: string + eventType: 'scan.captured' | 'pallet.session.started' | 'pallet.observation.captured' | 'pallet.session.completed' + schemaVersion: number + payload: unknown + createdAt: string + attempts: number + nextAttemptAt?: string + syncedAt?: string + lastError?: string +} + +export type MetadataRecord = { + key: string + value: unknown +} + +class FieldLensDatabase extends Dexie { + scans!: EntityTable + eanPalletSessions!: EntityTable + palletLabelSessions!: EntityTable + outbox!: EntityTable + metadata!: EntityTable + + constructor() { + super('fieldlens-pwa') + this.version(1).stores({ + scans: 'id, rawValue, format, isDuplicate, scannedAt, syncStatus', + }) + this.version(2).stores({ + scans: 'id, rawValue, format, isDuplicate, scannedAt, syncStatus', + eanPalletSessions: 'id, startedAt, updatedAt, completedAt, status', + }) + this.version(3).stores({ + scans: 'id, rawValue, format, isDuplicate, scannedAt, syncStatus', + eanPalletSessions: 'id, startedAt, updatedAt, completedAt, status', + palletLabelSessions: 'id, startedAt, updatedAt, completedAt, status', + }) + this.version(4).stores({ + scans: 'id, rawValue, format, isDuplicate, scannedAt, syncStatus', + eanPalletSessions: 'id, startedAt, updatedAt, completedAt, status', + palletLabelSessions: 'id, startedAt, updatedAt, completedAt, status', + outbox: 'id, aggregateId, eventType, createdAt, attempts, nextAttemptAt, syncedAt', + metadata: 'key', + }) + } +} + +export const database = new FieldLensDatabase() + +function outboxEvent( + aggregateId: string, + eventType: OutboxRecord['eventType'], + payload: unknown, + createdAt = new Date().toISOString(), +): OutboxRecord { + return { + id: crypto.randomUUID(), + aggregateId, + eventType, + schemaVersion: FIELDLENS_SCHEMA_VERSION, + payload, + createdAt, + attempts: 0, + } +} + +export async function recordScan(rawValue: string, format: string) { + return database.transaction('rw', database.scans, database.outbox, async () => { + const existing = await database.scans.where('rawValue').equals(rawValue).first() + const nextRecord: ScanRecord = { + id: crypto.randomUUID(), + rawValue, + format, + isDuplicate: Boolean(existing), + scannedAt: new Date().toISOString(), + syncStatus: 'local', + } + + await database.scans.add(nextRecord) + await database.outbox.add(outboxEvent(nextRecord.id, 'scan.captured', { + id: nextRecord.id, + rawValue: nextRecord.rawValue, + format: nextRecord.format, + duplicate: nextRecord.isDuplicate, + capturedAt: nextRecord.scannedAt, + schemaVersion: FIELDLENS_SCHEMA_VERSION, + }, nextRecord.scannedAt)) + return nextRecord + }) +} + +export async function startEanPalletSession(targetCount: number) { + const now = new Date().toISOString() + const record: EanPalletSessionRecord = { + id: crypto.randomUUID(), + targetCount, + scans: [], + startedAt: now, + updatedAt: now, + status: 'active', + } + + await database.transaction('rw', database.eanPalletSessions, database.outbox, async () => { + await database.eanPalletSessions.add(record) + await database.outbox.add(outboxEvent(record.id, 'pallet.session.started', { + id: record.id, + workflowType: 'ean', + targetCount, + startedAt: now, + status: 'active', + schemaVersion: FIELDLENS_SCHEMA_VERSION, + }, now)) + }) + + return record +} + +export async function saveEanPalletScans(id: string, scans: string[]) { + const now = new Date().toISOString() + return database.transaction('rw', database.eanPalletSessions, database.outbox, async () => { + const existing = await database.eanPalletSessions.get(id) + const previousCount = existing?.scans.length ?? 0 + await database.eanPalletSessions.update(id, { scans, updatedAt: now }) + + for (let index = previousCount; index < scans.length; index += 1) { + const ean = scans[index] + if (!ean) continue + const observationId = crypto.randomUUID() + await database.outbox.add(outboxEvent(id, 'pallet.observation.captured', { + id: observationId, + palletSessionId: id, + cartonIndex: index + 1, + ean, + capturedAt: now, + schemaVersion: FIELDLENS_SCHEMA_VERSION, + }, now)) + } + }) +} + +export async function finishEanPalletSession( + id: string, + details: Pick, +) { + const now = new Date().toISOString() + return database.transaction('rw', database.eanPalletSessions, database.outbox, async () => { + const session = await database.eanPalletSessions.get(id) + const updated = { + ...details, + status: 'complete' as const, + completedAt: now, + updatedAt: now, + } + const result = await database.eanPalletSessions.update(id, updated) + await database.outbox.add(outboxEvent(id, 'pallet.session.completed', { + id, + workflowType: 'ean', + targetCount: session?.targetCount ?? 0, + startedAt: session?.startedAt ?? now, + completedAt: now, + status: 'complete', + conforms: details.conforms, + resultStatus: details.conforms ? 'pass' : 'fail', + consensusEan: details.consensusEan, + uniqueEanCount: details.uniqueEanCount, + schemaVersion: FIELDLENS_SCHEMA_VERSION, + }, now)) + return result + }) +} + +export function saveEanPalletOcr(id: string, ocr: EanPalletOcrRecord) { + return database.eanPalletSessions.update(id, { + ocr, + updatedAt: new Date().toISOString(), + }) +} + +export async function startPalletLabelSession(targetCount: number) { + const now = new Date().toISOString() + const record: PalletLabelSessionRecord = { + id: crypto.randomUUID(), + targetCount, + labels: [], + startedAt: now, + updatedAt: now, + status: 'active', + } + + await database.transaction('rw', database.palletLabelSessions, database.outbox, async () => { + await database.palletLabelSessions.add(record) + await database.outbox.add(outboxEvent(record.id, 'pallet.session.started', { + id: record.id, + workflowType: 'carton-label', + targetCount, + startedAt: now, + status: 'active', + schemaVersion: FIELDLENS_SCHEMA_VERSION, + }, now)) + }) + + return record +} + +export async function savePalletLabelScans(id: string, labels: CartonLabelScan[]) { + const now = new Date().toISOString() + return database.transaction('rw', database.palletLabelSessions, database.outbox, async () => { + const existing = await database.palletLabelSessions.get(id) + const previousCount = existing?.labels.length ?? 0 + await database.palletLabelSessions.update(id, { labels, updatedAt: now }) + + for (let index = previousCount; index < labels.length; index += 1) { + const label = labels[index] + if (!label) continue + await database.outbox.add(outboxEvent(id, 'pallet.observation.captured', { + id: label.id, + palletSessionId: id, + cartonIndex: index + 1, + fieldsJson: label.fields, + rawText: label.rawText, + confidence: label.confidence, + capturedAt: label.capturedAt, + schemaVersion: FIELDLENS_SCHEMA_VERSION, + }, label.capturedAt)) + } + }) +} + +export async function finishPalletLabelSession( + id: string, + details: Pick, +) { + const now = new Date().toISOString() + return database.transaction('rw', database.palletLabelSessions, database.outbox, async () => { + const session = await database.palletLabelSessions.get(id) + const updated = { + ...details, + status: 'complete' as const, + completedAt: now, + updatedAt: now, + } + const result = await database.palletLabelSessions.update(id, updated) + await database.outbox.add(outboxEvent(id, 'pallet.session.completed', { + id, + workflowType: 'carton-label', + targetCount: session?.targetCount ?? 0, + startedAt: session?.startedAt ?? now, + completedAt: now, + status: 'complete', + conforms: details.conforms, + resultStatus: details.resultStatus, + mismatchCartons: details.mismatchCartons, + majorityFields: details.majorityFields, + schemaVersion: FIELDLENS_SCHEMA_VERSION, + }, now)) + return result + }) +} + +export async function getInstallationId() { + return database.transaction('rw', database.metadata, async () => { + const existing = await database.metadata.get('installationId') + if (typeof existing?.value === 'string' && existing.value) return existing.value + const installationId = crypto.randomUUID() + await database.metadata.put({ key: 'installationId', value: installationId }) + return installationId + }) +} + +export async function setLocalMetadata(key: string, value: unknown) { + await database.metadata.put({ key, value }) +} + +export async function getLocalMetadata(key: string) { + return (await database.metadata.get(key))?.value as T | undefined +} + +export async function getPendingOutbox(limit = 50, now = new Date()) { + const records = await database.outbox.filter((record) => + !record.syncedAt && (!record.nextAttemptAt || new Date(record.nextAttemptAt) <= now), + ).toArray() + return records.sort((left, right) => left.createdAt.localeCompare(right.createdAt)).slice(0, limit) +} + +export async function getOutboxStats() { + const records = await database.outbox.toArray() + return { + pending: records.filter((record) => !record.syncedAt).length, + errors: records.filter((record) => !record.syncedAt && Boolean(record.lastError)).length, + synced: records.filter((record) => Boolean(record.syncedAt)).length, + } +} + +export async function acknowledgeOutboxEvent(id: string) { + await database.outbox.update(id, { + syncedAt: new Date().toISOString(), + lastError: undefined, + nextAttemptAt: undefined, + }) +} + +export async function failOutboxEvent(id: string, error: string) { + const record = await database.outbox.get(id) + if (!record || record.syncedAt) return + const attempts = record.attempts + 1 + const delayMs = Math.min(5 * 60_000, 2_000 * (2 ** Math.min(attempts - 1, 7))) + await database.outbox.update(id, { + attempts, + lastError: error.slice(0, 300), + nextAttemptAt: new Date(Date.now() + delayMs).toISOString(), + }) +} + +export function clearScanHistory() { + return database.scans.clear() +} + +export function deleteScan(id: string) { + return database.scans.delete(id) +} + +export function restoreScan(scan: ScanRecord) { + return database.scans.put(scan) +} + +export function countDistinctScanValues(scans: ScanRecord[]) { + return new Set(scans.map((scan) => scan.rawValue)).size +} + +export function serializeScanHistory(scans: ScanRecord[]) { + return JSON.stringify(scans, null, 2) +} diff --git a/packages/fieldlens-core/src/lib/deviceOrientation.test.ts b/packages/fieldlens-core/src/lib/deviceOrientation.test.ts new file mode 100644 index 0000000..7b06a26 --- /dev/null +++ b/packages/fieldlens-core/src/lib/deviceOrientation.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' + +import { rotateNormalizedRect } from './deviceOrientation' + +describe('rotateNormalizedRect', () => { + const rect = { x: 0.1, y: 0.2, width: 0.3, height: 0.15 } + + it('rotates an EAN detection 90 degrees clockwise in normalized camera space', () => { + expect(rotateNormalizedRect(rect, 90)).toEqual({ + x: 0.65, + y: 0.1, + width: 0.15, + height: 0.3, + }) + }) + + it('rotates an EAN detection 270 degrees clockwise in normalized camera space', () => { + const rotated = rotateNormalizedRect(rect, 270) + expect(rotated.x).toBeCloseTo(0.2) + expect(rotated.y).toBeCloseTo(0.6) + expect(rotated.width).toBeCloseTo(0.15) + expect(rotated.height).toBeCloseTo(0.3) + }) + + it('leaves automatic orientation coordinates unchanged', () => { + expect(rotateNormalizedRect(rect, 0)).toEqual(rect) + }) +}) diff --git a/packages/fieldlens-core/src/lib/deviceOrientation.ts b/packages/fieldlens-core/src/lib/deviceOrientation.ts new file mode 100644 index 0000000..7429200 --- /dev/null +++ b/packages/fieldlens-core/src/lib/deviceOrientation.ts @@ -0,0 +1,265 @@ +export type DeviceHoldOrientation = 'portrait' | 'landscape' +export type DetectionRotation = 0 | 90 | 270 + +export type NormalizedRect = { + x: number + y: number + width: number + height: number +} + +export const DEVICE_HOLD_EVENT = 'fieldlens:device-hold' +export const DETECTION_ROTATION_EVENT = 'fieldlens:detection-rotation' + +let detectionRotation: DetectionRotation = 0 +let detectionBoxTimer: number | null = null + +export function getDeviceHoldOrientation(): DeviceHoldOrientation { + const type = window.screen.orientation?.type + if (type?.startsWith('landscape')) return 'landscape' + if (type?.startsWith('portrait')) return 'portrait' + + const viewport = window.visualViewport + const width = viewport?.width ?? window.innerWidth + const height = viewport?.height ?? window.innerHeight + return width > height ? 'landscape' : 'portrait' +} + +export function getDetectionRotation() { + return detectionRotation +} + +export function rotateNormalizedRect(rect: NormalizedRect, rotation: DetectionRotation): NormalizedRect { + if (rotation === 90) { + return { + x: 1 - rect.y - rect.height, + y: rect.x, + width: rect.height, + height: rect.width, + } + } + + if (rotation === 270) { + return { + x: rect.y, + y: 1 - rect.x - rect.width, + width: rect.height, + height: rect.width, + } + } + + return rect +} + +function readPercent(value: string) { + if (!value.endsWith('%')) return null + const parsed = Number.parseFloat(value) + return Number.isFinite(parsed) ? parsed / 100 : null +} + +function writeBoxCoordinate(box: HTMLElement, property: string, value: number) { + const next = `${value * 100}%` + if (box.style.getPropertyValue(property) !== next) box.style.setProperty(property, next) +} + +function updateDetectionBoxCoordinates() { + if (detectionRotation === 0) return + + document.querySelectorAll('.carton-video-coordinate-space .carton-batch-box').forEach((box) => { + // React owns the raw detector coordinates in left/top/width/height. Keep + // those values untouched and publish corrected coordinates through custom + // properties so tracking/OCR continue to operate in camera space. + const x = readPercent(box.style.left) + const y = readPercent(box.style.top) + const width = readPercent(box.style.width) + const height = readPercent(box.style.height) + if (x === null || y === null || width === null || height === null) return + + const rotated = rotateNormalizedRect({ x, y, width, height }, detectionRotation) + writeBoxCoordinate(box, '--fieldlens-box-left', rotated.x) + writeBoxCoordinate(box, '--fieldlens-box-top', rotated.y) + writeBoxCoordinate(box, '--fieldlens-box-width', rotated.width) + writeBoxCoordinate(box, '--fieldlens-box-height', rotated.height) + }) +} + +function clearDetectionBoxCoordinates() { + document.querySelectorAll('.carton-video-coordinate-space .carton-batch-box').forEach((box) => { + box.style.removeProperty('--fieldlens-box-left') + box.style.removeProperty('--fieldlens-box-top') + box.style.removeProperty('--fieldlens-box-width') + box.style.removeProperty('--fieldlens-box-height') + }) +} + +function syncDetectionBoxTracking() { + if (detectionRotation === 0) { + if (detectionBoxTimer !== null) { + window.clearInterval(detectionBoxTimer) + detectionBoxTimer = null + } + clearDetectionBoxCoordinates() + return + } + + updateDetectionBoxCoordinates() + if (detectionBoxTimer === null) { + // Detector tracks update every ~360 ms. Sampling the rendered coordinates + // a little faster keeps the corrected boxes smooth without a DOM observer + // feedback loop. + detectionBoxTimer = window.setInterval(updateDetectionBoxCoordinates, 120) + } +} + +function updateDetectionButtons() { + document.querySelectorAll('.fieldlens-detection-rotate').forEach((button) => { + const nextRotation = String(detectionRotation) + const nextTitle = detectionRotation === 0 + ? 'Rotate detection positions if phone rotation lock is on' + : `Detection positions corrected ${detectionRotation}°. Tap to rotate again.` + const nextValue = detectionRotation === 0 ? 'AUTO' : `${detectionRotation}°` + + if (button.dataset.rotation !== nextRotation) button.dataset.rotation = nextRotation + if (button.title !== nextTitle) button.title = nextTitle + if (button.getAttribute('aria-label') !== nextTitle) button.setAttribute('aria-label', nextTitle) + + const value = button.querySelector('[data-detection-rotation-value]') + if (value && value.textContent !== nextValue) value.textContent = nextValue + }) +} + +function publishDetectionRotation() { + const nextRotation = String(detectionRotation) + if (document.documentElement.dataset.detectionRotation !== nextRotation) { + document.documentElement.dataset.detectionRotation = nextRotation + } + updateDetectionButtons() + syncDetectionBoxTracking() + window.dispatchEvent(new CustomEvent(DETECTION_ROTATION_EVENT, { detail: detectionRotation })) +} + +export function rotateDetectionOverlay() { + detectionRotation = detectionRotation === 0 ? 90 : detectionRotation === 90 ? 270 : 0 + publishDetectionRotation() + refreshCameraGeometry() + return detectionRotation +} + +export function resetDetectionRotation() { + if (detectionRotation === 0) return + detectionRotation = 0 + publishDetectionRotation() +} + +function publishDeviceHoldOrientation() { + const orientation = getDeviceHoldOrientation() + if (document.documentElement.dataset.deviceHold !== orientation) { + document.documentElement.dataset.deviceHold = orientation + } + window.dispatchEvent(new CustomEvent(DEVICE_HOLD_EVENT, { detail: orientation })) +} + +function refreshCameraGeometry() { + // iOS can announce a hold change before the video element exposes its new + // intrinsic dimensions. LabelCheckView recalculates its cover rectangle on + // resize; repeat that signal after the browser settles without touching the + // camera constraints, preview transform, or field of view. + window.requestAnimationFrame(() => { + updateDetectionBoxCoordinates() + window.dispatchEvent(new Event('resize')) + }) + window.setTimeout(() => { + updateDetectionBoxCoordinates() + window.dispatchEvent(new Event('resize')) + }, 120) + window.setTimeout(() => { + updateDetectionBoxCoordinates() + window.dispatchEvent(new Event('resize')) + }, 320) + window.setTimeout(() => { + updateDetectionBoxCoordinates() + window.dispatchEvent(new Event('resize')) + }, 650) +} + +function attachCameraBridges() { + const attachedVideos = new WeakSet() + let attachQueued = false + + const attach = () => { + let changed = false + + document.querySelectorAll('video').forEach((video) => { + if (attachedVideos.has(video)) return + attachedVideos.add(video) + video.addEventListener('resize', refreshCameraGeometry) + changed = true + }) + + document.querySelectorAll('.carton-batch-camera').forEach((camera) => { + if (camera.querySelector('.fieldlens-detection-rotate')) return + + const button = document.createElement('button') + button.className = 'fieldlens-detection-rotate' + button.type = 'button' + button.innerHTML = 'AUTO' + button.addEventListener('click', rotateDetectionOverlay) + camera.appendChild(button) + changed = true + }) + + if (changed) { + updateDetectionButtons() + updateDetectionBoxCoordinates() + } + } + + const scheduleAttach = () => { + if (attachQueued) return + attachQueued = true + window.requestAnimationFrame(() => { + attachQueued = false + attach() + }) + } + + attach() + const observer = new MutationObserver(scheduleAttach) + observer.observe(document.documentElement, { childList: true, subtree: true }) + + return () => observer.disconnect() +} + +export function startDeviceHoldTracking() { + const handlePhysicalOrientationChange = () => { + // When the browser really rotates, its own camera/display geometry becomes + // authoritative again. Manual correction is only for orientation-lock cases. + resetDetectionRotation() + publishDeviceHoldOrientation() + refreshCameraGeometry() + } + + const handleViewportChange = () => { + publishDeviceHoldOrientation() + refreshCameraGeometry() + } + + publishDeviceHoldOrientation() + publishDetectionRotation() + const detachCameraBridges = attachCameraBridges() + + window.addEventListener('orientationchange', handlePhysicalOrientationChange) + window.screen.orientation?.addEventListener?.('change', handlePhysicalOrientationChange) + window.visualViewport?.addEventListener('resize', handleViewportChange) + + return () => { + detachCameraBridges() + if (detectionBoxTimer !== null) { + window.clearInterval(detectionBoxTimer) + detectionBoxTimer = null + } + window.removeEventListener('orientationchange', handlePhysicalOrientationChange) + window.screen.orientation?.removeEventListener?.('change', handlePhysicalOrientationChange) + window.visualViewport?.removeEventListener('resize', handleViewportChange) + } +} diff --git a/packages/fieldlens-core/src/lib/eanPallet.test.ts b/packages/fieldlens-core/src/lib/eanPallet.test.ts new file mode 100644 index 0000000..4ce0ba3 --- /dev/null +++ b/packages/fieldlens-core/src/lib/eanPallet.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' + +import { extractEanFromScan, isValidEan, summarizeEanScans } from './eanPallet' + +describe('isValidEan', () => { + it('accepts valid EAN, UPC and GTIN check digits', () => { + expect(isValidEan('4006381333931')).toBe(true) + expect(isValidEan('96385074')).toBe(true) + expect(isValidEan('036000291452')).toBe(true) + expect(isValidEan('04006381333931')).toBe(true) + }) + + it('rejects invalid lengths and check digits', () => { + expect(isValidEan('4006381333932')).toBe(false) + expect(isValidEan('12345')).toBe(false) + }) +}) + +describe('extractEanFromScan', () => { + it('returns direct EAN values unchanged', () => { + expect(extractEanFromScan('4006381333931')).toBe('4006381333931') + expect(extractEanFromScan('96385074')).toBe('96385074') + }) + + it('canonicalizes UPC-A to EAN-13', () => { + expect(extractEanFromScan('036000291452')).toBe('0036000291452') + }) + + it('extracts a GS1 AI 01 GTIN from 2D and element-string payloads', () => { + expect(extractEanFromScan('(01)04006381333931(10)LOT123')).toBe('4006381333931') + expect(extractEanFromScan('https://example.com/01/04006381333931/10/LOT123')).toBe('4006381333931') + }) + + it('does not treat an unrelated marketing QR as an EAN', () => { + expect(extractEanFromScan('https://example.com/find-out-more')).toBe('') + }) +}) + +describe('summarizeEanScans', () => { + it('passes only when every scanned label has the same EAN', () => { + expect(summarizeEanScans(['4006381333931', '4006381333931', '4006381333931'])).toMatchObject({ + total: 3, + conforms: true, + consensusEan: '4006381333931', + counts: [{ ean: '4006381333931', count: 3 }], + }) + }) + + it('finds the majority EAN without assuming the first scan is correct', () => { + const summary = summarizeEanScans([ + '96385074', + '4006381333931', + '4006381333931', + '4006381333931', + ]) + + expect(summary.conforms).toBe(false) + expect(summary.consensusEan).toBe('4006381333931') + expect(summary.counts).toEqual([ + { ean: '4006381333931', count: 3 }, + { ean: '96385074', count: 1 }, + ]) + }) +}) diff --git a/packages/fieldlens-core/src/lib/eanPallet.ts b/packages/fieldlens-core/src/lib/eanPallet.ts new file mode 100644 index 0000000..a9e1858 --- /dev/null +++ b/packages/fieldlens-core/src/lib/eanPallet.ts @@ -0,0 +1,104 @@ +export type EanCount = { + ean: string + count: number +} + +export type EanPalletSummary = { + total: number + conforms: boolean + consensusEan: string + counts: EanCount[] +} + +const GTIN_LENGTHS = new Set([8, 12, 13, 14]) + +export function normalizeEan(value: string) { + return value.replace(/\D/g, '') +} + +function hasValidGtinCheckDigit(value: string) { + if (!GTIN_LENGTHS.has(value.length) || !/^\d+$/.test(value)) return false + + const digits = [...value].map(Number) + const checkDigit = digits.at(-1) ?? -1 + const body = digits.slice(0, -1) + const weightedTotal = [...body].reverse().reduce((total, digit, index) => total + digit * (index % 2 === 0 ? 3 : 1), 0) + const expectedCheckDigit = (10 - (weightedTotal % 10)) % 10 + return checkDigit === expectedCheckDigit +} + +export function canonicalizeEan(value: string) { + const gtin = normalizeEan(value) + if (!hasValidGtinCheckDigit(gtin)) return gtin + + // UPC-A is the same trade item number as EAN-13 with a leading zero. + if (gtin.length === 12) return `0${gtin}` + + // GS1 commonly encodes an EAN-13 as GTIN-14 with indicator digit zero. + if (gtin.length === 14 && gtin.startsWith('0')) return gtin.slice(1) + + return gtin +} + +export function isValidEan(value: string) { + return hasValidGtinCheckDigit(normalizeEan(value)) +} + +/** + * Extract a trade item code from the raw payload returned by a barcode reader. + * Supports direct EAN/UPC/GTIN values plus common GS1 QR, Data Matrix and Code 128 payloads. + * Returns a canonical EAN/GTIN value, or an empty string when the decoded symbol contains + * no usable trade item number. + */ +export function extractEanFromScan(rawValue: string) { + const raw = rawValue.trim() + if (!raw) return '' + + const compactNumeric = raw.replace(/[\s-]/g, '') + if (/^\d+$/.test(compactNumeric) && hasValidGtinCheckDigit(compactNumeric)) { + return canonicalizeEan(compactNumeric) + } + + // GS1 Application Identifier 01 carries a 14-digit GTIN. This covers element strings, + // Digital Link style URLs/query strings and scanner payloads containing the symbology prefix. + const gs1Candidates = [ + ...raw.matchAll(/\(01\)\s*(\d{14})/g), + ...raw.matchAll(/\/01\/(\d{14})(?:\D|$)/g), + ...raw.matchAll(/[?&]01=(\d{14})(?:\D|$)/g), + ...raw.matchAll(/01(\d{14})/g), + ] + + for (const match of gs1Candidates) { + const candidate = match[1] + if (candidate && hasValidGtinCheckDigit(candidate)) return canonicalizeEan(candidate) + } + + // Some 2D codes carry a larger text payload with the GTIN as a standalone field. + // Prefer longer GTIN forms and only accept candidates with a valid check digit. + const embedded = [...raw.matchAll(/(?:^|\D)(\d{12,14})(?!\d)/g)] + .map((match) => match[1]) + .filter((candidate): candidate is string => Boolean(candidate) && hasValidGtinCheckDigit(candidate)) + .sort((a, b) => b.length - a.length) + + return embedded[0] ? canonicalizeEan(embedded[0]) : '' +} + +export function summarizeEanScans(scans: string[]): EanPalletSummary { + const totals = new Map() + scans.forEach((value) => { + const ean = canonicalizeEan(value) + if (!ean) return + totals.set(ean, (totals.get(ean) ?? 0) + 1) + }) + + const counts = [...totals.entries()] + .map(([ean, count]) => ({ ean, count })) + .sort((a, b) => b.count - a.count || a.ean.localeCompare(b.ean)) + + return { + total: scans.length, + conforms: counts.length === 1 && scans.length > 0, + consensusEan: counts[0]?.ean ?? '', + counts, + } +} diff --git a/packages/fieldlens-core/src/lib/labelExpectationProviders.ts b/packages/fieldlens-core/src/lib/labelExpectationProviders.ts new file mode 100644 index 0000000..08b81fb --- /dev/null +++ b/packages/fieldlens-core/src/lib/labelExpectationProviders.ts @@ -0,0 +1,51 @@ +import type { LabelFields } from './labelOcr' + +export type ExpectedLabelSpec = { ean: string; fields: LabelFields } + +export type LabelExpectationLookup = { + sourceId: string + sourceLabel: string + rows: ExpectedLabelSpec[] + error?: string +} + +export type LabelExpectationProvider = { + id: string + label: string + isAvailable: () => boolean + lookup: (ean: string) => Promise +} + +const providers: LabelExpectationProvider[] = [] + +export function registerLabelExpectationProvider(provider: LabelExpectationProvider) { + const index = providers.findIndex((candidate) => candidate.id === provider.id) + if (index >= 0) providers.splice(index, 1, provider) + else providers.push(provider) + return () => unregisterLabelExpectationProvider(provider.id) +} + +export function unregisterLabelExpectationProvider(id: string) { + const index = providers.findIndex((provider) => provider.id === id) + if (index >= 0) providers.splice(index, 1) +} + +export function getAvailableLabelExpectationProviders() { + return providers.filter((provider) => provider.isAvailable()).map(({ id, label }) => ({ id, label })) +} + +export async function lookupExpectedLabels(ean: string): Promise { + const provider = providers.find((candidate) => candidate.isAvailable()) + if (!provider) return { sourceId: 'local', sourceLabel: 'Local only', rows: [] } + + try { + return { sourceId: provider.id, sourceLabel: provider.label, rows: await provider.lookup(ean) } + } catch (error) { + return { + sourceId: provider.id, + sourceLabel: provider.label, + rows: [], + error: error instanceof Error ? error.message : `${provider.label} lookup failed.`, + } + } +} diff --git a/packages/fieldlens-core/src/lib/labelOcr.test.ts b/packages/fieldlens-core/src/lib/labelOcr.test.ts new file mode 100644 index 0000000..6e561f4 --- /dev/null +++ b/packages/fieldlens-core/src/lib/labelOcr.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest' + +import { + compareLabelFields, + getCartonLabelIdentity, + hasCartonLabelIdentity, + isValidLabelFieldValue, + parseLabelText, + sanitizeLabelFields, + type LabelFields, +} from './labelOcr' + +describe('parseLabelText', () => { + it('extracts the carton label fields used by the label-check flow', () => { + const fields = parseLabelText(` +Variety: Hass Grade: CLASS 1 +Grower: 34000 Packer: PK0405 +Pack Date: 8/09/26 Pick Date: 5/09/26 +Count: 24 Size: 24 +Region Northland +TempPC: 40594960 +`) + + expect(fields).toEqual({ + ppin: '', + grower: '34000', + variety: 'Hass', + grade: 'CLASS 1', + packer: 'PK0405', + pickDate: '05/09/2026', + packDate: '08/09/2026', + size: '24', + count: '24', + region: 'Northland', + tempPc: '40594960', + }) + }) + + it('extracts PPIN and two-column white carton label values', () => { + const fields = parseLabelText(` +Variety: HASS AVOCADO Count: 48 +Grade: CLASS 1 Packer: PK0405 +Pick Date: 03/09/26 Pack Date: 07/09/26 +Size: 24 PPIN: 04809 +`) + + expect(fields).toMatchObject({ + ppin: '04809', + variety: 'HASS AVOCADO', + grade: 'CLASS 1', + packer: 'PK0405', + pickDate: '03/09/2026', + packDate: '07/09/2026', + size: '24', + count: '48', + }) + }) + + it('accepts PPIN, grower, or strong carton structure as carton-label identity', () => { + const ppinFields = parseLabelText('PPIN: 123456 Variety: HASS Count: 48 Packer: PK0405 Size: 24') + const growerFields = parseLabelText('Grower No: 34000 Variety: HASS Count: 48') + const structuralFields = parseLabelText('Variety: HASS Count: 48') + const weakFields = parseLabelText('Size: 24 Count: 48') + + expect(getCartonLabelIdentity({ fields: ppinFields, rawText: 'PPIN: 123456 Variety: HASS Count: 48' })).toEqual({ kind: 'ppin', value: '123456' }) + expect(getCartonLabelIdentity({ fields: growerFields, rawText: 'Grower No: 34000 Variety: HASS Count: 48' })).toEqual({ kind: 'grower', value: '34000' }) + expect(getCartonLabelIdentity({ fields: structuralFields, rawText: 'Variety: HASS Count: 48' })).toEqual({ kind: 'structure', value: 'variety,count' }) + expect(getCartonLabelIdentity({ fields: weakFields, rawText: 'Size: 24 Count: 48' })).toBeNull() + }) + + it('keeps weak OCR retryable instead of prematurely marking a carton grey', () => { + const emptyFields = parseLabelText('') + const partialFields = parseLabelText('Size: 24 Count: 48') + const readableNonCarton = 'PROMOTIONAL SHIPPING NOTICE '.repeat(5) + + expect(hasCartonLabelIdentity({ fields: emptyFields, rawText: '' })).toBe(true) + expect(hasCartonLabelIdentity({ fields: partialFields, rawText: 'Size: 24 Count: 48' })).toBe(true) + expect(hasCartonLabelIdentity({ fields: parseLabelText(readableNonCarton), rawText: readableNonCarton })).toBe(false) + }) + + it('rejects partial identity and packer reads before conformity', () => { + expect(isValidLabelFieldValue('grower', '34')).toBe(false) + expect(isValidLabelFieldValue('packer', 'PKO40')).toBe(false) + expect(isValidLabelFieldValue('grower', '34000')).toBe(true) + expect(isValidLabelFieldValue('packer', 'PK0405')).toBe(true) + + const fields = parseLabelText('Grower: 34 Variety: HASS Grade: CLASS 1 Packer: PKO40 Size: 24') + expect(sanitizeLabelFields(fields)).toMatchObject({ grower: '', packer: '', variety: 'HASS', grade: 'CLASS 1', size: '24' }) + }) +}) + +describe('compareLabelFields', () => { + it('normalizes dates and text while preserving mismatches', () => { + const expected: LabelFields = { + ppin: '04809', + variety: 'HASS', + grower: '34000', + grade: 'CLASS 1', + packer: 'PK0405', + pickDate: '5/9/26', + packDate: '8/9/26', + size: '24', + count: '24', + region: 'Northland', + tempPc: '40594960', + } + const actual = { ...expected, packDate: '09/09/2026', pickDate: '05/09/2026' } + const comparisons = compareLabelFields(expected, actual) + + expect(comparisons.find((item) => item.field === 'pickDate')?.status).toBe('match') + expect(comparisons.find((item) => item.field === 'packDate')?.status).toBe('mismatch') + }) +}) diff --git a/packages/fieldlens-core/src/lib/labelOcr.ts b/packages/fieldlens-core/src/lib/labelOcr.ts new file mode 100644 index 0000000..5f61b59 --- /dev/null +++ b/packages/fieldlens-core/src/lib/labelOcr.ts @@ -0,0 +1,363 @@ +const TESSERACT_SCRIPT = 'https://cdn.jsdelivr.net/npm/tesseract.js@6/dist/tesseract.min.js' + +type TesseractProgress = { + status?: string + progress?: number +} + +type TesseractResult = { data: { text: string; confidence: number } } + +type TesseractWorker = { + recognize: (image: Blob | string) => Promise + setParameters?: (parameters: Record) => Promise + terminate?: () => Promise +} + +type TesseractApi = { + recognize: ( + image: Blob | string, + language?: string, + options?: { logger?: (message: TesseractProgress) => void }, + ) => Promise + createWorker?: ( + languages?: string | string[], + oem?: number, + options?: { logger?: (message: TesseractProgress) => void }, + ) => Promise +} + +type OcrWorkerSlot = { + worker: TesseractWorker + busy: boolean + progress: { current?: (progress: number, status: string) => void } +} + +declare global { + interface Window { + Tesseract?: TesseractApi + } +} + +export type LabelFieldKey = 'ppin' | 'grower' | 'variety' | 'grade' | 'packer' | 'pickDate' | 'packDate' | 'size' | 'count' | 'region' | 'tempPc' + +export type LabelFields = Record + +export type LabelOcrResult = { + fields: LabelFields + rawText: string + confidence: number +} + +export type LabelFieldComparison = { + field: LabelFieldKey + label: string + expected: string + actual: string + status: 'match' | 'mismatch' | 'unread' | 'ignored' +} + +export const LABEL_FIELD_KEYS: LabelFieldKey[] = ['ppin', 'grower', 'variety', 'grade', 'packer', 'pickDate', 'packDate', 'size', 'count', 'region', 'tempPc'] + +export const LABEL_FIELD_LABELS: Record = { + ppin: 'PPIN', + grower: 'Grower', + variety: 'Variety', + grade: 'Grade', + packer: 'Packer', + pickDate: 'Pick date', + packDate: 'Pack date', + size: 'Size', + count: 'Count', + region: 'Region', + tempPc: 'TempPC', +} + +let workerPoolPromise: Promise | null = null + +function emptyFields(): LabelFields { + return { + ppin: '', + grower: '', + variety: '', + grade: '', + packer: '', + pickDate: '', + packDate: '', + size: '', + count: '', + region: '', + tempPc: '', + } +} + +function capture(text: string, pattern: RegExp) { + return text.match(pattern)?.[1]?.trim().replace(/\s+/g, ' ') ?? '' +} + +function normalizeDate(value: string) { + const parts = value.trim().replace(/[.-]/g, '/').split('/') + if (parts.length !== 3) return value.trim() + const [day, month, year] = parts + const fullYear = year.length === 2 ? `20${year}` : year + return `${day.padStart(2, '0')}/${month.padStart(2, '0')}/${fullYear}` +} + +function validDate(value: string) { + const normalized = normalizeDate(value) + const match = normalized.match(/^(\d{2})\/(\d{2})\/(\d{4})$/) + if (!match) return false + const day = Number(match[1]) + const month = Number(match[2]) + const year = Number(match[3]) + const date = new Date(Date.UTC(year, month - 1, day)) + return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day +} + +export function normalizeLabelFieldValue(field: LabelFieldKey, value: string) { + if (!value) return '' + if (field === 'pickDate' || field === 'packDate') return normalizeDate(value) + if (field === 'tempPc' || field === 'count') return value.replace(/\D/g, '') + if (field === 'size') return value.replace(/\s/g, '').toUpperCase() + return value.toUpperCase().replace(/[^A-Z0-9]/g, '') +} + +export function isValidLabelFieldValue(field: LabelFieldKey, value: string) { + const trimmed = value.trim() + if (!trimmed) return false + const normalized = normalizeLabelFieldValue(field, trimmed) + + if (field === 'ppin' || field === 'grower') return /^[A-Z0-9-]{4,16}$/.test(trimmed.toUpperCase()) + if (field === 'packer') { + if (normalized.startsWith('PK')) return /^PK[A-Z0-9]{4,8}$/.test(normalized) + return /^[A-Z0-9]{5,12}$/.test(normalized) + } + if (field === 'grade') return /^(?:CLASS)?[123]$/.test(normalized) + if (field === 'pickDate' || field === 'packDate') return validDate(trimmed) + if (field === 'count') { + const count = Number(normalized) + return /^\d{1,3}$/.test(normalized) && count >= 1 && count <= 999 + } + if (field === 'size') return /^\d{1,3}(?:\/\d{1,3})?$/.test(normalized) + if (field === 'tempPc') return /^\d{7,10}$/.test(normalized) + if (field === 'variety') return normalized.length >= 3 && normalized.length <= 32 + if (field === 'region') return normalized.length >= 2 && normalized.length <= 30 + return true +} + +export function sanitizeLabelFields(fields: LabelFields): LabelFields { + return LABEL_FIELD_KEYS.reduce((clean, field) => { + clean[field] = isValidLabelFieldValue(field, fields[field]) ? fields[field].trim() : '' + return clean + }, emptyFields()) +} + +export function getCartonLabelIdentity(ocr: Pick) { + const ppin = ocr.fields.ppin.trim() || capture(ocr.rawText, /\bP\s*P\s*I\s*N(?:\s*(?:No|Number))?\s*:?\s*([A-Z0-9-]+)/i) + if (isValidLabelFieldValue('ppin', ppin)) return { kind: 'ppin' as const, value: ppin } + + const grower = ocr.fields.grower.trim() + if (isValidLabelFieldValue('grower', grower)) return { kind: 'grower' as const, value: grower } + + // A moving pallet sweep will occasionally lose the PPIN/grower token while + // still reading several unmistakable carton-label fields. Do not permanently + // grey those labels after a single weak identity read. Requiring at least one + // strong carton-specific field plus a second validated field keeps generic + // white stickers out while allowing the normal minimum-field gate to decide + // whether this read is accepted or retried. + const fields = sanitizeLabelFields(ocr.fields) + const evidenceFields = LABEL_FIELD_KEYS.filter((field) => field !== 'ppin' && field !== 'grower' && fields[field].trim()) + const strongEvidenceFields: LabelFieldKey[] = ['variety', 'grade', 'packer', 'pickDate', 'packDate', 'tempPc'] + const strongEvidence = strongEvidenceFields.filter((field) => fields[field].trim()) + if (strongEvidence.length >= 1 && evidenceFields.length >= 2) { + return { kind: 'structure' as const, value: evidenceFields.join(',') } + } + + return null +} + +export function hasCartonLabelIdentity(ocr: Pick) { + if (getCartonLabelIdentity(ocr)) return true + + const fields = sanitizeLabelFields(ocr.fields) + const evidenceFields = LABEL_FIELD_KEYS.filter((field) => field !== 'ppin' && field !== 'grower' && fields[field].trim()) + if (evidenceFields.length > 0) return true + + // A weak crop or motion blur commonly produces little or no usable OCR text. + // Treat that as uncertain and let the scanner retry instead of permanently + // painting a real carton label grey. Only confidently readable, substantial + // non-carton text should be classified as a non-target label. + const text = ocr.rawText.replace(/\s+/g, ' ').trim() + if (text.length < 96) return true + + return /\b(?:P\s*P\s*I\s*N|Grower|Variety|Grade|Packer|Pick\s*Date|Pack\s*Date|Size|Count|Region|Temp\s*P?C)\b/i.test(text) +} + +export function parseLabelText(rawText: string): LabelFields { + const text = rawText + .replace(/\r/g, '') + .replace(/[|]/g, ' ') + .replace(/\bPick\s*Dale\b/gi, 'Pick Date') + .replace(/\bPack\s*Dale\b/gi, 'Pack Date') + const fields = emptyFields() + const datePattern = '(\\d{1,2}[\\/.-]\\d{1,2}[\\/.-]\\d{2,4})' + const nextKey = '(?=\\s+(?:Grade|Grower(?:\\s*(?:No|Number|Code))?|PPIN(?:\\s*(?:No|Number))?|Packer|Pick\\s*Date|Pack\\s*Date|Size|Count|Region|Temp\\s*P?C|Variety)\\b|\\n|$)' + + fields.ppin = capture(text, /\bP\s*P\s*I\s*N(?:\s*(?:No|Number))?\s*:?\s*([A-Z0-9-]+)/i) + fields.grower = capture(text, /\bGrower(?:\s*(?:No|Number|Code))?\s*:?\s*([A-Z0-9-]+)/i) + fields.variety = capture(text, new RegExp(`\\bVariety\\s*:?\\s*([A-Za-z][A-Za-z0-9 -]{0,28}?)${nextKey}`, 'i')) + fields.grade = capture(text, /\bGrade\s*:?\s*(CLASS\s*[A-Z0-9]+)/i) + fields.packer = capture(text, /\bPacker\s*:?\s*([A-Z0-9-]+)/i) + fields.pickDate = normalizeDate(capture(text, new RegExp(`\\bPick\\s*Date\\s*:?\\s*${datePattern}`, 'i'))) + fields.packDate = normalizeDate(capture(text, new RegExp(`\\bPack\\s*Date\\s*:?\\s*${datePattern}`, 'i'))) + fields.size = capture(text, /\bSize\s*:?\s*([0-9]+(?:\s*\/\s*[0-9]+)?)/i).replace(/\s/g, '') + fields.count = capture(text, /\bCount\s*:?\s*([0-9]+)/i) + fields.region = capture(text, new RegExp(`\\bRegion\\s*:?\\s*([A-Za-z][A-Za-z ]{1,28}?)${nextKey}`, 'i')) + fields.tempPc = capture(text, /\bTemp\s*P?C\s*:?\s*([0-9]{7,10})/i) + + return fields +} + +export function compareLabelFields(expected: LabelFields, actual: LabelFields): LabelFieldComparison[] { + return LABEL_FIELD_KEYS.map((field) => { + const expectedValue = expected[field].trim() + const actualValue = actual[field].trim() + let status: LabelFieldComparison['status'] = 'ignored' + + if (expectedValue && !actualValue) status = 'unread' + else if (expectedValue && actualValue) { + status = normalizeLabelFieldValue(field, expectedValue) === normalizeLabelFieldValue(field, actualValue) ? 'match' : 'mismatch' + } + + return { + field, + label: LABEL_FIELD_LABELS[field], + expected: expectedValue, + actual: actualValue, + status, + } + }) +} + +function loadTesseract() { + if (window.Tesseract) return Promise.resolve(window.Tesseract) + + return new Promise((resolve, reject) => { + const existing = document.querySelector(`script[src="${TESSERACT_SCRIPT}"]`) + const script = existing ?? document.createElement('script') + const handleLoad = () => window.Tesseract ? resolve(window.Tesseract) : reject(new Error('OCR engine did not initialize.')) + const handleError = () => reject(new Error('OCR engine could not be loaded. Check the network connection.')) + + script.addEventListener('load', handleLoad, { once: true }) + script.addEventListener('error', handleError, { once: true }) + + if (!existing) { + script.src = TESSERACT_SCRIPT + script.async = true + document.head.append(script) + } + }) +} + +async function createWorkerPool() { + const tesseract = await loadTesseract() + if (!tesseract.createWorker) return [] + + const workerCount = (navigator.hardwareConcurrency ?? 2) >= 4 ? 2 : 1 + return Promise.all(Array.from({ length: workerCount }, async () => { + const progress: OcrWorkerSlot['progress'] = {} + const worker = await tesseract.createWorker?.('eng', 1, { + logger: (message) => { + if (typeof message.progress === 'number') { + progress.current?.(Math.round(message.progress * 100), message.status ?? 'Reading label') + } + }, + }) + if (!worker) throw new Error('OCR worker could not be created.') + await worker.setParameters?.({ + tessedit_pageseg_mode: '6', + preserve_interword_spaces: '1', + }) + return { worker, busy: false, progress } + })) +} + +async function getWorkerPool() { + if (!workerPoolPromise) { + workerPoolPromise = createWorkerPool().catch((error) => { + workerPoolPromise = null + throw error + }) + } + return workerPoolPromise +} + +async function acquireWorker() { + const slots = await getWorkerPool() + if (!slots.length) return null + + while (true) { + const slot = slots.find((candidate) => !candidate.busy) + if (slot) { + slot.busy = true + return slot + } + await new Promise((resolve) => window.setTimeout(resolve, 20)) + } +} + +export async function warmLabelOcr() { + await getWorkerPool() +} + +async function prepareImage(image: Blob): Promise { + if (typeof createImageBitmap !== 'function') return image + + try { + const bitmap = await createImageBitmap(image) + const maxDimension = 1200 + const scale = Math.min(1, maxDimension / Math.max(bitmap.width, bitmap.height)) + const canvas = document.createElement('canvas') + canvas.width = Math.max(1, Math.round(bitmap.width * scale)) + canvas.height = Math.max(1, Math.round(bitmap.height * scale)) + const context = canvas.getContext('2d') + if (!context) return image + + context.filter = 'grayscale(1) contrast(1.5) brightness(1.04)' + context.drawImage(bitmap, 0, 0, canvas.width, canvas.height) + bitmap.close() + + const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.92)) + return blob ?? image + } catch { + return image + } +} + +export async function recognizeLabel(image: Blob, onProgress?: (progress: number, status: string) => void): Promise { + const prepared = await prepareImage(image) + const tesseract = await loadTesseract() + const slot = await acquireWorker().catch(() => null) + let result: TesseractResult + + if (slot) { + slot.progress.current = onProgress + try { + result = await slot.worker.recognize(prepared) + } finally { + slot.progress.current = undefined + slot.busy = false + } + } else { + result = await tesseract.recognize(prepared, 'eng', { + logger: (message) => { + if (typeof message.progress === 'number') onProgress?.(Math.round(message.progress * 100), message.status ?? 'Reading label') + }, + }) + } + + return { + fields: parseLabelText(result.data.text), + rawText: result.data.text, + confidence: result.data.confidence, + } +} diff --git a/packages/fieldlens-core/src/lib/preferences.test.ts b/packages/fieldlens-core/src/lib/preferences.test.ts new file mode 100644 index 0000000..ddb1c6f --- /dev/null +++ b/packages/fieldlens-core/src/lib/preferences.test.ts @@ -0,0 +1,33 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { defaultScannerPreferences, loadScannerPreferences, resetScannerPreferences, saveScannerPreferences } from './preferences' + +const values = new Map() +vi.stubGlobal('localStorage', { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + removeItem: (key: string) => values.delete(key), +}) + +describe('scanner preferences', () => { + afterEach(() => values.clear()) + + it('round-trips scanner settings', () => { + const preferences = { + ...defaultScannerPreferences, + autoAdvance: false, + confirmationFrames: 3 as const, + payloadRules: { enabled: true, expectedLength: 12, requiredPrefix: 'FL-' }, + } + + saveScannerPreferences(preferences) + + expect(loadScannerPreferences()).toEqual(preferences) + }) + + it('falls back to defaults for invalid stored data', () => { + localStorage.setItem('fieldlens-scanner-preferences', '{invalid') + expect(loadScannerPreferences()).toEqual(defaultScannerPreferences) + expect(resetScannerPreferences()).toEqual(defaultScannerPreferences) + }) +}) \ No newline at end of file diff --git a/packages/fieldlens-core/src/lib/preferences.ts b/packages/fieldlens-core/src/lib/preferences.ts new file mode 100644 index 0000000..58e05ea --- /dev/null +++ b/packages/fieldlens-core/src/lib/preferences.ts @@ -0,0 +1,50 @@ +import type { PayloadRules, ScanFormatMode } from './barcodes' + +export interface ScannerPreferences { + autoAdvance: boolean + soundEnabled: boolean + formatMode: ScanFormatMode + confirmationFrames: 2 | 3 + minimumSize: number + payloadRules: PayloadRules +} + +const STORAGE_KEY = 'fieldlens-scanner-preferences' + +export const defaultScannerPreferences: ScannerPreferences = { + autoAdvance: true, + soundEnabled: false, + formatMode: 'standard', + confirmationFrames: 2, + minimumSize: 6, + payloadRules: { enabled: true, expectedLength: 0, requiredPrefix: '' }, +} + +export function loadScannerPreferences(): ScannerPreferences { + try { + const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as Partial + return { + autoAdvance: typeof stored.autoAdvance === 'boolean' ? stored.autoAdvance : defaultScannerPreferences.autoAdvance, + soundEnabled: typeof stored.soundEnabled === 'boolean' ? stored.soundEnabled : defaultScannerPreferences.soundEnabled, + formatMode: stored.formatMode === 'standard' || stored.formatMode === 'products' || stored.formatMode === 'matrix' || stored.formatMode === 'all' ? stored.formatMode : defaultScannerPreferences.formatMode, + confirmationFrames: stored.confirmationFrames === 3 ? 3 : 2, + minimumSize: typeof stored.minimumSize === 'number' ? Math.min(20, Math.max(0, stored.minimumSize)) : defaultScannerPreferences.minimumSize, + payloadRules: { + enabled: typeof stored.payloadRules?.enabled === 'boolean' ? stored.payloadRules.enabled : defaultScannerPreferences.payloadRules.enabled, + expectedLength: typeof stored.payloadRules?.expectedLength === 'number' ? Math.min(256, Math.max(0, stored.payloadRules.expectedLength)) : defaultScannerPreferences.payloadRules.expectedLength, + requiredPrefix: typeof stored.payloadRules?.requiredPrefix === 'string' ? stored.payloadRules.requiredPrefix : defaultScannerPreferences.payloadRules.requiredPrefix, + }, + } + } catch { + return structuredClone(defaultScannerPreferences) + } +} + +export function saveScannerPreferences(preferences: ScannerPreferences) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(preferences)) +} + +export function resetScannerPreferences() { + localStorage.removeItem(STORAGE_KEY) + return structuredClone(defaultScannerPreferences) +} diff --git a/packages/fieldlens-core/src/lib/scanAudio.ts b/packages/fieldlens-core/src/lib/scanAudio.ts new file mode 100644 index 0000000..272323f --- /dev/null +++ b/packages/fieldlens-core/src/lib/scanAudio.ts @@ -0,0 +1,15 @@ +let scanAudioContext: AudioContext | null = null + +export function enableScanAudio() { + scanAudioContext ??= new AudioContext() + void scanAudioContext.resume() + return scanAudioContext +} + +export function disableScanAudio() { + void scanAudioContext?.suspend() +} + +export function getScanAudio() { + return scanAudioContext +} \ No newline at end of file diff --git a/packages/fieldlens-core/src/main.tsx b/packages/fieldlens-core/src/main.tsx new file mode 100644 index 0000000..45c888a --- /dev/null +++ b/packages/fieldlens-core/src/main.tsx @@ -0,0 +1,17 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' +import { startDeviceHoldTracking } from './lib/deviceOrientation' +import './ui-polish.css' +import './ios-safe-area.css' +import './phone-landscape.css' +import './detection-rotation.css' + +startDeviceHoldTracking() + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/packages/fieldlens-core/src/phone-landscape.css b/packages/fieldlens-core/src/phone-landscape.css new file mode 100644 index 0000000..5d4ce9d --- /dev/null +++ b/packages/fieldlens-core/src/phone-landscape.css @@ -0,0 +1,173 @@ +/* Keep a phone in the mobile UI family when its wide landscape viewport crosses + * the normal 760px desktop breakpoint. Camera capture is untouched; only layout + * classification changes. Device hold is published by deviceOrientation.ts. */ +@media (pointer: coarse) and (orientation: landscape) and (max-height: 500px) { + html[data-device-hold='landscape'] .app-shell { + display: block; + padding-bottom: calc(58px + env(safe-area-inset-bottom)); + } + + html[data-device-hold='landscape'] .sidebar { + display: none; + } + + html[data-device-hold='landscape'] main { + width: 100%; + min-width: 0; + } + + html[data-device-hold='landscape'] .mobile-nav { + position: fixed; + z-index: 20; + left: 0; + right: 0; + bottom: 0; + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + height: calc(58px + env(safe-area-inset-bottom)); + padding: 0 max(8px, env(safe-area-inset-right)) env(safe-area-inset-bottom) max(8px, env(safe-area-inset-left)); + border-top: 1px solid #34413d; + background: var(--ink); + } + + html[data-device-hold='landscape'] .mobile-nav button { + display: grid; + min-width: 0; + place-items: center; + align-content: center; + gap: 2px; + padding: 0 2px; + border: 0; + color: #8f9a96; + background: transparent; + font-size: 7px; + font-weight: 800; + } + + html[data-device-hold='landscape'] .mobile-nav button.active { + color: white; + } + + html[data-device-hold='landscape'] .mobile-nav button.active svg { + color: var(--yellow); + } + + html[data-device-hold='landscape'] .workspace-view { + width: 100%; + max-width: none; + padding: 12px max(12px, env(safe-area-inset-right)) 28px max(12px, env(safe-area-inset-left)); + } + + /* During an EAN sweep, preserve the same camera-first mobile composition used + * in portrait. Rotating the phone must not promote the page to desktop UI. */ + @supports selector(:has(*)) { + html[data-device-hold='landscape'] .carton-label-view:has(.carton-label-scan-panel) { + width: 100%; + max-width: none; + min-height: calc(100dvh - 58px - env(safe-area-inset-bottom)); + padding: env(safe-area-inset-top) 0 0 !important; + gap: 0; + background: var(--ink); + } + + html[data-device-hold='landscape'] .carton-label-view:has(.carton-label-scan-panel) .label-check-heading, + html[data-device-hold='landscape'] .carton-label-view:has(.carton-label-scan-panel) .label-stepper { + display: none; + } + + html[data-device-hold='landscape'] .carton-label-view:has(.carton-label-scan-panel) .carton-label-scan-panel { + border: 0; + border-radius: 0; + } + } + + html[data-device-hold='landscape'] .carton-section-head { + min-height: 46px; + padding: 6px max(10px, env(safe-area-inset-right)) 6px max(10px, env(safe-area-inset-left)); + } + + html[data-device-hold='landscape'] .carton-section-head strong { + margin-top: 1px; + font-size: 11px; + } + + html[data-device-hold='landscape'] .carton-section-head small { + margin-top: 1px; + font-size: 6.5px; + } + + html[data-device-hold='landscape'] .carton-section-head .quiet-button { + min-height: 32px; + padding: 0 9px; + font-size: 8px; + } + + html[data-device-hold='landscape'] .carton-batch-stats { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + html[data-device-hold='landscape'] .carton-batch-stats > div { + padding: 5px 8px; + } + + html[data-device-hold='landscape'] .carton-batch-stats span { + font-size: 5.5px; + } + + html[data-device-hold='landscape'] .carton-batch-stats strong { + font-size: 10px; + } + + html[data-device-hold='landscape'] .carton-label-camera { + min-height: max(180px, calc(100dvh - 196px - env(safe-area-inset-top) - env(safe-area-inset-bottom))) !important; + } + + html[data-device-hold='landscape'] .carton-batch-instruction { + top: 7px; + max-width: min(470px, calc(100% - 24px)); + padding: 6px 9px; + } + + html[data-device-hold='landscape'] .carton-batch-instruction strong { + font-size: 10px; + } + + html[data-device-hold='landscape'] .carton-batch-instruction small { + font-size: 6px; + } + + html[data-device-hold='landscape'] .carton-batch-instruction em { + margin-top: 3px; + font-size: 5.5px; + } + + html[data-device-hold='landscape'] .carton-live-fields { + top: 58px; + right: max(8px, env(safe-area-inset-right)); + width: min(30%, 210px); + max-height: 88px; + } + + html[data-device-hold='landscape'] .carton-batch-ocr-status { + left: max(8px, env(safe-area-inset-left)); + bottom: 8px; + width: min(210px, 32%); + } + + html[data-device-hold='landscape'] .carton-section-actions { + grid-template-columns: minmax(0, 1fr) auto auto; + gap: 6px; + padding: 6px max(10px, env(safe-area-inset-right)) 6px max(10px, env(safe-area-inset-left)); + } + + html[data-device-hold='landscape'] .carton-section-actions > div { + min-width: 0; + } + + html[data-device-hold='landscape'] .carton-section-actions .primary-button, + html[data-device-hold='landscape'] .carton-section-actions .quiet-button { + min-height: 36px; + padding: 0 10px; + font-size: 8px; + } +} diff --git a/packages/fieldlens-core/src/ui-polish.css b/packages/fieldlens-core/src/ui-polish.css new file mode 100644 index 0000000..6db6cf9 --- /dev/null +++ b/packages/fieldlens-core/src/ui-polish.css @@ -0,0 +1,161 @@ +/* Final responsive pass: keep operational screens dense, predictable and phone-first. */ + +body { overflow-x: clip; } + +/* Camera restart / scan-mode transition ------------------------------------------------ */ +.camera-stage video { transition: opacity .18s ease, filter .18s ease, transform .18s ease; } +.camera-stage.scanner-starting video { opacity: .48; filter: blur(5px) saturate(.65); transform: scale(1.015); } +.camera-stage.scanner-starting::before { + content: ''; + position: absolute; + z-index: 7; + inset: 0; + background: rgba(18, 27, 25, .36); + pointer-events: auto; +} +.camera-stage.scanner-starting::after { + content: 'Preparing scanner…'; + position: absolute; + z-index: 8; + top: 50%; + left: 50%; + min-width: 190px; + padding: 13px 16px 13px 42px; + transform: translate(-50%, -50%); + border: 1px solid rgba(255,255,255,.24); + border-radius: 7px; + color: white; + background: + radial-gradient(circle at 21px 50%, var(--yellow) 0 5px, rgba(244,211,94,.18) 6px 10px, transparent 11px), + rgba(23,33,31,.92); + box-shadow: 0 12px 34px rgba(0,0,0,.28); + backdrop-filter: blur(10px); + font-size: 11px; + font-weight: 900; + letter-spacing: .02em; + text-align: left; + animation: scanner-loading-pulse 1.15s ease-in-out infinite; +} +.camera-stage.scanner-starting .scan-frame, +.camera-stage.scanner-starting .detection-point, +.camera-stage.scanner-starting .camera-instruction { opacity: .18; } +.camera-stage.scanner-starting .camera-tools { opacity: .5; pointer-events: none; } + +@keyframes scanner-loading-pulse { + 0%, 100% { box-shadow: 0 12px 34px rgba(0,0,0,.28), 0 0 0 0 rgba(244,211,94,.14); } + 50% { box-shadow: 0 12px 34px rgba(0,0,0,.28), 0 0 0 7px rgba(244,211,94,0); } +} + +/* Restore Code Lab card containment lost in the earlier shell cleanup ------------------ */ +.code-card { min-width: 0; overflow: hidden; border: 1px solid var(--line); border-radius: 7px; background: white; } +.code-card-heading { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 13px 14px 10px; } +.code-card-heading strong { font-family: Georgia, serif; font-size: 17px; font-weight: 500; } +.code-card-heading span { color: var(--green); font-size: 8px; font-weight: 900; white-space: nowrap; } +.barcode-canvas { min-width: 0; } +.barcode-canvas canvas { display: block; max-width: 100%; height: auto; } +.code-card code { display: block; padding: 9px 14px 13px; overflow-wrap: anywhere; color: #5e6965; font-size: 10px; } + +/* Settings section structure ------------------------------------------------------------ */ +.settings-section { overflow: hidden; border-radius: 7px; } +.settings-section > header { display: flex; align-items: center; gap: 11px; min-height: 64px; padding: 12px 15px; border-bottom: 1px solid var(--line); } +.settings-section > header h2 { margin: 0; padding: 0; border: 0; font-family: Georgia, serif; font-size: 18px; font-weight: 500; } +.settings-section > header p { margin: 3px 0 0; color: #77817d; font-size: 9px; } +.settings-icon { display: grid; flex: 0 0 auto; width: 34px; height: 34px; place-items: center; border-radius: 5px; color: var(--ink); background: var(--yellow); } +.settings-section .view-actions select { min-width: 0; } +.switch-control { position: relative; width: 42px; height: 23px; padding: 0; border: 1px solid #aab2ad; border-radius: 999px; background: #d8dcd6; transition: background .16s ease, border-color .16s ease; } +.switch-control span { position: absolute; top: 2px; left: 2px; width: 17px; height: 17px; border-radius: 50%; background: white; box-shadow: 0 1px 3px rgba(0,0,0,.22); transition: transform .16s ease; } +.switch-control[aria-checked='true'] { border-color: var(--green); background: var(--green); } +.switch-control[aria-checked='true'] span { transform: translateX(19px); } +.device-facts > div { display: grid; grid-template-columns: 24px minmax(0,1fr) auto; align-items: center; gap: 10px; } +.device-facts svg { width: 16px; color: var(--green); } + +/* EAN quick select --------------------------------------------------------------------- */ +.ean-setup-card .ean-custom-row { grid-template-columns: minmax(0, 1fr) auto; width: min(100%, 452px); } +.ean-setup-card .ean-custom-row label { grid-column: 1 / -1; } +.ean-setup-card .ean-custom-row .compact-input { width: 100%; min-width: 0; max-width: none; } +.ean-setup-card .ean-custom-row .primary-button { white-space: nowrap; } + +@media (max-width: 760px) { + main { width: 100%; min-width: 0; } + + /* Camera loader should feel like part of the camera rather than a page transition. */ + .camera-stage.scanner-starting::after { top: 54%; min-width: 176px; padding: 12px 14px 12px 39px; font-size: 10px; } + + /* Keep workspace pages on one consistent mobile gutter. */ + .workspace-view { width: 100%; max-width: none; padding: 24px 14px 42px; } + .view-heading { gap: 12px; margin-bottom: 18px; } + .view-heading h1 { margin: 5px 0 6px; font-size: 34px; line-height: 1.02; } + .view-heading p { font-size: 11px; line-height: 1.4; } + + /* EAN setup: no overlap between the selector and Start button. */ + .label-check-view { gap: 11px; } + .ean-setup-card { padding: 20px 14px; } + .ean-setup-card h2 { font-size: 25px; } + .ean-setup-card .ean-custom-row { grid-template-columns: minmax(0,1fr) auto; gap: 8px; } + .ean-setup-card .ean-custom-row label { grid-column: 1 / -1; } + .ean-setup-card .ean-custom-row .compact-input { width: 100%; max-width: none; min-height: 42px; } + .ean-setup-card .ean-custom-row .primary-button { min-height: 42px; padding: 0 15px; } + .ean-ar-toggle { margin-top: 1px; } + + /* Code lab: full viewport width, contained canvases, less vertical waste. */ + .code-lab { width: 100%; max-width: none; } + .code-lab .view-heading { margin-bottom: 18px; } + .code-lab .view-heading .primary-button { min-height: 44px; } + .code-grid { width: 100%; gap: 12px; } + .code-card { width: 100%; } + .code-card-heading { padding: 11px 12px 8px; } + .code-card-heading strong { font-size: 16px; } + .barcode-canvas { width: 100%; min-height: 118px; padding: 10px; overflow: hidden; } + .barcode-canvas canvas { max-width: 100% !important; height: auto !important; } + .code-card code { padding: 7px 12px 11px; font-size: 9px; } + + /* Settings: same gutter/rhythm as the other workspaces. */ + .settings-view { width: 100%; max-width: none; } + .settings-view .view-heading { margin-bottom: 16px; } + .settings-view .view-heading > .quiet-button { min-height: 40px; width: auto; justify-self: start; } + .settings-layout { grid-template-columns: 1fr; gap: 12px; } + .settings-section:nth-child(2), .settings-device { grid-column: 1; grid-row: auto; } + .settings-section > header { min-height: 58px; padding: 10px 13px; } + .settings-section > header h2 { font-size: 17px; } + .settings-icon { width: 32px; height: 32px; } + .setting-row { grid-template-columns: minmax(0,1fr) auto; gap: 12px; min-height: 62px; padding: 11px 13px; } + .setting-row strong { font-size: 11px; } + .setting-row small { margin-top: 3px; font-size: 9px; } + .setting-row select, + .setting-row .compact-input, + .setting-range input { width: 130px; max-width: 38vw; min-height: 38px; } + .setting-row .quiet-button, + .setting-row a.quiet-button { min-height: 38px; padding: 0 11px; font-size: 10px; } + .setting-row > .view-actions { display: grid; grid-template-columns: minmax(0,1fr) auto; grid-column: 1 / -1; width: 100%; gap: 7px; } + .setting-row > .view-actions select { width: 100%; max-width: none; } + .device-facts { gap: 9px; padding: 13px; } + .device-facts div { padding-bottom: 9px; } + + @supports selector(:has(*)) { + .setting-row:has(> select), + .setting-row:has(> .compact-input), + .setting-row.setting-range { grid-template-columns: minmax(0,1fr) 130px; } + } +} + +@media (max-width: 390px) { + .workspace-view { padding-left: 12px; padding-right: 12px; } + .view-heading h1 { font-size: 32px; } + + .ean-setup-card .ean-custom-row { grid-template-columns: 1fr; } + .ean-setup-card .ean-custom-row .primary-button { width: 100%; } + + .setting-row { padding: 10px 12px; } + .setting-row select, + .setting-row .compact-input, + .setting-range input { width: 116px; max-width: 36vw; } + + @supports selector(:has(*)) { + .setting-row:has(> select), + .setting-row:has(> .compact-input), + .setting-row.setting-range { grid-template-columns: 1fr; } + .setting-row:has(> select) > select, + .setting-row:has(> .compact-input) > .compact-input, + .setting-row.setting-range > input { width: 100%; max-width: none; } + } +}