8
.dockerignore
Normal file
@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
**/node_modules
|
||||
**/dist
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.log
|
||||
11
.env.example
Normal file
@ -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
|
||||
21
.gitea/workflows/ci.yml
Normal file
@ -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 .
|
||||
8
.gitignore
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.log
|
||||
.DS_Store
|
||||
47
Dockerfile
Normal file
@ -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"]
|
||||
36
README.md
@ -1,3 +1,35 @@
|
||||
# fieldlens-deploy
|
||||
# FieldLens
|
||||
|
||||
Credential-free deployment mirror of private Admin/fieldlens. No secrets.
|
||||
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.
|
||||
|
||||
12
apps/api/.env.example
Normal file
@ -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=
|
||||
29
apps/api/package.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
15
apps/api/src/auth.test.ts
Normal file
@ -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')
|
||||
})
|
||||
})
|
||||
90
apps/api/src/auth.ts
Normal file
@ -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<string, { roles?: unknown }> | 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<string, string[]> = {
|
||||
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()
|
||||
}
|
||||
}
|
||||
20
apps/api/src/config.ts
Normal file
@ -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() || '',
|
||||
}
|
||||
31
apps/api/src/context.ts
Normal file
@ -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<DeviceContext | null> {
|
||||
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
|
||||
}
|
||||
20
apps/api/src/db.ts
Normal file
@ -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()
|
||||
}
|
||||
}
|
||||
11
apps/api/src/log.ts
Normal file
@ -0,0 +1,11 @@
|
||||
export function log(level: 'info' | 'warn' | 'error', event: string, fields: Record<string, unknown> = {}) {
|
||||
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`)
|
||||
}
|
||||
283
apps/api/src/server.ts
Normal file
@ -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<DeviceRegistration>
|
||||
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<string, unknown> }>(`
|
||||
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 }
|
||||
174
apps/api/src/sync.ts
Normal file
@ -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<string, unknown>
|
||||
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<string, unknown>
|
||||
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<string, unknown>
|
||||
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<string, unknown>
|
||||
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<string, unknown> | null }>(
|
||||
'SELECT id, fields_json FROM pallet_observations WHERE pallet_session_id = $1',
|
||||
[sessionId],
|
||||
)
|
||||
const majorities = payload.majorityFields as Array<Record<string, unknown>>
|
||||
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<string, unknown>
|
||||
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<SyncAcknowledgement> {
|
||||
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',
|
||||
}
|
||||
}
|
||||
}
|
||||
14
apps/api/tsconfig.json
Normal file
@ -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"]
|
||||
}
|
||||
4
apps/web/.env.example
Normal file
@ -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
|
||||
15
apps/web/index.html
Normal file
@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#17211f" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<title>FieldLens</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
29
apps/web/package.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
34
apps/web/src/HostStatus.tsx
Normal file
@ -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>('registeredDevice').then((device) => {
|
||||
if (device?.station?.name) setStation(device.station.name)
|
||||
})
|
||||
}
|
||||
refreshStation()
|
||||
const timer = window.setInterval(refreshStation, 5000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<aside className="host-status" aria-label="Arrowstack synchronization status">
|
||||
<span><strong>{station}</strong><small>{pending} pending{errors ? ` · ${errors} retrying` : ''}</small></span>
|
||||
<button type="button" onClick={() => void (authenticated ? logout() : login())}>
|
||||
{authenticated ? 'Sign out' : 'Sign in to sync'}
|
||||
</button>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
54
apps/web/src/auth.ts
Normal file
@ -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<boolean> | 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 })
|
||||
}
|
||||
1
apps/web/src/host.css
Normal file
@ -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}}
|
||||
26
apps/web/src/main.tsx
Normal file
@ -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(
|
||||
<StrictMode>
|
||||
<CoreApp />
|
||||
<HostStatus />
|
||||
</StrictMode>,
|
||||
)
|
||||
15
apps/web/src/referenceProvider.ts
Normal file
@ -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[] : []
|
||||
},
|
||||
})
|
||||
}
|
||||
7
apps/web/src/sync.test.ts
Normal file
@ -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)
|
||||
})
|
||||
})
|
||||
117
apps/web/src/sync.ts
Normal file
@ -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<RegisteredDevice | null> {
|
||||
const existing = await getLocalMetadata<RegisteredDevice>('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<void> | 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 }
|
||||
20
apps/web/tsconfig.json
Normal file
@ -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"]
|
||||
}
|
||||
34
apps/web/vite.config.ts
Normal file
@ -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}']
|
||||
}
|
||||
})
|
||||
]
|
||||
})
|
||||
42
docker-compose.yml
Normal file
@ -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:
|
||||
122
migrations/001_initial.sql
Normal file
@ -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;
|
||||
11
migrations/002_bootstrap.sql
Normal file
@ -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;
|
||||
21
package.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
20
packages/contracts/package.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
116
packages/contracts/src/index.ts
Normal file
@ -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<string, unknown>
|
||||
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<string, unknown> {
|
||||
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)
|
||||
}
|
||||
13
packages/contracts/tsconfig.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
2
packages/fieldlens-core/.env.example
Normal file
@ -0,0 +1,2 @@
|
||||
# FieldLens Core requires no environment variables by default.
|
||||
# Host applications can define API/auth/provider configuration.
|
||||
19
packages/fieldlens-core/.github/workflows/ci.yml
vendored
Normal file
@ -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
|
||||
24
packages/fieldlens-core/.gitignore
vendored
Normal file
@ -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?
|
||||
8
packages/fieldlens-core/.oxlintrc.json
Normal file
@ -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 }]
|
||||
}
|
||||
}
|
||||
545
packages/fieldlens-core/ARROWSTACK_PLAN.md
Normal file
@ -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 <access-token>
|
||||
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.
|
||||
62
packages/fieldlens-core/README.md
Normal file
@ -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.
|
||||
19
packages/fieldlens-core/index.html
Normal file
@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/fieldlens-mark.svg" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#17211f" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="FieldLens" />
|
||||
<meta name="description" content="Offline barcode capture and duplicate detection." />
|
||||
<title>FieldLens Capture</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
39
packages/fieldlens-core/package.json
Normal file
@ -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"
|
||||
}
|
||||
BIN
packages/fieldlens-core/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 525 B |
BIN
packages/fieldlens-core/public/favicon-16.png
Normal file
|
After Width: | Height: | Size: 165 B |
BIN
packages/fieldlens-core/public/favicon-32.png
Normal file
|
After Width: | Height: | Size: 177 B |
10
packages/fieldlens-core/public/favicon.svg
Normal file
@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="12" fill="#17211f"/>
|
||||
<g transform="translate(4 4)">
|
||||
<path d="M7 19V11a4 4 0 0 1 4-4h8M37 7h8a4 4 0 0 1 4 4v8M49 37v8a4 4 0 0 1-4 4h-8M19 49h-8a4 4 0 0 1-4-4v-8" fill="none" stroke="#f4d35e" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<rect x="18" y="19" width="4" height="19" rx="1" fill="#f7f7f2"/>
|
||||
<rect x="25" y="19" width="3" height="19" rx="1" fill="#f7f7f2"/>
|
||||
<rect x="31" y="19" width="5" height="19" rx="1" fill="#f7f7f2"/>
|
||||
<rect x="39" y="19" width="3" height="19" rx="1" fill="#f7f7f2"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 652 B |
11
packages/fieldlens-core/public/fieldlens-horizontal.svg
Normal file
@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 420 96">
|
||||
<rect width="420" height="96" fill="#f7f7f2"/>
|
||||
<g transform="translate(16 16)">
|
||||
<path d="M7 21V11a4 4 0 0 1 4-4h10M43 7h10a4 4 0 0 1 4 4v10M57 43v10a4 4 0 0 1-4 4H43M21 57H11a4 4 0 0 1-4-4V43" fill="none" stroke="#f4d35e" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<rect x="19" y="20" width="4" height="24" rx="1" fill="#17211f"/>
|
||||
<rect x="26" y="20" width="3" height="24" rx="1" fill="#17211f"/>
|
||||
<rect x="32" y="20" width="5" height="24" rx="1" fill="#17211f"/>
|
||||
<rect x="40" y="20" width="3" height="24" rx="1" fill="#17211f"/>
|
||||
</g>
|
||||
<text x="98" y="61" font-family="Inter,Arial,sans-serif" font-size="45" font-weight="800" fill="#17211f">Field<tspan fill="#f4d35e">Lens</tspan></text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 792 B |
1
packages/fieldlens-core/public/fieldlens-mark.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="12" fill="#17211f"/><path d="M12 24V16a4 4 0 0 1 4-4h8M40 12h8a4 4 0 0 1 4 4v8M52 40v8a4 4 0 0 1-4 4h-8M24 52h-8a4 4 0 0 1-4-4v-8" fill="none" stroke="#f4d35e" stroke-width="6"/><path d="M23 23v18M29 23v18M36 23v18M42 23v18" stroke="#f7f7f2" stroke-width="4"/></svg>
|
||||
|
After Width: | Height: | Size: 359 B |
1
packages/fieldlens-core/public/fieldlens-wordmark.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 72"><text x="2" y="52" font-family="Inter,Arial,sans-serif" font-size="52" font-weight="800" fill="#17211f">Field<tspan fill="#f4d35e">Lens</tspan></text></svg>
|
||||
|
After Width: | Height: | Size: 218 B |
24
packages/fieldlens-core/public/icons.svg
Normal file
@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
BIN
packages/fieldlens-core/public/pwa-192.png
Normal file
|
After Width: | Height: | Size: 552 B |
BIN
packages/fieldlens-core/public/pwa-512.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
223
packages/fieldlens-core/src/App.css
Normal file
@ -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; }
|
||||
}
|
||||
244
packages/fieldlens-core/src/App.tsx
Normal file
@ -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<void>
|
||||
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<View>('scanner')
|
||||
const [installPrompt, setInstallPrompt] = useState<BeforeInstallPromptEvent | null>(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<ServiceWorkerRegistration | null>(null)
|
||||
const installButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const installDialogRef = useRef<HTMLDialogElement>(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) ? (
|
||||
<button className="install-button" type="button" ref={installButtonRef} onClick={() => void installApp()}>
|
||||
<Smartphone size={15} /> Install app
|
||||
</button>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="app-shell">
|
||||
<aside className="sidebar">
|
||||
<button className="brand" type="button" onClick={() => setView('scanner')} aria-label="Open scanner">
|
||||
<span className="brand-mark"><img src="/fieldlens-mark.svg" width="28" height="28" alt="" aria-hidden="true" /></span>
|
||||
<span><strong>FIELDLENS</strong><small>CAPTURE SYSTEM</small></span>
|
||||
</button>
|
||||
|
||||
<nav aria-label="Primary navigation">
|
||||
{navigation.map(({ id, label, icon: Icon }) => (
|
||||
<button className={view === id ? 'active' : ''} type="button" key={id} onClick={() => setView(id)}>
|
||||
<Icon size={19} /><span>{label}</span>
|
||||
{id === 'history' && scanCount > 0 && <b>{scanCount}</b>}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-status">
|
||||
{isOnline ? <Wifi size={16} /> : <WifiOff size={16} />}
|
||||
<span><strong>{isOnline ? 'Online' : 'Offline ready'}</strong><small>{storageStatus === 'persistent' ? 'Durable storage' : storageStatus === 'checking' ? 'Checking storage' : 'Best-effort storage'}</small></span>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main>
|
||||
<Suspense fallback={<div className="loading-view">Loading workspace</div>}>
|
||||
{view === 'scanner' && <ScannerView installAction={installAction} />}
|
||||
{view === 'ean-pallet' && <EanPalletView />}
|
||||
{view === 'label-check' && <LabelCheckView onOpenSettings={() => setView('settings')} />}
|
||||
{view === 'history' && <HistoryView />}
|
||||
{view === 'lab' && <CodeLab />}
|
||||
{view === 'settings' && (
|
||||
<SettingsView
|
||||
storageStatus={storageStatus}
|
||||
updateAvailable={needRefresh}
|
||||
isUpdating={isUpdating}
|
||||
updateError={updateError}
|
||||
onApplyUpdate={applyUpdate}
|
||||
/>
|
||||
)}
|
||||
</Suspense>
|
||||
</main>
|
||||
|
||||
<nav className="mobile-nav" aria-label="Mobile navigation">
|
||||
{navigation.map(({ id, label, mobileLabel, icon: Icon }) => (
|
||||
<button className={view === id ? 'active' : ''} type="button" key={id} onClick={() => setView(id)}>
|
||||
<Icon size={20} /><span>{mobileLabel ?? label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<dialog className="install-dialog-shell" ref={installDialogRef} aria-labelledby="install-title" onCancel={(event) => { event.preventDefault(); event.currentTarget.close() }} onClose={() => { setShowIosInstall(false); installButtonRef.current?.focus() }} onClick={(event) => { if (event.target === event.currentTarget) event.currentTarget.close() }}>
|
||||
<div className="install-dialog">
|
||||
<button className="dialog-close" type="button" autoFocus aria-label="Close" onClick={() => installDialogRef.current?.close()}><X /></button>
|
||||
<Smartphone className="install-dialog-icon" />
|
||||
<h2 id="install-title">Install FieldLens</h2>
|
||||
<p><Share size={18} /> Tap <strong>Share</strong> in Safari.</p>
|
||||
<p><SquarePlus size={18} /> Choose <strong>Add to Home Screen</strong>.</p>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
{needRefresh && (
|
||||
<aside className={`update-banner ${updateError ? 'error' : ''}`} aria-label="Application update" aria-live="polite">
|
||||
<span className="update-icon"><CloudDownload /></span>
|
||||
<span className="update-copy">
|
||||
<strong>{updateError ? 'Update paused' : isUpdating ? 'Updating FieldLens' : 'Update available'}</strong>
|
||||
<small>{updateError || (isUpdating ? 'Installing the latest version…' : 'Update now or FieldLens will refresh after you leave the app.')}</small>
|
||||
</span>
|
||||
<button type="button" disabled={isUpdating} onClick={() => void applyUpdate()}>
|
||||
{isUpdating ? <LoaderCircle className="spinning" /> : <CloudDownload />} {updateError ? 'Retry' : isUpdating ? 'Updating' : 'Update now'}
|
||||
</button>
|
||||
</aside>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
64
packages/fieldlens-core/src/detection-rotation.css
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
147
packages/fieldlens-core/src/features/codes/CodeLab.css
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
162
packages/fieldlens-core/src/features/codes/CodeLab.tsx
Normal file
@ -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<HTMLCanvasElement>(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 (
|
||||
<article className="code-card">
|
||||
<div className="code-card-heading"><strong>{fixture.name}</strong><span>VALID SAMPLE</span></div>
|
||||
<div className="barcode-canvas"><canvas ref={canvasRef} /></div>
|
||||
<code>{fixture.value}</code>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function EanLabelCard({ fixture }: { fixture: EanLabelFixture }) {
|
||||
return (
|
||||
<article className="ean-lab-carton">
|
||||
<div className="ean-lab-label" aria-label={`EAN carton test label ${fixture.carton}`}>
|
||||
<div className="ean-lab-label-head">
|
||||
<strong>EXPORT AVOCADOS</strong>
|
||||
<span>CARTON {String(fixture.carton).padStart(2, '0')}</span>
|
||||
</div>
|
||||
<div className="ean-lab-fields">
|
||||
<span><b>PPIN</b>{fixture.ppin}</span>
|
||||
<span><b>Grower</b>{fixture.grower}</span>
|
||||
<span><b>Variety</b>{fixture.variety}</span>
|
||||
<span><b>Grade</b>{fixture.grade}</span>
|
||||
<span><b>Packer</b>{fixture.packer}</span>
|
||||
<span><b>Region</b>{fixture.region}</span>
|
||||
<span><b>Pick Date</b>{fixture.pickDate}</span>
|
||||
<span><b>Pack Date</b>{fixture.packDate}</span>
|
||||
<span><b>Size</b>{fixture.size}</span>
|
||||
<span><b>Count</b>{fixture.count}</span>
|
||||
<span className="ean-lab-temp"><b>TempPC</b>{fixture.tempPc}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export function CodeLab() {
|
||||
const [tab, setTab] = useState<LabTab>('barcodes')
|
||||
const [fixtures, setFixtures] = useState(makeFixtures)
|
||||
const [eanFixtures, setEanFixtures] = useState(makeEanFixtures)
|
||||
|
||||
const regenerate = () => {
|
||||
if (tab === 'ean') setEanFixtures(makeEanFixtures())
|
||||
else setFixtures(makeFixtures())
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="workspace-view code-lab" aria-labelledby="lab-title">
|
||||
<header className="view-heading">
|
||||
<div>
|
||||
<span className="eyebrow">VALIDATION BENCH</span>
|
||||
<h1 id="lab-title">Code lab</h1>
|
||||
<p>{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.'}</p>
|
||||
</div>
|
||||
<button className="primary-button" type="button" onClick={regenerate}>
|
||||
<RefreshCw size={17} /> Generate new set
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="code-lab-tabs" role="tablist" aria-label="Code lab fixtures">
|
||||
<button type="button" role="tab" aria-selected={tab === 'barcodes'} className={tab === 'barcodes' ? 'active' : ''} onClick={() => setTab('barcodes')}>Barcodes</button>
|
||||
<button type="button" role="tab" aria-selected={tab === 'ean'} className={tab === 'ean' ? 'active' : ''} onClick={() => setTab('ean')}>EAN labels</button>
|
||||
</div>
|
||||
|
||||
{tab === 'barcodes' ? (
|
||||
<div className="code-grid">{fixtures.map((fixture) => <CodeCard fixture={fixture} key={fixture.id} />)}</div>
|
||||
) : (
|
||||
<div className="ean-lab-grid">{eanFixtures.map((fixture) => <EanLabelCard fixture={fixture} key={fixture.id} />)}</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@ -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}}
|
||||
@ -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<HTMLVideoElement>(null)
|
||||
const controlsRef = useRef<IScannerControls | null>(null)
|
||||
const armedRef = useRef(false)
|
||||
const sessionIdRef = useRef('')
|
||||
const scansRef = useRef<string[]>([])
|
||||
const [step, setStep] = useState<Step>('setup')
|
||||
const [targetCount, setTargetCount] = useState(88)
|
||||
const [scans, setScans] = useState<string[]>([])
|
||||
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 (
|
||||
<section className="ean-pallet-view">
|
||||
<header>
|
||||
<div>
|
||||
<span className="eyebrow">LOCAL-FIRST PALLET CHECK</span>
|
||||
<h1>EAN pallet verification</h1>
|
||||
<p>Capture one carton at a time. Each accepted read is persisted locally before it is eligible for sync.</p>
|
||||
</div>
|
||||
<span className="ean-local-badge">Works offline</span>
|
||||
</header>
|
||||
|
||||
{step === 'setup' && (
|
||||
<div className="ean-setup-card">
|
||||
<label>
|
||||
Cartons on pallet
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="500"
|
||||
inputMode="numeric"
|
||||
value={targetCount}
|
||||
onChange={(event) => setTargetCount(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<button type="button" className="ean-primary" onClick={() => void start()}>
|
||||
<ScanLine size={18} /> Start pallet
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'scan' && (
|
||||
<div className="ean-scan-layout">
|
||||
<div className="ean-camera">
|
||||
<video ref={videoRef} autoPlay muted playsInline />
|
||||
<div className="ean-reticle" />
|
||||
</div>
|
||||
<aside className="ean-progress">
|
||||
<strong>{scans.length} / {targetCount}</strong>
|
||||
<span>{message}</span>
|
||||
<div className="ean-progress-track"><i style={{ width: `${Math.min(100, scans.length / targetCount * 100)}%` }} /></div>
|
||||
{cameraError && <p className="ean-error">{cameraError}</p>}
|
||||
{!armed && scans.length < targetCount && (
|
||||
<button type="button" className="ean-primary" onClick={armNext}>
|
||||
<ScanLine size={17} /> Scan next carton
|
||||
</button>
|
||||
)}
|
||||
<small>Re-arming is manual so identical EANs on adjacent cartons are not counted repeatedly from the same camera frame.</small>
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'result' && (
|
||||
<div className={`ean-result ${summary.conforms ? 'pass' : 'fail'}`}>
|
||||
{summary.conforms ? <CheckCircle2 /> : <XCircle />}
|
||||
<div>
|
||||
<span className="eyebrow">{summary.conforms ? 'PALLET EAN PASS' : 'EAN MISMATCH FOUND'}</span>
|
||||
<h2>{summary.total} cartons captured</h2>
|
||||
<p>{summary.conforms
|
||||
? `All cartons resolved to ${summary.consensusEan}.`
|
||||
: `${summary.counts.length} different trade item codes were captured. Review before release.`}</p>
|
||||
<ul>
|
||||
{summary.counts.map((entry) => <li key={entry.ean}><strong>{entry.ean}</strong><span>{entry.count}</span></li>)}
|
||||
</ul>
|
||||
<button type="button" className="ean-primary" onClick={reset}><RefreshCw size={17} /> New pallet</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
381
packages/fieldlens-core/src/features/history/HistoryView.css
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
166
packages/fieldlens-core/src/features/history/HistoryView.tsx
Normal file
@ -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<boolean | null>(null)
|
||||
const copyTimerRef = useRef<number | null>(null)
|
||||
const [dragOffset, setDragOffset] = useState<number | null>(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<HTMLElement>) => {
|
||||
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<HTMLElement>) => {
|
||||
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<HTMLElement>) => {
|
||||
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 (
|
||||
<div className="history-swipe-row">
|
||||
<div className="history-actions" aria-hidden={!isOpen}>
|
||||
<button className="history-action copy-action" type="button" tabIndex={isOpen ? 0 : -1} onClick={() => void copyValue()}>
|
||||
{copied ? <Check /> : <Copy />}<span>{copied ? 'Copied' : 'Copy'}</span>
|
||||
</button>
|
||||
<button className="history-action delete-action" type="button" tabIndex={isOpen ? 0 : -1} onClick={onDelete}>
|
||||
<Trash2 /><span>Delete</span>
|
||||
</button>
|
||||
</div>
|
||||
<article
|
||||
className={`history-row ${scan.isDuplicate ? 'duplicate' : ''} ${dragOffset !== null ? 'dragging' : ''}`}
|
||||
style={{ transform: `translateX(${offset}px)` }}
|
||||
onPointerDown={startDrag}
|
||||
onPointerMove={moveDrag}
|
||||
onPointerUp={finishDrag}
|
||||
onPointerCancel={finishDrag}
|
||||
>
|
||||
<div className="history-status">{scan.isDuplicate ? <AlertTriangle /> : <CircleCheck />}</div>
|
||||
<div className="history-value">
|
||||
<strong>{scan.rawValue}</strong>
|
||||
<span>{scan.format}</span>
|
||||
</div>
|
||||
<div className="history-meta">
|
||||
<span className="status-label">{scan.isDuplicate ? 'DUPLICATE' : 'UNIQUE'}</span>
|
||||
<time dateTime={scan.scannedAt}>{new Date(scan.scannedAt).toLocaleString()}</time>
|
||||
</div>
|
||||
<button className="swipe-menu" type="button" aria-label={`Actions for ${scan.rawValue}`} aria-expanded={isOpen} onClick={() => setOpen(!isOpen)}>
|
||||
<MoreHorizontal />
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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<string | null>(null)
|
||||
const [deletedScan, setDeletedScan] = useState<ScanRecord | null>(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 (
|
||||
<section className="workspace-view history-view" aria-labelledby="history-title">
|
||||
<header className="view-heading">
|
||||
<div>
|
||||
<span className="eyebrow">DEVICE REGISTER</span>
|
||||
<h1 id="history-title">Capture history</h1>
|
||||
<p>Records stay available offline in this browser.</p>
|
||||
</div>
|
||||
<div className="view-actions">
|
||||
<button className="quiet-button" type="button" disabled={!scans.length} onClick={exportHistory}>
|
||||
<Download size={16} /> Export
|
||||
</button>
|
||||
<button className="quiet-button history-clear-button" type="button" disabled={!scans.length} onClick={() => { setDeletedScan(null); void clearScanHistory() }}>
|
||||
<Trash2 size={16} /> Clear history
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="metric-strip">
|
||||
<div><strong>{scans.length}</strong><span>Total captures</span></div>
|
||||
<div><strong>{duplicates}</strong><span>Duplicates flagged</span></div>
|
||||
<div><strong>{uniqueValues}</strong><span>Unique values</span></div>
|
||||
</div>
|
||||
|
||||
<div className="history-list">
|
||||
{!scans.length && <div className="empty-state"><Barcode /><strong>No scans recorded</strong><span>Open Scanner to capture the first code.</span></div>}
|
||||
{scans.map((scan) => (
|
||||
<HistoryRow scan={scan} isOpen={openScanId === scan.id} setOpen={(open) => setOpenScanId(open ? scan.id : null)} onDelete={() => removeScan(scan)} key={scan.id} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{deletedScan && (
|
||||
<div className="undo-toast" role="status">
|
||||
<span>Scan deleted</span>
|
||||
<button type="button" onClick={undoDelete}><RotateCcw /> Undo</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@ -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%; }
|
||||
}
|
||||
@ -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<ArDetectionState, number> = {
|
||||
candidate: 0xf4d35e,
|
||||
accepted: 0x66cf9d,
|
||||
invalid: 0xed736a,
|
||||
}
|
||||
|
||||
function projectBox(
|
||||
item: Pick<ArDetection, 'sourceWidth' | 'sourceHeight' | 'box'>,
|
||||
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<ArDetection, 'sourceWidth' | 'sourceHeight' | 'box'>, 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<HTMLDivElement>(null)
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const rendererRef = useRef<THREE.WebGLRenderer | null>(null)
|
||||
const sceneRef = useRef<THREE.Scene | null>(null)
|
||||
const cameraRef = useRef<THREE.OrthographicCamera | null>(null)
|
||||
const lineRef = useRef<THREE.LineLoop | null>(null)
|
||||
const materialRef = useRef<THREE.LineBasicMaterial | null>(null)
|
||||
const memoryGroupRef = useRef<THREE.Group | null>(null)
|
||||
const lastScannedRef = useRef(0)
|
||||
const [available, setAvailable] = useState(true)
|
||||
const [remembered, setRemembered] = useState<ArRememberedPosition[]>([])
|
||||
|
||||
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 (
|
||||
<div className="ar-overlay" ref={hostRef} aria-hidden="true">
|
||||
{available && <canvas ref={canvasRef} className="ar-overlay-canvas" />}
|
||||
<div className="ar-overlay-hud">
|
||||
<span className="ar-mode-badge">AR ASSIST</span>
|
||||
<strong>{scanned} / {target}</strong>
|
||||
<small>{uniqueEans || 0} EAN value{uniqueEans === 1 ? '' : 's'} seen · {remembered.length} positions remembered</small>
|
||||
</div>
|
||||
{remembered.length > 0 && (
|
||||
<div className="ar-memory-map">
|
||||
<span>POSITION MEMORY</span>
|
||||
<div className="ar-memory-field">
|
||||
{remembered.map((position) => {
|
||||
const centerX = ((position.box.x + position.box.width / 2) / position.sourceWidth) * 100
|
||||
const centerY = ((position.box.y + position.box.height / 2) / position.sourceHeight) * 100
|
||||
return <i key={position.scanNumber} style={{ left: `${centerX}%`, top: `${centerY}%` }} />
|
||||
})}
|
||||
</div>
|
||||
<small>{remembered.length} / {target}</small>
|
||||
</div>
|
||||
)}
|
||||
{detection && (
|
||||
<div className={`ar-detection-readout ${detection.state}`}>
|
||||
<strong>{detection.ean}</strong>
|
||||
<span>{detection.state === 'accepted' ? 'accepted · position saved' : detection.state === 'invalid' ? 'invalid EAN' : 'hold steady'}</span>
|
||||
</div>
|
||||
)}
|
||||
{!available && <div className="ar-unavailable">AR overlay unavailable · scanner continues normally</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -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%; }
|
||||
}
|
||||
@ -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<typeof loadCartonConfigs>) {
|
||||
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 (
|
||||
<div className="ean-config-picker">
|
||||
<label htmlFor="ean-carton-config">Carton config</label>
|
||||
<div className="ean-config-row">
|
||||
<select id="ean-carton-config" className="compact-input" value={selection} onChange={(event) => choose(event.target.value)}>
|
||||
{PRESETS.map((option) => <option value={`preset:${option.value}`} key={option.value}>{option.label}</option>)}
|
||||
{savedConfigs.map((config) => <option value={`saved:${config.id}`} key={config.id}>{config.name} · {config.count}</option>)}
|
||||
<option value="custom">Custom…</option>
|
||||
</select>
|
||||
<button className="primary-button" type="button" disabled={value < 1} onClick={onStart}>Start scan</button>
|
||||
</div>
|
||||
|
||||
{custom && (
|
||||
<div className="ean-config-editor">
|
||||
<input
|
||||
className="compact-input"
|
||||
type="text"
|
||||
maxLength={40}
|
||||
placeholder="Config name"
|
||||
aria-label="Carton config name"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="compact-input"
|
||||
type="number"
|
||||
min="1"
|
||||
max="500"
|
||||
inputMode="numeric"
|
||||
placeholder="Cartons"
|
||||
aria-label="Custom carton count"
|
||||
value={value || ''}
|
||||
onChange={(event) => onChange(Number(event.target.value))}
|
||||
/>
|
||||
<button className="quiet-button" type="button" disabled={!name.trim() || value < 1} onClick={saveCustom}>Save config</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!custom && selectedSavedConfig && <small className="ean-config-note">Saved locally · {selectedSavedConfig.count} cartons</small>}
|
||||
{message && <small className="ean-config-note">{message}</small>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
@ -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); }
|
||||
}
|
||||
}
|
||||
@ -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<Step, number> = {
|
||||
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<HTMLVideoElement>(null)
|
||||
const stageRef = useRef<HTMLDivElement>(null)
|
||||
const streamRef = useRef<MediaStream | null>(null)
|
||||
const labelsRef = useRef<CartonLabelScan[]>([])
|
||||
const sessionIdRef = useRef('')
|
||||
const tracksRef = useRef<LabelTrack[]>([])
|
||||
const trackCounterRef = useRef(0)
|
||||
const queueRef = useRef<OcrQueueItem[]>([])
|
||||
const activeOcrWorkersRef = useRef(0)
|
||||
const activeTrackIdsRef = useRef(new Set<string>())
|
||||
const acceptedAnchorsRef = useRef(new Map<string, RememberedAnchor>())
|
||||
const homographyRef = useRef<Homography>([...IDENTITY_HOMOGRAPHY])
|
||||
const sectionRef = useRef<CartonLabelSection>({ face: 1, half: 'top' })
|
||||
const sectionStartCountRef = useRef(0)
|
||||
const targetCountRef = useRef(loadCartonCount())
|
||||
const finishedRef = useRef(false)
|
||||
|
||||
const [step, setStep] = useState<Step>('setup')
|
||||
const [targetCount, setTargetCount] = useState(loadCartonCount)
|
||||
const [labels, setLabels] = useState<CartonLabelScan[]>([])
|
||||
const [tracks, setTracks] = useState<LabelTrack[]>([])
|
||||
const [acceptedAnchors, setAcceptedAnchors] = useState<RememberedAnchor[]>([])
|
||||
const [homography, setHomography] = useState<Homography>([...IDENTITY_HOMOGRAPHY])
|
||||
const [anchorLandmarks, setAnchorLandmarks] = useState(0)
|
||||
const [anchorMode, setAnchorMode] = useState<'idle' | 'translation' | 'homography'>('idle')
|
||||
const [videoSpace, setVideoSpace] = useState<CoverCoordinateSpace>({ left: 0, top: 0, width: 0, height: 0 })
|
||||
const [section, setSection] = useState<CartonLabelSection>({ 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<CartonLabelScan | null>(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 (
|
||||
<section className="workspace-view label-check-view carton-label-view" aria-labelledby="label-check-title">
|
||||
<header className="view-heading label-check-heading">
|
||||
<div>
|
||||
<span className="eyebrow">PALLET LABEL VERIFICATION</span>
|
||||
<h1 id="label-check-title">Pallet label check</h1>
|
||||
<p>Batch OCR · sweep each pallet section and compare printed key/value fields</p>
|
||||
</div>
|
||||
<div className="view-actions">
|
||||
<button className="quiet-button" type="button" onClick={onOpenSettings}><Settings size={16} /> Integrations</button>
|
||||
{step !== 'setup' && <button className="quiet-button" type="button" onClick={reset}><RefreshCw size={16} /> New pallet</button>}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ol className="label-stepper label-stepper-three" aria-label="Pallet label check progress">
|
||||
{['Setup', 'Sweep sections', 'Conformity'].map((label, index) => (
|
||||
<li className={currentStepIndex === index ? 'active' : currentStepIndex > index ? 'done' : ''} key={label}><span>{index + 1}</span> {label}</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
{step === 'setup' && (
|
||||
<div className="ean-setup-card">
|
||||
<ScanLine size={38} />
|
||||
<h2>Start pallet check</h2>
|
||||
<p>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.</p>
|
||||
<CartonConfigPicker value={targetCount} onChange={changeCartonCount} onStart={() => void startSession()} />
|
||||
<label className="ean-ar-toggle">
|
||||
<input type="checkbox" checked={arEnabled} onChange={(event) => setArEnabled(event.target.checked)} />
|
||||
<span><strong>AR assist</strong><small>Tracks accepted carton positions across each pallet-half sweep. OCR and storage stay local-first.</small></span>
|
||||
</label>
|
||||
{error && <div className="label-error"><AlertTriangle /> <span>{error}</span></div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'scan' && (
|
||||
<div className="label-scan-panel carton-label-scan-panel">
|
||||
<div className="carton-section-head">
|
||||
<div>
|
||||
<span>Current sweep</span>
|
||||
<strong>{sectionLabel(section)}</strong>
|
||||
<small>Sweep slowly with some overlap between views so AR can keep the pallet face anchored.</small>
|
||||
</div>
|
||||
<button className="quiet-button" type="button" onClick={() => setScanPaused((value) => !value)}>
|
||||
{scanPaused ? <Play size={15} /> : <Pause size={15} />} {scanPaused ? 'Resume' : 'Pause'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="ean-session-strip carton-batch-stats">
|
||||
<div><span>Pallet</span><strong>{labels.length} / {targetCount}</strong></div>
|
||||
<div><span>This section</span><strong>{sectionRead} / ~{sectionGuide}</strong></div>
|
||||
<div><span>Visible labels</span><strong>{visibleTracks.length}</strong></div>
|
||||
<div><span>OCR queue</span><strong>{queueCount + activeOcrCount}</strong></div>
|
||||
</div>
|
||||
<div className="ean-progress-track"><span style={{ width: `${Math.min(100, (labels.length / targetCount) * 100)}%` }} /></div>
|
||||
|
||||
<div ref={stageRef} className={`label-camera-stage carton-label-camera carton-batch-camera ${!cameraReady ? 'scanner-starting' : ''}`}>
|
||||
<video ref={videoRef} autoPlay muted playsInline aria-label="Pallet carton label batch OCR camera" />
|
||||
<div className="label-camera-shade" />
|
||||
|
||||
{arEnabled && videoSpace.width > 0 && (
|
||||
<div
|
||||
className="carton-video-coordinate-space"
|
||||
style={{
|
||||
left: `${videoSpace.left}px`,
|
||||
top: `${videoSpace.top}px`,
|
||||
width: `${videoSpace.width}px`,
|
||||
height: `${videoSpace.height}px`,
|
||||
}}
|
||||
>
|
||||
{rememberedBoxes.map((anchor) => (
|
||||
<div
|
||||
className="carton-batch-box accepted remembered"
|
||||
key={`remembered-${anchor.id}`}
|
||||
style={{
|
||||
left: `${anchor.box.x * 100}%`,
|
||||
top: `${anchor.box.y * 100}%`,
|
||||
width: `${anchor.box.width * 100}%`,
|
||||
height: `${anchor.box.height * 100}%`,
|
||||
}}
|
||||
>
|
||||
<span>✓</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{visibleTracks.map((track) => (
|
||||
<div
|
||||
className={`carton-batch-box ${track.status}`}
|
||||
key={track.id}
|
||||
style={{
|
||||
left: `${track.box.x * 100}%`,
|
||||
top: `${track.box.y * 100}%`,
|
||||
width: `${track.box.width * 100}%`,
|
||||
height: `${track.box.height * 100}%`,
|
||||
}}
|
||||
>
|
||||
<span>{track.status === 'accepted' ? '✓' : track.status === 'ignored' ? '–' : track.status === 'retry' ? '?' : track.status === 'reading' ? 'OCR' : track.status === 'queued' ? '…' : ''}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="carton-batch-instruction">
|
||||
<span>{sectionLabel(section)}</span>
|
||||
<strong>{scanPaused ? 'Sweep paused' : 'Sweep across the white labels'}</strong>
|
||||
<small>Green = accepted, amber = another pass, grey = not a PPIN/grower label.</small>
|
||||
{arEnabled && <em>{anchorStatus}</em>}
|
||||
</div>
|
||||
|
||||
{activeOcrCount > 0 && (
|
||||
<div className="carton-batch-ocr-status" aria-live="polite">
|
||||
<strong>{activeOcrCount} OCR worker{activeOcrCount === 1 ? '' : 's'} · {progress}%</strong>
|
||||
<span>{progressLabel}</span>
|
||||
<div className="label-progress"><span style={{ width: `${progress}%` }} /></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{arEnabled && lastFields.length > 0 && (
|
||||
<div className="carton-live-fields carton-batch-last-fields" aria-live="polite">
|
||||
<span>Last accepted · {lastRead?.recognizedFields} fields</span>
|
||||
{lastFields.slice(0, 4).map(({ field, label, value }) => <div key={field}><b>{label}</b><strong>{value}</strong></div>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="carton-section-actions">
|
||||
<div>
|
||||
<span>{retryTracks > 0 ? `${retryTracks} visible label${retryTracks === 1 ? '' : 's'} need another pass` : sectionRead >= sectionGuide ? 'Section guide reached' : `Aim for about ${sectionGuide} cartons in this section`}</span>
|
||||
<small>Section counts are guidance only; the pallet stops automatically at {targetCount} accepted cartons.{ignoredTracks > 0 ? ` ${ignoredTracks} visible non-target shape${ignoredTracks === 1 ? '' : 's'} ignored.` : ''}</small>
|
||||
</div>
|
||||
<button className="primary-button" type="button" disabled={activeOcrCount > 0 || queueCount > 0 || labels.length >= targetCount} onClick={nextSection}>
|
||||
{section.half === 'top' ? <ArrowDown size={17} /> : <RefreshCw size={17} />}
|
||||
{section.half === 'top' ? 'Finish top · scan bottom' : `Finish face ${section.face} · move round`}
|
||||
</button>
|
||||
<button className="quiet-button" type="button" disabled={labels.length === 0 || activeOcrCount > 0 || queueCount > 0} onClick={undoLastScan}><RotateCcw size={15} /> Undo last</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="label-error"><AlertTriangle /> <span>{error}</span></div>}
|
||||
{cameraError && <div className="label-error"><AlertTriangle /> <span>{cameraError}</span><button type="button" onClick={() => setRetryKey((value) => value + 1)}>Retry camera</button></div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'result' && (
|
||||
<div className={`ean-conformity-card carton-conformity-card ${summary.status}`}>
|
||||
<div className="label-result-heading">
|
||||
<span className="label-result-icon">{summary.status === 'pass' ? <CheckCircle2 /> : summary.status === 'fail' ? <XCircle /> : <AlertTriangle />}</span>
|
||||
<div>
|
||||
<span className="eyebrow">{summary.status === 'pass' ? 'LABEL CONFORMITY PASS' : summary.status === 'fail' ? 'LABEL MISMATCH FOUND' : 'LABEL REVIEW REQUIRED'}</span>
|
||||
<h2>{summary.total} / {targetCount} cartons read</h2>
|
||||
<p>{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.`}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ean-final-summary carton-final-summary">
|
||||
<span>Cartons <strong>{summary.total}</strong></span>
|
||||
<span>Compared fields <strong>{summary.majorityFields.length}</strong></span>
|
||||
<span>Mismatch cartons <strong>{summary.mismatchCartons}</strong></span>
|
||||
<span>Avg OCR <strong>{Math.round(summary.averageConfidence)}%</strong></span>
|
||||
</div>
|
||||
|
||||
<div className="carton-majority-fields">
|
||||
<div className="carton-majority-head"><span>Field</span><span>Pallet majority</span><span>Read support</span></div>
|
||||
{summary.majorityFields.map((field) => (
|
||||
<div key={field.field}>
|
||||
<strong>{field.label}</strong>
|
||||
<span>{field.value}</span>
|
||||
<b>{field.support} / {summary.total}</b>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{summary.issues.length > 0 && (
|
||||
<div className="carton-issues">
|
||||
<header><strong>Exceptions</strong><span>{mismatchIssues.length} mismatch · {unreadIssues.length} unread</span></header>
|
||||
{summary.issues.slice(0, 24).map((issue) => (
|
||||
<div className={issue.status} key={`${issue.scanId}-${issue.field}`}>
|
||||
<span>Carton {issue.cartonNumber}</span>
|
||||
<strong>{issue.label}</strong>
|
||||
{issue.section && <em>Face {issue.section.face} · {issue.section.half}</em>}
|
||||
<small>Expected majority</small><b>{issue.expected}</b>
|
||||
<small>Read</small><b>{issue.actual || 'Unread'}</b>
|
||||
</div>
|
||||
))}
|
||||
{summary.issues.length > 24 && <p>+ {summary.issues.length - 24} more exceptions</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{labels.at(-1)?.rawText && (
|
||||
<details className="label-ocr-details">
|
||||
<summary>Last carton raw OCR</summary>
|
||||
<pre>{labels.at(-1)?.rawText}</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<div className="label-result-actions">
|
||||
<button className="primary-button" type="button" onClick={reset}><ScanLine size={16} /> New pallet</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
377
packages/fieldlens-core/src/features/scanner/ScannerView.tsx
Normal file
@ -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<HTMLVideoElement>(null)
|
||||
const stageRef = useRef<HTMLDivElement>(null)
|
||||
const controlsRef = useRef<IScannerControls | null>(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<PayloadRules>(initialPreferences.payloadRules)
|
||||
const qualitySettingsOpenRef = useRef(false)
|
||||
const candidateRef = useRef<ConfirmationCandidate | null>(null)
|
||||
const rearmTimerRef = useRef<number | null>(null)
|
||||
const lastReadRef = useRef({ value: '', time: 0 })
|
||||
const [armed, setArmed] = useState(true)
|
||||
const [status, setStatus] = useState<ScannerStatus>('starting')
|
||||
const [cameraError, setCameraError] = useState('')
|
||||
const [cameras, setCameras] = useState<MediaDeviceInfo[]>([])
|
||||
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<ScanFormatMode>(initialPreferences.formatMode)
|
||||
const [confirmationFrames, setConfirmationFrames] = useState(initialPreferences.confirmationFrames)
|
||||
const [minimumSize, setMinimumSize] = useState(initialPreferences.minimumSize)
|
||||
const [payloadRules, setPayloadRules] = useState<PayloadRules>(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<DetectionPoint[]>([])
|
||||
const [latest, setLatest] = useState<ScanRecord | null>(null)
|
||||
const [pendingCapture, setPendingCapture] = useState<PendingCapture | null>(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 (
|
||||
<section className="scanner-view" aria-labelledby="scanner-title">
|
||||
<div className={`camera-stage scanner-${status} ${showQualitySettings ? 'quality-settings-open' : ''}`} ref={stageRef}>
|
||||
<video ref={videoRef} autoPlay muted playsInline aria-label="Rear camera preview" />
|
||||
<div className="camera-shade" />
|
||||
<div className="camera-heading">
|
||||
<div>
|
||||
<span className="eyebrow">FIELDLENS / LIVE</span>
|
||||
<h1 id="scanner-title">Scan station</h1>
|
||||
</div>
|
||||
<div className="camera-actions">
|
||||
<span className="local-badge"><span /> {statusLabel}</span>
|
||||
{installAction}
|
||||
</div>
|
||||
</div>
|
||||
<div className="camera-tools" aria-label="Scanner controls">
|
||||
{cameras.length > 1 && (
|
||||
<label className="camera-select">
|
||||
<SwitchCamera size={15} />
|
||||
<span className="sr-only">Camera</span>
|
||||
<select value={selectedCamera} onChange={(event) => { setSelectedCamera(event.target.value); setRequestedCamera(event.target.value) }}>
|
||||
{cameras.map((camera, index) => <option value={camera.deviceId} key={camera.deviceId}>{camera.label || `Camera ${index + 1}`}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{torchAvailable && (
|
||||
<button className="camera-tool-button" type="button" aria-label={torchOn ? 'Turn torch off' : 'Turn torch on'} title={torchOn ? 'Turn torch off' : 'Turn torch on'} aria-pressed={torchOn} onClick={() => void toggleTorch()}>
|
||||
{torchOn ? <ZapOff /> : <Zap />}
|
||||
</button>
|
||||
)}
|
||||
<button className="camera-tool-button" type="button" aria-label={soundEnabled ? 'Turn scan sounds off' : 'Turn scan sounds on'} title={soundEnabled ? 'Turn scan sounds off' : 'Turn scan sounds on'} aria-pressed={soundEnabled} onClick={toggleSound}>
|
||||
{soundEnabled ? <Volume2 /> : <VolumeX />}
|
||||
</button>
|
||||
<button className="auto-toggle" type="button" aria-pressed={autoAdvance} onClick={toggleAutoAdvance}>
|
||||
<span /> Auto next
|
||||
</button>
|
||||
<button className="camera-tool-button" type="button" aria-label="Scan quality settings" title="Scan quality settings" aria-expanded={showQualitySettings} onClick={toggleQualitySettings}>
|
||||
<Settings2 />
|
||||
</button>
|
||||
</div>
|
||||
{showQualitySettings && (
|
||||
<div className="quality-panel">
|
||||
<label><span>Formats</span><select value={formatMode} onChange={(event) => { const value = event.target.value as ScanFormatMode; setFormatMode(value); saveScannerPreferences({ ...loadScannerPreferences(), formatMode: value }) }}>{scanFormatOptions.map((option) => <option value={option.value} key={option.value}>{option.label}</option>)}</select></label>
|
||||
<label><span>Confirm reads</span><select value={confirmationFrames} onChange={(event) => updateConfirmationFrames(Number(event.target.value) as 2 | 3)}><option value="2">2 frames</option><option value="3">3 frames</option></select></label>
|
||||
<label className="quality-range"><span>Minimum size <b>{minimumSize}%</b></span><input type="range" min="0" max="20" step="1" value={minimumSize} onChange={(event) => updateMinimumSize(Number(event.target.value))} /></label>
|
||||
<label className="quality-check"><input type="checkbox" checked={payloadRules.enabled} onChange={(event) => updatePayloadRules({ ...payloadRules, enabled: event.target.checked })} /><span>Validate known barcode standards</span></label>
|
||||
<label><span>Expected length</span><input type="number" min="0" max="256" inputMode="numeric" placeholder="Any" value={payloadRules.expectedLength || ''} onChange={(event) => updatePayloadRules({ ...payloadRules, expectedLength: Math.max(0, Number(event.target.value)) })} /></label>
|
||||
<label><span>Required prefix</span><input type="text" placeholder="Any" value={payloadRules.requiredPrefix} onChange={(event) => updatePayloadRules({ ...payloadRules, requiredPrefix: event.target.value })} /></label>
|
||||
</div>
|
||||
)}
|
||||
<div className={`scan-frame ${latest && /QR|Data Matrix|Aztec|MaxiCode/i.test(latest.format) ? 'matrix' : ''}`} aria-hidden="true"><span /><span /><span /><span /></div>
|
||||
{detectionPoints.map((point, index) => <i className="detection-point" style={{ left: `${point.left}%`, top: `${point.top}%` }} key={index} />)}
|
||||
<p className="camera-instruction" aria-live="polite">
|
||||
{cameraError || (showQualitySettings ? 'Scanning paused while settings are open' : qualityMessage || (status === 'starting' ? 'Preparing the rear camera' : armed ? `Barcode type is detected automatically${confirmationProgress ? ` · ${confirmationProgress}/${confirmationFrames}` : ''}` : autoAdvance ? 'Captured · scanner will rearm automatically' : 'Capture held for review'))}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={`scan-result ${latest?.isDuplicate ? 'duplicate' : ''}`} aria-live="polite">
|
||||
<div className="result-icon">
|
||||
{latest?.isDuplicate ? <ShieldAlert /> : latest ? <CheckCircle2 /> : <Camera />}
|
||||
</div>
|
||||
<div className="result-copy">
|
||||
<span className="eyebrow">
|
||||
{status === 'storage-error' ? 'CAPTURE NOT SAVED' : cameraError ? 'CAMERA UNAVAILABLE' : latest?.isDuplicate ? 'DUPLICATE DETECTED' : latest ? 'CAPTURE SAVED' : 'SCANNER READY'}
|
||||
</span>
|
||||
<strong>{status === 'storage-error' ? pendingCapture?.rawValue : cameraError ? 'Camera access required' : latest?.rawValue ?? 'Waiting for a code'}</strong>
|
||||
<small>{status === 'storage-error' ? 'Local storage failed. Retry saving or discard this capture.' : cameraError ? (status === 'permission-denied' ? 'Allow camera access in browser settings, then try again.' : 'Check that a camera is available, then try again.') : latest ? `${latest.format} · stored on this device` : 'Nothing leaves the device until sync is configured.'}</small>
|
||||
</div>
|
||||
{status === 'storage-error' && pendingCapture ? (
|
||||
<div className="result-actions">
|
||||
<button className="quiet-button" type="button" onClick={discardPendingCapture}>Discard</button>
|
||||
<button className="primary-button" type="button" onClick={() => void saveCapture(pendingCapture)}><RefreshCw size={17} /> Retry save</button>
|
||||
</div>
|
||||
) : cameraError ? (
|
||||
<button className="primary-button" type="button" onClick={retryCamera}><RefreshCw size={17} /> Try camera again</button>
|
||||
) : (
|
||||
<button className="primary-button" type="button" disabled={armed} onClick={() => setScannerArmed(true)}>
|
||||
<RefreshCw size={17} /> Scan next
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@ -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<void>
|
||||
}
|
||||
|
||||
export function SettingsView({ storageStatus, updateAvailable, isUpdating, updateError, onApplyUpdate }: SettingsViewProps) {
|
||||
const [preferences, setPreferences] = useState(loadScannerPreferences)
|
||||
const [deviceHold, setDeviceHold] = useState<DeviceHoldOrientation>(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<ScannerPreferences>) => {
|
||||
const next = { ...preferences, ...updates }
|
||||
setPreferences(next)
|
||||
saveScannerPreferences(next)
|
||||
}
|
||||
|
||||
const updatePayloadRules = (updates: Partial<ScannerPreferences['payloadRules']>) => {
|
||||
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 (
|
||||
<section className="workspace-view settings-view" aria-labelledby="settings-title">
|
||||
<header className="view-heading">
|
||||
<div><span className="eyebrow">DEVICE PROFILE</span><h1 id="settings-title">Settings</h1><p>Core scanner defaults are stored locally on this device.</p></div>
|
||||
<button className="quiet-button" type="button" onClick={() => setPreferences(resetScannerPreferences())}><RefreshCcw size={16} /> Reset defaults</button>
|
||||
</header>
|
||||
|
||||
<div className="settings-layout">
|
||||
<section className="settings-section">
|
||||
<header><span className="settings-icon"><ScanLine /></span><div><h2>Capture</h2><p>Detection and workflow defaults</p></div></header>
|
||||
<div className="setting-row"><label htmlFor="setting-formats"><strong>Barcode formats</strong><small>Choose the decoder profile</small></label><select id="setting-formats" value={preferences.formatMode} onChange={(event) => updatePreferences({ formatMode: event.target.value as ScannerPreferences['formatMode'] })}>{scanFormatOptions.map((option) => <option value={option.value} key={option.value}>{option.label}</option>)}</select></div>
|
||||
<div className="setting-row"><div><strong>Phone orientation</strong><small>Detected from how the device is held.</small></div><b>{deviceHold === 'portrait' ? 'Portrait' : 'Landscape'}</b></div>
|
||||
<div className="setting-row"><div><strong>Automatic next scan</strong><small>Rearm after a successful capture</small></div><button className="switch-control" type="button" role="switch" aria-checked={preferences.autoAdvance} onClick={() => updatePreferences({ autoAdvance: !preferences.autoAdvance })}><span /></button></div>
|
||||
<div className="setting-row"><div><strong>Capture sound</strong><small>Play distinct success and duplicate tones</small></div><button className="switch-control" type="button" role="switch" aria-checked={preferences.soundEnabled} onClick={toggleSound}><span /></button></div>
|
||||
<div className="setting-row"><label htmlFor="setting-confirmation"><strong>Confirmation reads</strong><small>Consecutive matches before capture</small></label><select id="setting-confirmation" value={preferences.confirmationFrames} onChange={(event) => updatePreferences({ confirmationFrames: Number(event.target.value) as 2 | 3 })}><option value="2">2 frames</option><option value="3">3 frames</option></select></div>
|
||||
<div className="setting-row setting-range"><label htmlFor="setting-size"><strong>Minimum code size</strong><small>Reject distant codes below {preferences.minimumSize}% of frame</small></label><input id="setting-size" type="range" min="0" max="20" value={preferences.minimumSize} onChange={(event) => updatePreferences({ minimumSize: Number(event.target.value) })} /></div>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<header><span className="settings-icon"><ShieldCheck /></span><div><h2>Validation</h2><p>Payload acceptance rules</p></div></header>
|
||||
<div className="setting-row"><div><strong>Known standards</strong><small>Validate checksums and barcode structure</small></div><button className="switch-control" type="button" role="switch" aria-checked={preferences.payloadRules.enabled} onClick={() => updatePayloadRules({ enabled: !preferences.payloadRules.enabled })}><span /></button></div>
|
||||
<div className="setting-row"><label htmlFor="setting-length"><strong>Expected length</strong><small>Zero accepts any payload length</small></label><input id="setting-length" className="compact-input" type="number" min="0" max="256" inputMode="numeric" value={preferences.payloadRules.expectedLength || ''} placeholder="Any" onChange={(event) => updatePayloadRules({ expectedLength: Math.max(0, Number(event.target.value)) })} /></div>
|
||||
<div className="setting-row"><label htmlFor="setting-prefix"><strong>Required prefix</strong><small>Optional exact leading characters</small></label><input id="setting-prefix" className="compact-input" type="text" value={preferences.payloadRules.requiredPrefix} placeholder="None" onChange={(event) => updatePayloadRules({ requiredPrefix: event.target.value })} /></div>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<header><span className="settings-icon"><Database /></span><div><h2>Local-first storage</h2><p>Core persistence and integration boundary</p></div></header>
|
||||
<div className="setting-row"><div><strong>Browser storage</strong><small>Capture data stays usable without a backend</small></div><b>{storageLabel}</b></div>
|
||||
<div className="setting-row"><div><strong>Remote integration</strong><small>Register a provider or add sync in the host application</small></div><b>Host supplied</b></div>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<header><span className="settings-icon"><CloudDownload /></span><div><h2>Application</h2><p>PWA lifecycle</p></div></header>
|
||||
<div className="setting-row"><div><strong>{updateAvailable ? 'Update available' : 'Service worker active'}</strong><small>{updateError || (isUpdating ? 'Installing the latest build…' : updateAvailable ? 'A newer build is ready.' : 'Updates are managed by the PWA service worker.')}</small></div><button className="quiet-button" type="button" disabled={!updateAvailable || isUpdating} onClick={() => void onApplyUpdate()}><CloudDownload size={15} /> {isUpdating ? 'Updating' : 'Update'}</button></div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
17
packages/fieldlens-core/src/index.css
Normal file
@ -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; }
|
||||
18
packages/fieldlens-core/src/ios-safe-area.css
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
31
packages/fieldlens-core/src/lib/barcodes.test.ts
Normal file
@ -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()
|
||||
})
|
||||
})
|
||||
96
packages/fieldlens-core/src/lib/barcodes.ts
Normal file
@ -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('')
|
||||
}
|
||||
91
packages/fieldlens-core/src/lib/cartonArTracking.test.ts
Normal file
@ -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)
|
||||
})
|
||||
})
|
||||
354
packages/fieldlens-core/src/lib/cartonArTracking.ts
Normal file
@ -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<number>(4).fill(0))
|
||||
const rhs = Array<number>(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<CameraDisplayOrientation, 'auto'> = 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
|
||||
}
|
||||
56
packages/fieldlens-core/src/lib/cartonConfigs.ts
Normal file
@ -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<CartonConfig>
|
||||
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 }
|
||||
}
|
||||
@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { createCartonLabelScan, summarizeCartonLabels } from './cartonLabelConformity'
|
||||
import type { LabelOcrResult } from './labelOcr'
|
||||
|
||||
function ocr(overrides: Partial<LabelOcrResult['fields']> = {}, 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')
|
||||
})
|
||||
})
|
||||
191
packages/fieldlens-core/src/lib/cartonLabelConformity.ts
Normal file
@ -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<string, { count: number; display: string }>()
|
||||
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,
|
||||
}
|
||||
}
|
||||
20
packages/fieldlens-core/src/lib/cartonLabelDetection.test.ts
Normal file
@ -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)
|
||||
})
|
||||
})
|
||||
189
packages/fieldlens-core/src/lib/cartonLabelDetection.ts
Normal file
@ -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<Blob> {
|
||||
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<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.94))
|
||||
if (!blob) throw new Error('Could not crop the carton label.')
|
||||
return blob
|
||||
}
|
||||
@ -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])
|
||||
})
|
||||
})
|
||||
129
packages/fieldlens-core/src/lib/cartonTrackAssociation.ts
Normal file
@ -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<number, CandidateMatch>()
|
||||
const bestByDetection = new Map<number, CandidateMatch>()
|
||||
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<number>()
|
||||
const usedDetections = new Set<number>()
|
||||
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)),
|
||||
}
|
||||
}
|
||||
116
packages/fieldlens-core/src/lib/database.test.ts
Normal file
@ -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()
|
||||
})
|
||||
})
|
||||
386
packages/fieldlens-core/src/lib/database.ts
Normal file
@ -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<ScanRecord, 'id'>
|
||||
eanPalletSessions!: EntityTable<EanPalletSessionRecord, 'id'>
|
||||
palletLabelSessions!: EntityTable<PalletLabelSessionRecord, 'id'>
|
||||
outbox!: EntityTable<OutboxRecord, 'id'>
|
||||
metadata!: EntityTable<MetadataRecord, 'key'>
|
||||
|
||||
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<EanPalletSessionRecord, 'conforms' | 'consensusEan' | 'uniqueEanCount'>,
|
||||
) {
|
||||
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<PalletLabelSessionRecord, 'conforms' | 'resultStatus' | 'mismatchCartons' | 'majorityFields'>,
|
||||
) {
|
||||
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<T>(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)
|
||||
}
|
||||
28
packages/fieldlens-core/src/lib/deviceOrientation.test.ts
Normal file
@ -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)
|
||||
})
|
||||
})
|
||||
265
packages/fieldlens-core/src/lib/deviceOrientation.ts
Normal file
@ -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<HTMLElement>('.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<HTMLElement>('.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<HTMLButtonElement>('.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<HTMLElement>('[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<DetectionRotation>(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<DeviceHoldOrientation>(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<HTMLVideoElement>()
|
||||
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<HTMLElement>('.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 = '<span aria-hidden="true">↻</span><small data-detection-rotation-value>AUTO</small>'
|
||||
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)
|
||||
}
|
||||
}
|
||||
64
packages/fieldlens-core/src/lib/eanPallet.test.ts
Normal file
@ -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 },
|
||||
])
|
||||
})
|
||||
})
|
||||
104
packages/fieldlens-core/src/lib/eanPallet.ts
Normal file
@ -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<string, number>()
|
||||
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,
|
||||
}
|
||||
}
|
||||
51
packages/fieldlens-core/src/lib/labelExpectationProviders.ts
Normal file
@ -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<ExpectedLabelSpec[]>
|
||||
}
|
||||
|
||||
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<LabelExpectationLookup> {
|
||||
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.`,
|
||||
}
|
||||
}
|
||||
}
|
||||
113
packages/fieldlens-core/src/lib/labelOcr.test.ts
Normal file
@ -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')
|
||||
})
|
||||
})
|
||||
363
packages/fieldlens-core/src/lib/labelOcr.ts
Normal file
@ -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<TesseractResult>
|
||||
setParameters?: (parameters: Record<string, string>) => Promise<unknown>
|
||||
terminate?: () => Promise<unknown>
|
||||
}
|
||||
|
||||
type TesseractApi = {
|
||||
recognize: (
|
||||
image: Blob | string,
|
||||
language?: string,
|
||||
options?: { logger?: (message: TesseractProgress) => void },
|
||||
) => Promise<TesseractResult>
|
||||
createWorker?: (
|
||||
languages?: string | string[],
|
||||
oem?: number,
|
||||
options?: { logger?: (message: TesseractProgress) => void },
|
||||
) => Promise<TesseractWorker>
|
||||
}
|
||||
|
||||
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<LabelFieldKey, string>
|
||||
|
||||
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<LabelFieldKey, string> = {
|
||||
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<OcrWorkerSlot[]> | 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<LabelFields>((clean, field) => {
|
||||
clean[field] = isValidLabelFieldValue(field, fields[field]) ? fields[field].trim() : ''
|
||||
return clean
|
||||
}, emptyFields())
|
||||
}
|
||||
|
||||
export function getCartonLabelIdentity(ocr: Pick<LabelOcrResult, 'fields' | 'rawText'>) {
|
||||
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<LabelOcrResult, 'fields' | 'rawText'>) {
|
||||
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<TesseractApi>((resolve, reject) => {
|
||||
const existing = document.querySelector<HTMLScriptElement>(`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<Blob> {
|
||||
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<Blob | null>((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<LabelOcrResult> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
33
packages/fieldlens-core/src/lib/preferences.test.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { defaultScannerPreferences, loadScannerPreferences, resetScannerPreferences, saveScannerPreferences } from './preferences'
|
||||
|
||||
const values = new Map<string, string>()
|
||||
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)
|
||||
})
|
||||
})
|
||||
50
packages/fieldlens-core/src/lib/preferences.ts
Normal file
@ -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<ScannerPreferences>
|
||||
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)
|
||||
}
|
||||
15
packages/fieldlens-core/src/lib/scanAudio.ts
Normal file
@ -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
|
||||
}
|
||||
17
packages/fieldlens-core/src/main.tsx
Normal file
@ -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(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
173
packages/fieldlens-core/src/phone-landscape.css
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
161
packages/fieldlens-core/src/ui-polish.css
Normal file
@ -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; }
|
||||
}
|
||||
}
|
||||