This commit is contained in:
Stan
2026-04-19 21:14:16 +02:00
parent 0c74a75126
commit 28d167f11f
42 changed files with 5681 additions and 55 deletions
+50
View File
@@ -0,0 +1,50 @@
/*
* API communication module. Centralizes fetch calls and service-worker
* registration so network details stay out of rendering and state logic.
*/
import { API_BASE } from './constants.js';
/*
* Generic JSON fetcher. All frontend API calls pass through this function so
* error handling, header defaults, and base path are consistent everywhere.
*/
export async function fetchJson(path, options = {}) {
const url = path.startsWith('http') ? path : `${API_BASE}${path}`;
const requestOptions = {
...options,
headers: {
Accept: 'application/json',
...(options.headers || {})
}
};
const response = await fetch(url, requestOptions);
if (!response.ok) {
let message = `Request failed for ${url}: ${response.status}`;
try {
const errorPayload = await response.json();
if (errorPayload?.message) {
message = errorPayload.message;
}
} catch {
/* Ignore JSON parse errors for non-JSON responses. */
}
throw new Error(message);
}
return response.json();
}
export function registerServiceWorker() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch((error) => {
console.error('Service worker registration failed', error);
});
}
}
+30
View File
@@ -0,0 +1,30 @@
/*
* Application-wide constants shared by all frontend modules.
*
* Centralizing these values prevents magic strings and numbers from being
* scattered across the codebase and makes configuration changes easy to trace.
*/
/* IndexedDB database name and schema version (bump when stores change). */
export const DB_NAME = 'check-list-poc-db';
export const DB_VERSION = 2;
/* IndexedDB object store names. */
export const STORE_TEMPLATES = 'templates';
export const STORE_LOOKUPS = 'lookups';
export const STORE_CONFIG = 'config';
export const STORE_REPORTS = 'reports';
export const STORE_ATTACHMENTS = 'attachments';
export const STORE_SETTINGS = 'settings';
/* Autosave interval used when the server does not supply a value. */
export const DEFAULT_AUTOSAVE_SECONDS = 20;
/* Base path for all versioned API calls. */
export const API_BASE = '/api/v1';
/* Minimum delay before re-rendering the form after a field change. */
export const RENDER_DEBOUNCE_MS = 200;
/* Maximum entries kept in the Service Worker dynamic cache (LRU eviction). */
export const SW_DYNAMIC_CACHE_LIMIT = 50;
+133
View File
@@ -0,0 +1,133 @@
/*
* IndexedDB operations module. Provides typed helpers for reading and writing to
* the browser-side database. All functions depend on `state.db` being set during
* initialization.
*
* Changes from the original monolithic app.js:
* - A5: `dbTransaction()` wraps multi-store operations in a single IndexedDB
* transaction so related writes (e.g. delete report + delete attachments) are
* atomic and won't leave orphaned records on mid-operation failure.
*/
import { state } from './state.js';
import {
DB_NAME,
DB_VERSION,
STORE_TEMPLATES,
STORE_LOOKUPS,
STORE_CONFIG,
STORE_REPORTS,
STORE_ATTACHMENTS,
STORE_SETTINGS
} from './constants.js';
/* ── Database bootstrap ─────────────────────────────────────────────────── */
export function openDatabase() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = request.result;
if (!db.objectStoreNames.contains(STORE_TEMPLATES)) {
db.createObjectStore(STORE_TEMPLATES, { keyPath: 'cacheKey' });
}
if (!db.objectStoreNames.contains(STORE_LOOKUPS)) {
db.createObjectStore(STORE_LOOKUPS, { keyPath: 'code' });
}
if (!db.objectStoreNames.contains(STORE_CONFIG)) {
db.createObjectStore(STORE_CONFIG, { keyPath: 'key' });
}
if (!db.objectStoreNames.contains(STORE_REPORTS)) {
db.createObjectStore(STORE_REPORTS, { keyPath: 'id' });
}
if (!db.objectStoreNames.contains(STORE_ATTACHMENTS)) {
const store = db.createObjectStore(STORE_ATTACHMENTS, { keyPath: 'id' });
store.createIndex('byReportId', 'reportId', { unique: false });
}
if (!db.objectStoreNames.contains(STORE_SETTINGS)) {
db.createObjectStore(STORE_SETTINGS, { keyPath: 'key' });
}
};
});
}
/* ── Single-store helpers ───────────────────────────────────────────────── */
export function dbGetAll(storeName) {
return executeStoreRequest(storeName, 'readonly', (store) => store.getAll());
}
export function dbGet(storeName, key) {
return executeStoreRequest(storeName, 'readonly', (store) => store.get(key));
}
export function dbPut(storeName, value) {
return executeStoreRequest(storeName, 'readwrite', (store) => store.put(value));
}
export function dbDelete(storeName, key) {
return executeStoreRequest(storeName, 'readwrite', (store) => store.delete(key));
}
export function dbGetAllByIndex(storeName, indexName, key) {
return executeStoreRequest(storeName, 'readonly', (store) => {
return store.index(indexName).getAll(IDBKeyRange.only(key));
});
}
function executeStoreRequest(storeName, mode, callback) {
return new Promise((resolve, reject) => {
const transaction = state.db.transaction(storeName, mode);
const store = transaction.objectStore(storeName);
const request = callback(store);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
/* ── Multi-store transaction (A5) ───────────────────────────────────────── */
/*
* Wraps multiple writes across different object stores in a single IndexedDB
* transaction. The callback receives a helper that returns a store by name.
* All writes either commit together or roll back as a unit.
*
* Example:
* await dbTransaction([STORE_REPORTS, STORE_ATTACHMENTS], 'readwrite', (getStore) => {
* getStore(STORE_REPORTS).delete(reportId);
* getStore(STORE_ATTACHMENTS).delete(attachmentId);
* });
*/
export function dbTransaction(storeNames, mode, callback) {
return new Promise((resolve, reject) => {
const tx = state.db.transaction(storeNames, mode);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error || new Error('Transaction aborted'));
const getStore = (name) => tx.objectStore(name);
callback(getStore);
});
}
/* ── Settings helpers ───────────────────────────────────────────────────── */
export async function saveSetting(key, value) {
await dbPut(STORE_SETTINGS, { key, value });
}
export async function loadSetting(key) {
const record = await dbGet(STORE_SETTINGS, key);
return record?.value;
}
+100
View File
@@ -0,0 +1,100 @@
/*
* CSV and attachment export module (F2). Generates a CSV file from the current
* report's answers and allows downloading individual attachments. XLSX and ZIP
* export can be added by integrating SheetJS and JSZip libraries.
*/
import { state, getCurrentReport, getTemplateRecord } from './state.js';
import { dbGetAllByIndex } from './db.js';
import { STORE_ATTACHMENTS } from './constants.js';
/*
* Exports the active report as a CSV file. Columns are derived from the template
* definition so field labels appear as headers and field values as the row.
*/
export async function exportReportCSV() {
const report = getCurrentReport();
if (!report) {
throw new Error('No report to export');
}
const template = getTemplateRecord(report.templateCode, report.templateVersion);
if (!template) {
throw new Error('Template definition needed for export');
}
const headers = [];
const values = [];
/* Meta columns. */
headers.push('Report Number', 'Template', 'Version', 'Status', 'Created', 'Updated');
values.push(
report.reportNumber,
report.templateCode,
String(report.templateVersion),
report.status,
report.createdAt,
report.updatedAt
);
/* Dynamic field columns derived from the template definition. */
for (const section of template.definition.sections || []) {
for (const field of section.fields || []) {
if (field.type === 'attachment') {
continue;
}
headers.push(field.label);
const raw = report.answers[field.id];
values.push(raw === undefined || raw === null ? '' : String(raw));
}
}
const csvContent = [
headers.map(csvEscape).join(','),
values.map(csvEscape).join(',')
].join('\r\n');
downloadBlob(
new Blob([csvContent], { type: 'text/csv;charset=utf-8' }),
`${report.reportNumber || 'report'}.csv`
);
}
/*
* Exports all attachments for the active report as individual file downloads.
* A future iteration could bundle these into a ZIP archive using JSZip.
*/
export async function exportReportAttachments() {
const report = getCurrentReport();
if (!report) {
throw new Error('No report to export');
}
const attachments = await dbGetAllByIndex(STORE_ATTACHMENTS, 'byReportId', report.id);
for (const attachment of attachments) {
downloadBlob(attachment.blob, attachment.generatedFilename);
}
}
/* ── Helpers ────────────────────────────────────────────────────────────── */
function csvEscape(value) {
const str = String(value).replace(/"/g, '""');
return /[",\r\n]/.test(str) ? `"${str}"` : str;
}
function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
}
+239
View File
@@ -0,0 +1,239 @@
/*
* Dynamic form field creation. Each function returns a DOM node tree for a
* single template field. The approach keeps template-driven rendering in one
* module while the orchestrator (app.js) provides callbacks for state mutations.
*
* Callback contract:
* onFieldChange(field, nextValue) — called when the user edits a field
* onAttachFiles(field, report, files) — called when files are selected
* onRemoveAttachment(attachmentId) — called to remove an attachment
*/
import { evaluateRequiredWhen } from './validation.js';
import { formatFileSize } from './utils.js';
export function createFieldNode(field, report, { state, onFieldChange, onAttachFiles, onRemoveAttachment }) {
const wrapper = document.createElement('div');
wrapper.className = `field ${field.type === 'comment' || field.type === 'attachment' ? 'field-full' : ''}`;
const isRequired = Boolean(field.required || evaluateRequiredWhen(field.requiredWhen, report.answers));
const currentValue = report.answers[field.id];
wrapper.innerHTML = `
<div class="field-header">
<label class="field-label" for="field-${field.id}">${escapeHtml(field.label)}</label>
${isRequired ? '<span class="required-pill">Required</span>' : ''}
</div>
`;
let inputNode;
switch (field.type) {
case 'text':
case 'date':
case 'number':
inputNode = document.createElement('input');
inputNode.className = 'text-input';
inputNode.id = `field-${field.id}`;
inputNode.name = field.id;
inputNode.type = field.type;
inputNode.value = currentValue ?? '';
inputNode.readOnly = Boolean(field.readOnly);
if (field.validation?.min !== undefined) {
inputNode.min = String(field.validation.min);
}
inputNode.addEventListener('input', (event) => {
onFieldChange(field, event.target.value);
});
break;
case 'lookup': {
inputNode = document.createElement('select');
inputNode.className = 'select-input';
inputNode.id = `field-${field.id}`;
inputNode.name = field.id;
const emptyOption = document.createElement('option');
emptyOption.value = '';
emptyOption.textContent = 'Select an option';
inputNode.append(emptyOption);
const lookup = state.lookups.get(field.lookupCode);
for (const optionData of lookup?.values || []) {
const option = document.createElement('option');
option.value = optionData.value;
option.textContent = optionData.label;
option.selected = optionData.value === currentValue;
inputNode.append(option);
}
inputNode.addEventListener('change', (event) => {
onFieldChange(field, event.target.value);
});
break;
}
case 'checkbox':
inputNode = document.createElement('label');
inputNode.className = 'checkbox-row';
inputNode.innerHTML = `
<input id="field-${field.id}" name="${field.id}" type="checkbox" ${currentValue ? 'checked' : ''} />
<span>${escapeHtml(field.label)}</span>
`;
inputNode.querySelector('input').addEventListener('change', (event) => {
onFieldChange(field, event.target.checked);
});
break;
case 'comment':
inputNode = document.createElement('textarea');
inputNode.className = 'text-area';
inputNode.id = `field-${field.id}`;
inputNode.name = field.id;
inputNode.maxLength = field.maxLength || 5000;
inputNode.value = currentValue ?? '';
inputNode.addEventListener('input', (event) => {
onFieldChange(field, event.target.value);
});
break;
case 'attachment':
inputNode = createAttachmentFieldNode(field, report, { state, onAttachFiles, onRemoveAttachment });
break;
default:
inputNode = document.createElement('input');
inputNode.className = 'text-input';
inputNode.id = `field-${field.id}`;
inputNode.name = field.id;
inputNode.type = 'text';
inputNode.value = currentValue ?? '';
inputNode.addEventListener('input', (event) => {
onFieldChange(field, event.target.value);
});
}
wrapper.append(inputNode);
if (field.requiredWhen?.field) {
const help = document.createElement('p');
help.className = 'field-help';
help.textContent = `Required when ${field.requiredWhen.field} is ${String(field.requiredWhen.equals)}.`;
wrapper.append(help);
}
return wrapper;
}
/* ── Attachment field ───────────────────────────────────────────────────── */
function createAttachmentFieldNode(field, report, { state, onAttachFiles, onRemoveAttachment }) {
const container = document.createElement('div');
container.className = 'attachment-list';
const toolbar = document.createElement('div');
toolbar.className = 'attachment-toolbar';
const input = document.createElement('input');
input.className = 'file-input';
input.id = `field-${field.id}`;
input.type = 'file';
input.accept = state.imageRules?.allowedMimeTypes?.join(',') || 'image/*';
input.multiple = true;
input.addEventListener('change', async (event) => {
const files = Array.from(event.target.files || []);
await onAttachFiles(field, report, files);
event.target.value = '';
});
toolbar.append(input);
container.append(toolbar);
/*
* P3 — Lazy-load attachment previews. Thumbnails are created from object URLs
* on demand using IntersectionObserver. Only attachments scrolled into view
* allocate a Blob URL, keeping memory use proportional to the visible area.
*/
const attachments = state.currentAttachments.filter((item) => item.fieldId === field.id);
if (!attachments.length) {
const hint = document.createElement('p');
hint.className = 'field-help';
hint.textContent = 'No images attached yet.';
container.append(hint);
return container;
}
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) {
continue;
}
const img = entry.target;
const attachment = attachments.find((a) => a.id === img.dataset.attachmentId);
if (attachment?.blob) {
const objectUrl = URL.createObjectURL(attachment.blob);
img.src = objectUrl;
img.addEventListener('load', () => URL.revokeObjectURL(objectUrl), { once: true });
}
observer.unobserve(img);
}
}, { rootMargin: '200px' });
for (const attachment of attachments) {
const card = document.createElement('article');
card.className = 'attachment-card';
const preview = document.createElement('img');
preview.className = 'attachment-preview';
preview.alt = attachment.generatedFilename;
preview.dataset.attachmentId = attachment.id;
/* Actual src loaded lazily by the IntersectionObserver above. */
observer.observe(preview);
const copy = document.createElement('div');
copy.className = 'attachment-card__copy';
copy.innerHTML = `
<strong>${escapeHtml(attachment.generatedFilename)}</strong>
<span>${escapeHtml(attachment.originalFilename)}</span>
<span>${attachment.width}x${attachment.height}px &bull; ${formatFileSize(attachment.sizeBytes)}</span>
`;
const removeButton = document.createElement('button');
removeButton.type = 'button';
removeButton.className = 'button button-small button-ghost';
removeButton.textContent = 'Remove';
removeButton.addEventListener('click', () => {
void onRemoveAttachment(attachment.id);
});
card.append(preview, copy, removeButton);
container.append(card);
}
return container;
}
/* ── Helpers ────────────────────────────────────────────────────────────── */
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
+121
View File
@@ -0,0 +1,121 @@
/*
* Lightweight i18n module. All user-facing strings are collected here so the app
* can be translated by swapping or extending the locale object. The current
* implementation is English-only; a future iteration could load locale files
* dynamically based on a user preference.
*/
const en = {
/* General */
appTitle: 'Check List',
appTagline: 'Offline-first proof of concept for template-driven quality reports.',
checkingConnection: 'Checking connection',
online: 'Online',
offline: 'Offline',
noChanges: 'No changes',
/* Sync */
syncingTemplates: 'Syncing templates',
templatesSynced: 'Templates synced',
syncFailed: 'Sync failed, using cache',
offlineMode: 'Offline mode',
/* Reports */
noReportSelected: 'No report selected',
noReportSelectedHint: 'Start by syncing templates and creating a local draft.',
noLocalReports: 'No local reports yet.',
selectTemplate: 'Select a template first',
templateNotAvailable: 'Template not available locally',
newReportCreated: 'New report created',
reportDeleted: 'Report deleted',
deleteReportConfirm: 'Delete local report {0}?',
draftUpdated: 'Draft updated',
statusChanged: 'Status changed',
saved: 'Saved {0}',
/* Submission */
submitting: 'Submitting report…',
submitted: 'Report submitted to server',
submitFailed: 'Submission failed',
goOnlineToSubmit: 'Go online to submit a report',
/* Validation */
readyForExport: 'Ready for export validation',
noBlockingIssues: 'No blocking validation issues detected for the current draft.',
noValidationIssues: 'No validation issues.',
issueCount: '{0} issue(s) to resolve',
issueReadyWarning: 'The report is marked ready, but validation still has blocking items.',
issueDraftHint: 'Draft save is still allowed, but export should be blocked until these issues are fixed.',
templateUnavailable: 'Template unavailable',
validationNeedsTemplate: 'Validation cannot run until the template is cached again.',
required: 'value is required.',
numberInvalid: 'number is invalid.',
numberMin: 'must be at least {0}.',
imageRequired: 'at least one image is required.',
templateMissing: 'Template definition missing for this report version.',
/* Attachments */
noImagesAttached: 'No images attached yet.',
maxAttachments: 'Only {0} attachment(s) allowed',
imageSkipped: 'Image skipped: {0}',
imagesUpdated: 'Images updated',
attachmentRemoved: 'Attachment removed',
unsupportedFileType: 'Unsupported file type: {0}',
fileExceedsLimit: 'File exceeds limit: {0}',
optimizeFailed: 'Failed to optimize image: {0}',
optimizedStillExceeds: 'Optimized image still exceeds limit: {0}',
/* Admin */
savingImagePolicy: 'Saving image policy',
imagePolicySaved: 'Image policy saved',
goOnlineToSave: 'Go online to save admin settings',
policyNameRequired: 'Policy name is required',
addMimeType: 'Add at least one MIME type',
maxFileSizePositive: 'Max file size must be greater than 0',
maxWidthPositive: 'Max width must be a positive number',
maxHeightPositive: 'Max height must be a positive number',
jpegQualityRange: 'JPEG quality must be between 1 and 100',
maxAttachmentsPositive: 'Max attachments must be a positive number',
/* Template management */
publishingVersion: 'Publishing version…',
versionPublished: 'Template version published',
publishFailed: 'Failed to publish version',
/* Export */
exportStarted: 'Preparing export…',
exportComplete: 'Export ready — file downloaded',
exportFailed: 'Export failed',
noReportToExport: 'Open a report before exporting',
noTemplateForExport: 'Template definition needed for export',
/* Search */
searchPlaceholder: 'Search reports…',
/* Misc */
noTemplatesCached: 'No cached templates available',
liveConfig: 'Live server configuration',
offlineCachedConfig: 'Offline cached configuration',
noImageRulesLoaded: 'No image rules loaded',
imagePolicyHint: 'Load server configuration to see image limits and optimization rules.'
};
let currentLocale = en;
/*
* Retrieve a translated string by key. Optional positional parameters replace
* {0}, {1}, etc. placeholders.
*/
export function t(key, ...params) {
let text = currentLocale[key] ?? key;
for (let i = 0; i < params.length; i++) {
text = text.replace(`{${i}}`, String(params[i]));
}
return text;
}
export function setLocale(locale) {
currentLocale = { ...en, ...locale };
}
+58
View File
@@ -0,0 +1,58 @@
/*
* Web Worker for image optimization (P1). Offloads the expensive bitmap decode,
* resize, and JPEG/PNG compression to a background thread so the UI remains
* responsive while processing large photos.
*
* Uses OffscreenCanvas which is available in Chrome 69+, Firefox 105+, and
* Safari 16.4+. The main module (images.js) detects support at runtime and
* falls back to the main thread for older browsers.
*/
function fitIntoBox(originalWidth, originalHeight, maxWidth, maxHeight) {
const ratio = Math.min(maxWidth / originalWidth, maxHeight / originalHeight, 1);
return {
width: Math.round(originalWidth * ratio),
height: Math.round(originalHeight * ratio)
};
}
self.onmessage = async (event) => {
const { id, file, imageRules } = event.data;
try {
const imageBitmap = await createImageBitmap(file);
const maxWidth = imageRules?.maxWidthPx || imageBitmap.width;
const maxHeight = imageRules?.maxHeightPx || imageBitmap.height;
const { width, height } = fitIntoBox(imageBitmap.width, imageBitmap.height, maxWidth, maxHeight);
const canvas = new OffscreenCanvas(width, height);
const context = canvas.getContext('2d');
context.drawImage(imageBitmap, 0, 0, width, height);
imageBitmap.close();
const targetMimeType = file.type === 'image/png' ? 'image/png' : 'image/jpeg';
const quality = Math.min(Math.max((imageRules?.jpegQuality || 82) / 100, 0.2), 0.95);
const blob = await canvas.convertToBlob({ type: targetMimeType, quality });
if (!blob) {
throw new Error(`Failed to optimize image: ${file.name}`);
}
if (imageRules?.maxFileSizeBytes && blob.size > imageRules.maxFileSizeBytes) {
throw new Error(`Optimized image still exceeds limit: ${file.name}`);
}
self.postMessage({
id,
result: {
blob,
width,
height,
extension: targetMimeType === 'image/png' ? 'png' : 'jpg'
}
});
} catch (error) {
self.postMessage({ id, error: error.message });
}
};
+134
View File
@@ -0,0 +1,134 @@
/*
* Image optimization module (P1). When OffscreenCanvas is available the heavy
* pixel work runs in a dedicated Web Worker so the UI thread stays responsive
* during large-image processing. On browsers that lack OffscreenCanvas support
* (or when running inside a Worker is not possible) the module falls back to
* main-thread canvas operations.
*/
let worker = null;
let workerSupported = null;
function isWorkerSupported() {
if (workerSupported !== null) {
return workerSupported;
}
try {
/* OffscreenCanvas is required inside the Worker to draw without a DOM. */
workerSupported = typeof OffscreenCanvas !== 'undefined' && typeof Worker !== 'undefined';
} catch {
workerSupported = false;
}
return workerSupported;
}
function getWorker() {
if (!worker && isWorkerSupported()) {
worker = new Worker('/js/image-worker.js');
}
return worker;
}
/*
* Public entry point. Validates the file against the image rules, then delegates
* the actual resize/compress work to the worker or the main-thread fallback.
*/
export async function optimizeImage(file, imageRules) {
if (imageRules?.allowedMimeTypes?.length && !imageRules.allowedMimeTypes.includes(file.type)) {
throw new Error(`Unsupported file type: ${file.type}`);
}
if (imageRules?.oversizeBehavior === 'block' && file.size > imageRules.maxFileSizeBytes) {
throw new Error(`File exceeds limit: ${file.name}`);
}
const w = getWorker();
if (w) {
return optimizeInWorker(w, file, imageRules);
}
return optimizeOnMainThread(file, imageRules);
}
/* ── Worker path ────────────────────────────────────────────────────────── */
function optimizeInWorker(w, file, imageRules) {
return new Promise((resolve, reject) => {
const messageId = crypto.randomUUID();
function handler(event) {
if (event.data?.id !== messageId) {
return;
}
w.removeEventListener('message', handler);
w.removeEventListener('error', errorHandler);
if (event.data.error) {
reject(new Error(event.data.error));
} else {
resolve(event.data.result);
}
}
function errorHandler(event) {
w.removeEventListener('message', handler);
w.removeEventListener('error', errorHandler);
reject(new Error(event.message || 'Worker error'));
}
w.addEventListener('message', handler);
w.addEventListener('error', errorHandler);
w.postMessage({ id: messageId, file, imageRules });
});
}
/* ── Main-thread fallback ───────────────────────────────────────────────── */
async function optimizeOnMainThread(file, imageRules) {
const imageBitmap = await createImageBitmap(file);
const maxWidth = imageRules?.maxWidthPx || imageBitmap.width;
const maxHeight = imageRules?.maxHeightPx || imageBitmap.height;
const { width, height } = fitIntoBox(imageBitmap.width, imageBitmap.height, maxWidth, maxHeight);
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d');
context.drawImage(imageBitmap, 0, 0, width, height);
const targetMimeType = file.type === 'image/png' ? 'image/png' : 'image/jpeg';
const quality = Math.min(Math.max((imageRules?.jpegQuality || 82) / 100, 0.2), 0.95);
const blob = await new Promise((resolve) => {
canvas.toBlob(resolve, targetMimeType, quality);
});
if (!blob) {
throw new Error(`Failed to optimize image: ${file.name}`);
}
if (imageRules?.maxFileSizeBytes && blob.size > imageRules.maxFileSizeBytes) {
throw new Error(`Optimized image still exceeds limit: ${file.name}`);
}
return {
blob,
width,
height,
extension: targetMimeType === 'image/png' ? 'png' : 'jpg'
};
}
export function fitIntoBox(originalWidth, originalHeight, maxWidth, maxHeight) {
const ratio = Math.min(maxWidth / originalWidth, maxHeight / originalHeight, 1);
return {
width: Math.round(originalWidth * ratio),
height: Math.round(originalHeight * ratio)
};
}
+389
View File
@@ -0,0 +1,389 @@
/*
* Rendering module. All functions that manipulate the visible DOM live here.
* Each render function reads from the shared state and writes to the cached
* element references. The module has no side effects beyond DOM mutation.
*/
import { state, elements, getCurrentReport, getTemplateRecord } from './state.js';
import { createFieldNode } from './forms.js';
import { validateReport } from './validation.js';
import {
formatDateTime,
formatTime,
formatRelativeTime,
formatFileSize,
prettifyStatus,
badgeClassForTone
} from './utils.js';
import { t } from './i18n.js';
/* ── Top-level render orchestrator ──────────────────────────────────────── */
export function render(fieldCallbacks) {
renderTemplateSelector();
/* User workspace renders — only when the report editor DOM exists. */
if (elements.reportForm) {
renderReportList();
renderTemplateSummary();
renderCurrentReport(fieldCallbacks);
renderSyncSummary();
renderImagePolicy();
}
/* Admin workspace renders — only when the admin form DOM exists. */
if (elements.adminImageRulesForm) {
renderAdminImageRules();
}
}
/* ── Template selector ──────────────────────────────────────────────────── */
export function renderTemplateSelector() {
const currentValue = state.selectedTemplateCode;
elements.templateSelect.innerHTML = '';
if (!state.templatesCatalog.length) {
const option = document.createElement('option');
option.value = '';
option.textContent = t('noTemplatesCached');
elements.templateSelect.append(option);
return;
}
for (const template of state.templatesCatalog) {
const option = document.createElement('option');
option.value = template.code;
option.textContent = `${template.name} (v${template.activeVersion})`;
option.selected = template.code === currentValue;
elements.templateSelect.append(option);
}
}
/* ── Report list (F4 — with search & status filter) ─────────────────────── */
export function renderReportList() {
if (!elements.reportList) {
return;
}
elements.reportList.innerHTML = '';
/* Apply search query and status filter. */
let filtered = state.reports;
if (state.reportSearchQuery) {
const q = state.reportSearchQuery.toLowerCase();
filtered = filtered.filter((r) => {
const num = (r.answers?.reportNumber || r.reportNumber || '').toLowerCase();
const title = (r.title || '').toLowerCase();
return num.includes(q) || title.includes(q);
});
}
if (state.reportFilterStatus) {
filtered = filtered.filter((r) => r.status === state.reportFilterStatus);
}
elements.reportCount.textContent = String(filtered.length);
if (!filtered.length) {
const empty = document.createElement('p');
empty.className = 'field-help';
empty.textContent = state.reports.length ? 'No reports match the current filter.' : t('noLocalReports');
elements.reportList.append(empty);
return;
}
for (const report of filtered) {
const fragment = elements.reportListItemTemplate.content.cloneNode(true);
const button = fragment.querySelector('.report-list-item');
const title = fragment.querySelector('.report-list-item__title');
const statusEl = fragment.querySelector('.report-list-item__status');
const meta = fragment.querySelector('.report-list-item__meta');
title.textContent = report.answers.reportNumber || report.reportNumber;
statusEl.textContent = prettifyStatus(report.status);
statusEl.classList.add(`status-${report.status}`);
meta.textContent = `${report.title} • Updated ${formatDateTime(report.updatedAt)}`;
if (report.id === state.currentReportId) {
button.classList.add('is-active');
}
button.dataset.reportId = report.id;
elements.reportList.append(button);
}
}
/* ── Template summary cards ─────────────────────────────────────────────── */
export function renderTemplateSummary() {
if (!elements.summaryTemplate) {
return;
}
const report = getCurrentReport();
const templateCode = report?.templateCode || state.selectedTemplateCode;
const catalogEntry = state.templatesCatalog.find((item) => item.code === templateCode);
if (!catalogEntry) {
elements.summaryTemplate.textContent = 'Not loaded';
elements.summaryVersion.textContent = 'Version -';
return;
}
elements.summaryTemplate.textContent = catalogEntry.name;
elements.summaryVersion.textContent = report
? `Version ${report.templateVersion}`
: `Version ${catalogEntry.activeVersion}`;
}
export function renderSyncSummary() {
if (!elements.syncHeadline) {
return;
}
elements.syncHeadline.textContent = state.lastSyncAt
? `Last sync ${formatRelativeTime(state.lastSyncAt)}`
: 'No sync yet';
elements.syncDetail.textContent = state.lastSyncAt
? `Cached template data from ${formatDateTime(state.lastSyncAt)}`
: 'Templates are cached locally after the first successful sync.';
}
/* ── Image policy ───────────────────────────────────────────────────────── */
export function renderImagePolicy() {
if (!elements.imagePolicyText) {
return;
}
if (!state.imageRules) {
elements.imagePolicyText.textContent = t('imagePolicyHint');
return;
}
elements.imagePolicyText.textContent = `${state.imageRules.name}: ${state.imageRules.allowedMimeTypes.join(
', '
)}, max ${formatFileSize(state.imageRules.maxFileSizeBytes)}, ${state.imageRules.maxWidthPx}x${state.imageRules.maxHeightPx}px, behavior ${state.imageRules.oversizeBehavior}.`;
}
/* ── Admin image rules ──────────────────────────────────────────────────── */
export function renderAdminImageRules() {
if (!elements.adminSyncState) {
return;
}
populateAdminImageRulesForm(state.imageRules);
if (!state.imageRules) {
elements.adminSyncState.textContent = t('noImageRulesLoaded');
elements.adminSyncState.className = 'badge badge-offline';
elements.adminPolicyCode.textContent = '-';
elements.adminPolicyMimeTypes.textContent = '-';
elements.adminPolicyOptimization.textContent = '-';
elements.adminPolicyLimits.textContent = '-';
return;
}
elements.adminSyncState.textContent = navigator.onLine ? t('liveConfig') : t('offlineCachedConfig');
elements.adminSyncState.className = `badge ${navigator.onLine ? 'badge-online' : 'badge-offline'}`;
elements.adminPolicyCode.textContent = state.imageRules.code;
elements.adminPolicyMimeTypes.textContent = state.imageRules.allowedMimeTypes.join(', ');
elements.adminPolicyOptimization.textContent = `${state.imageRules.oversizeBehavior}, JPEG ${state.imageRules.jpegQuality}%`;
elements.adminPolicyLimits.textContent = `${formatFileSize(state.imageRules.maxFileSizeBytes)}, ${state.imageRules.maxWidthPx}x${state.imageRules.maxHeightPx}px, ${state.imageRules.maxAttachmentsPerField} attachment(s)`;
}
export function populateAdminImageRulesForm(imageRules) {
if (!imageRules || !elements.adminPolicyName) {
return;
}
elements.adminPolicyName.value = imageRules.name || '';
elements.adminAllowedMimeTypes.value = (imageRules.allowedMimeTypes || []).join(', ');
elements.adminMaxFileSizeMb.value = String((Number(imageRules.maxFileSizeBytes || 0) / (1024 * 1024)).toFixed(1));
elements.adminMaxAttachmentsPerField.value = String(imageRules.maxAttachmentsPerField || 1);
elements.adminMaxWidthPx.value = String(imageRules.maxWidthPx || '');
elements.adminMaxHeightPx.value = String(imageRules.maxHeightPx || '');
elements.adminJpegQuality.value = String(imageRules.jpegQuality || '');
elements.adminOversizeBehavior.value = imageRules.oversizeBehavior || 'auto_optimize';
}
/* ── Current report ─────────────────────────────────────────────────────── */
/*
* `renderCurrentReport` accepts a `fieldCallbacks` object so the form module
* can invoke actions (field change, attach, remove) owned by the orchestrator
* without creating circular imports.
*/
export function renderCurrentReport(fieldCallbacks = {}) {
if (!elements.reportForm) {
return;
}
const report = getCurrentReport();
if (!report) {
elements.heroTitle.textContent = t('noReportSelected');
elements.heroSubtitle.textContent = t('noReportSelectedHint');
elements.reportStatusSelect.value = 'draft';
elements.reportStatusSelect.disabled = true;
elements.deleteReportButton.disabled = true;
if (elements.submitReportButton) elements.submitReportButton.disabled = true;
if (elements.exportReportButton) elements.exportReportButton.disabled = true;
elements.editorHint.textContent = 'Dynamic form rendering from template JSON';
elements.reportForm.innerHTML = `
<div class="empty-state">
<h3>No report open</h3>
<p>Choose a template and create a report to start editing locally.</p>
</div>
`;
renderMeta(null);
renderValidation();
return;
}
const template = getTemplateRecord(report.templateCode, report.templateVersion);
elements.reportStatusSelect.disabled = false;
elements.reportStatusSelect.value = report.status;
elements.deleteReportButton.disabled = false;
if (elements.submitReportButton) elements.submitReportButton.disabled = false;
if (elements.exportReportButton) elements.exportReportButton.disabled = false;
elements.heroTitle.textContent = report.answers.reportNumber || report.reportNumber;
elements.heroSubtitle.textContent = `${template?.name || report.templateCode} • Local draft bound to template version ${report.templateVersion}`;
elements.editorHint.textContent = `${state.currentAttachments.length} attachment(s) stored in IndexedDB for this report`;
if (!template) {
elements.reportForm.innerHTML = `
<div class="empty-state">
<h3>Template missing</h3>
<p>This draft is bound to template version ${report.templateVersion}, but that definition is not cached locally.</p>
</div>
`;
renderMeta(report);
renderValidation();
return;
}
elements.reportForm.innerHTML = '';
for (const section of template.definition.sections || []) {
const sectionNode = document.createElement('section');
sectionNode.className = 'template-section';
const heading = document.createElement('div');
heading.className = 'section-heading-row';
heading.innerHTML = `<h3>${escapeHtml(section.title)}</h3><span class="panel-note">${section.fields.length} field(s)</span>`;
sectionNode.append(heading);
const fieldGrid = document.createElement('div');
fieldGrid.className = 'field-grid';
for (const field of section.fields || []) {
fieldGrid.append(createFieldNode(field, report, {
state,
onFieldChange: fieldCallbacks.onFieldChange || (() => {}),
onAttachFiles: fieldCallbacks.onAttachFiles || (() => {}),
onRemoveAttachment: fieldCallbacks.onRemoveAttachment || (() => {})
}));
}
sectionNode.append(fieldGrid);
elements.reportForm.append(sectionNode);
}
renderMeta(report);
renderValidation();
}
/* ── Meta & validation ──────────────────────────────────────────────────── */
export function renderMeta(report) {
if (!elements.reportMeta) {
return;
}
const values = report
? [
report.id,
`${report.templateCode} v${report.templateVersion}`,
formatDateTime(report.createdAt),
formatDateTime(report.updatedAt)
]
: ['-', '-', '-', '-'];
elements.reportMeta.querySelectorAll('dd').forEach((node, index) => {
node.textContent = values[index];
});
}
export function renderValidation() {
if (!elements.validationHeadline) {
return;
}
const report = getCurrentReport();
if (!report) {
elements.validationHeadline.textContent = t('noReportSelected');
elements.validationDetail.textContent = 'Draft validation will appear here.';
elements.validationList.innerHTML = '<li>No report selected.</li>';
return;
}
const template = getTemplateRecord(report.templateCode, report.templateVersion);
if (!template) {
elements.validationHeadline.textContent = t('templateUnavailable');
elements.validationDetail.textContent = t('validationNeedsTemplate');
elements.validationList.innerHTML = `<li>${t('templateMissing')}</li>`;
return;
}
const issues = validateReport(report, template, state.currentAttachments);
if (!issues.length) {
elements.validationHeadline.textContent = t('readyForExport');
elements.validationDetail.textContent = t('noBlockingIssues');
elements.validationList.innerHTML = `<li>${t('noValidationIssues')}</li>`;
return;
}
elements.validationHeadline.textContent = t('issueCount', issues.length);
elements.validationDetail.textContent =
report.status === 'ready_for_export' ? t('issueReadyWarning') : t('issueDraftHint');
elements.validationList.innerHTML = '';
for (const issue of issues) {
const item = document.createElement('li');
item.textContent = issue;
elements.validationList.append(item);
}
}
/* ── Badge helpers ──────────────────────────────────────────────────────── */
export function updateConnectionBadge() {
if (navigator.onLine) {
elements.connectionBadge.textContent = t('online');
elements.connectionBadge.className = 'badge badge-online';
} else {
elements.connectionBadge.textContent = t('offline');
elements.connectionBadge.className = 'badge badge-offline';
}
}
export function updateSaveBadge(text, tone) {
elements.saveBadge.textContent = text;
elements.saveBadge.className = `badge ${badgeClassForTone(tone)}`;
}
/* ── Helpers ────────────────────────────────────────────────────────────── */
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
+58
View File
@@ -0,0 +1,58 @@
/*
* Centralized application state. Every module imports the same `state` object so
* all shared data lives in one place. The `elements` object caches DOM references
* established during initialization.
*/
export const state = {
db: null,
reports: [],
templatesCatalog: [],
templateDefinitions: new Map(),
lookups: new Map(),
imageRules: null,
appConfig: new Map(),
currentReportId: null,
currentAttachments: [],
selectedTemplateCode: null,
saveState: 'idle',
saveTimer: null,
autosaveIntervalId: null,
lastSyncAt: null,
/* Dirty flag: true when the current report has unsaved field changes (P6). */
dirty: false,
/* Search/filter state for the report list (F4). */
reportSearchQuery: '',
reportFilterStatus: ''
};
/* Cached DOM element references, populated once during init. */
export const elements = {};
/* ── State accessors ────────────────────────────────────────────────────── */
export function getCurrentReport() {
return state.reports.find((item) => item.id === state.currentReportId) || null;
}
export function getTemplateRecord(code, version) {
if (!code || version == null) {
return null;
}
const { makeTemplateKey } = stateHelpers;
return state.templateDefinitions.get(makeTemplateKey(code, version)) || null;
}
/*
* Externalizing makeTemplateKey as a helper avoids a circular import — utils.js
* cannot import state.js. Instead state.js imports nothing from utils; callers
* that need both can reference stateHelpers.makeTemplateKey.
*/
const stateHelpers = {
makeTemplateKey(code, version) {
return `${code}::${version}`;
}
};
export { stateHelpers };
+150
View File
@@ -0,0 +1,150 @@
/*
* Utility functions used across multiple frontend modules. These are pure
* functions with no side effects and no dependency on application state.
*/
/* ── Formatting ─────────────────────────────────────────────────────────── */
export function prettifyStatus(status) {
return status
.split('_')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
export function formatDateTime(value) {
return new Date(value).toLocaleString();
}
export function formatTime(value) {
return new Date(value).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
export function formatRelativeTime(value) {
const diffMs = Date.now() - new Date(value).getTime();
const diffMinutes = Math.round(diffMs / 60000);
if (diffMinutes < 1) {
return 'just now';
}
if (diffMinutes < 60) {
return `${diffMinutes} minute(s) ago`;
}
const diffHours = Math.round(diffMinutes / 60);
if (diffHours < 24) {
return `${diffHours} hour(s) ago`;
}
return `${Math.round(diffHours / 24)} day(s) ago`;
}
export function formatFileSize(bytes) {
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
/* ── Naming & Sanitization ──────────────────────────────────────────────── */
export function sanitizeForFilename(value) {
return String(value || 'report')
.trim()
.replace(/\s+/g, '-')
.replace(/[^a-zA-Z0-9-_]/g, '')
.slice(0, 40);
}
export function generateReportNumber() {
const now = new Date();
const stamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(
now.getDate()
).padStart(2, '0')}-${String(now.getHours()).padStart(2, '0')}${String(
now.getMinutes()
).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`;
return `POC-${stamp}`;
}
export function buildGeneratedFilename(report, field, sequence, extension) {
const reportNumber = sanitizeForFilename(report.answers.reportNumber || report.reportNumber);
const sectionCode = sanitizeForFilename(field.id).slice(0, 10).toUpperCase();
return `${reportNumber}_${sectionCode}_${String(sequence).padStart(3, '0')}.${extension}`;
}
/* ── Badge helpers ──────────────────────────────────────────────────────── */
export function badgeClassForTone(tone) {
if (tone === 'success') {
return 'badge-online';
}
if (tone === 'error') {
return 'badge-error';
}
if (tone === 'warning') {
return 'badge-offline';
}
return 'badge-neutral';
}
/* ── Template helpers ───────────────────────────────────────────────────── */
export function makeTemplateKey(code, version) {
return `${code}::${version}`;
}
/*
* Multiple versions of the same template can exist in local cache because old
* drafts remain bound to the version they started with. The catalog therefore
* picks the newest version per template code for the creation UI while keeping
* older records available in the version lookup map.
*/
export function deriveTemplateCatalog(templateRows) {
const byCode = new Map();
for (const row of templateRows) {
const existing = byCode.get(row.code);
const shouldReplace = !existing || Number(row.version) > Number(existing.version);
if (shouldReplace) {
byCode.set(row.code, row);
}
}
return Array.from(byCode.values())
.sort((left, right) => left.name.localeCompare(right.name))
.map((item) => ({
code: item.code,
name: item.name,
description: item.description,
activeVersion: item.version,
publishedAt: item.publishedAt
}));
}
/* ── General ────────────────────────────────────────────────────────────── */
/*
* Debounce helper. Returns a wrapper that delays invocation until `ms`
* milliseconds of inactivity have passed, preventing expensive operations
* (such as a full form re-render) from running on every keystroke.
*/
export function debounce(fn, ms) {
let timer = null;
return function debounced(...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), ms);
};
}
+100
View File
@@ -0,0 +1,100 @@
/*
* Report and image-rules validation. These are pure functions with no DOM or
* state dependencies so they can be tested independently and kept in sync with
* server-side validation in src/routes/configRoutes.js (A7).
*/
/* ── Report validation ──────────────────────────────────────────────────── */
/*
* Returns an array of human-readable issue strings. The caller decides whether
* issues are informational or blocking.
*/
export function validateReport(report, template, attachments) {
const issues = [];
for (const section of template.definition.sections || []) {
for (const field of section.fields || []) {
const value = report.answers[field.id];
const required = Boolean(field.required || evaluateRequiredWhen(field.requiredWhen, report.answers));
if (field.type === 'attachment') {
const fieldAttachments = attachments.filter((item) => item.fieldId === field.id);
if (required && fieldAttachments.length === 0) {
issues.push(`${field.label}: at least one image is required.`);
}
continue;
}
if (required && isBlankValue(value, field.type)) {
issues.push(`${field.label}: value is required.`);
}
if (field.type === 'number' && value !== '' && value != null) {
if (Number.isNaN(Number(value))) {
issues.push(`${field.label}: number is invalid.`);
}
if (field.validation?.min !== undefined && Number(value) < field.validation.min) {
issues.push(`${field.label}: must be at least ${field.validation.min}.`);
}
}
}
}
return issues;
}
/* ── Image-rules validation (mirrors server-side logic in configRoutes) ── */
export function validateImageRulesPayload(payload) {
if (!payload.name) {
return 'Policy name is required';
}
if (!payload.allowedMimeTypes.length) {
return 'Add at least one MIME type';
}
if (!Number.isFinite(payload.maxFileSizeBytes) || payload.maxFileSizeBytes <= 0) {
return 'Max file size must be greater than 0';
}
if (!Number.isInteger(payload.maxWidthPx) || payload.maxWidthPx <= 0) {
return 'Max width must be a positive number';
}
if (!Number.isInteger(payload.maxHeightPx) || payload.maxHeightPx <= 0) {
return 'Max height must be a positive number';
}
if (!Number.isInteger(payload.jpegQuality) || payload.jpegQuality < 1 || payload.jpegQuality > 100) {
return 'JPEG quality must be between 1 and 100';
}
if (!Number.isInteger(payload.maxAttachmentsPerField) || payload.maxAttachmentsPerField <= 0) {
return 'Max attachments must be a positive number';
}
return null;
}
/* ── Helpers ────────────────────────────────────────────────────────────── */
export function evaluateRequiredWhen(requiredWhen, answers) {
if (!requiredWhen?.field) {
return false;
}
return answers[requiredWhen.field] === requiredWhen.equals;
}
export function isBlankValue(value, type) {
if (type === 'checkbox') {
return false;
}
return value === '' || value == null;
}