From c74eb3595909b269c887a6702df82ee7026788c9 Mon Sep 17 00:00:00 2001 From: QvarcY Date: Sat, 26 Sep 2026 18:19:10 +0300 Subject: [PATCH 1/9] feat(ui): add frontend prototype --- README_FRONTEND.md | 14 + index.html | 38 +- package.json | 7 +- src/app/app-controller.js | 143 ++ src/app/app-events.js | 34 + src/app/app-store.js | 85 + src/main.js | 77 +- src/models/contracts.js | 148 ++ src/services/browser-comparison-service.js | 24 + src/services/comparison-service.js | 58 + src/services/mock-comparison-service.js | 202 +++ src/styles/base.css | 184 +++ src/styles/components.css | 1682 ++++++++++++++++++++ src/styles/tokens.css | 164 ++ src/styles/utilities.css | 53 + src/ui/components/file-panel.js | 286 ++++ src/ui/components/mapping-editor.js | 259 +++ src/ui/components/mapping-row.js | 137 ++ src/ui/components/privacy-note.js | 13 + src/ui/components/record-detail-drawer.js | 148 ++ src/ui/components/results-table.js | 130 ++ src/ui/components/skip-link.js | 9 + src/ui/components/summary-card.js | 31 + src/ui/components/toast-host.js | 49 + src/ui/renderers/dom.js | 106 ++ src/ui/renderers/format.js | 46 + src/ui/views/comparing-view.js | 71 + src/ui/views/landing-view.js | 40 + src/ui/views/mapping-view.js | 25 + src/ui/views/results-view.js | 106 ++ vite.config.js | 18 +- 31 files changed, 4356 insertions(+), 31 deletions(-) create mode 100644 src/ui/components/file-panel.js create mode 100644 src/ui/components/mapping-editor.js create mode 100644 src/ui/components/mapping-row.js create mode 100644 src/ui/components/privacy-note.js create mode 100644 src/ui/components/record-detail-drawer.js create mode 100644 src/ui/components/results-table.js create mode 100644 src/ui/components/skip-link.js create mode 100644 src/ui/components/summary-card.js create mode 100644 src/ui/components/toast-host.js create mode 100644 src/ui/renderers/dom.js create mode 100644 src/ui/renderers/format.js create mode 100644 src/ui/views/comparing-view.js create mode 100644 src/ui/views/landing-view.js create mode 100644 src/ui/views/mapping-view.js create mode 100644 src/ui/views/results-view.js diff --git a/README_FRONTEND.md b/README_FRONTEND.md index e69de29..f8893ce 100644 --- a/README_FRONTEND.md +++ b/README_FRONTEND.md @@ -0,0 +1,14 @@ +# Two-Export Comparator — Frontend + +A privacy-first browser utility that compares two CSV/TSV exports locally +and identifies missing, duplicate, ambiguous and mismatched records. + +**Two files in. Differences out. No account. No upload. No cloud database.** + +## Commands + +```bash +npm install # install dev dependencies +npm run dev # start Vite dev server +npm run build # produce static ./dist +npm run preview # preview the production build \ No newline at end of file diff --git a/index.html b/index.html index c715ea4..36c86ae 100644 --- a/index.html +++ b/index.html @@ -1,17 +1,21 @@ - - - - - - - Two Export Comparator - - -
- - - - + + + + + + + + Two-Export Comparator + + + +
+ + + \ No newline at end of file diff --git a/package.json b/package.json index 90beda8..cfff6dc 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,15 @@ { "name": "two-export-comparator", - "version": "0.0.1", + "version": "0.1.0", "private": true, "type": "module", + "description": "Compare two CSV/TSV exports locally. Privacy-first browser utility.", "scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview" }, "devDependencies": { - "vite": "^7.0.0" + "vite": "^5.4.0" } -} +} \ No newline at end of file diff --git a/src/app/app-controller.js b/src/app/app-controller.js index e69de29..e059d24 100644 --- a/src/app/app-controller.js +++ b/src/app/app-controller.js @@ -0,0 +1,143 @@ +import { AppEvents } from './app-events.js'; + +/** + * AppController — coordinates user actions and service calls. + * Contains NO CSV parsing and NO matching algorithms. + */ +export class AppController { + /** + * @param {import('./app-store.js').AppStore} store + * @param {import('../services/comparison-service.js').ComparisonService} service + */ + constructor(store, service) { + this.store = store; + this.service = service; + this._wireEvents(); + } + + _wireEvents() { + window.addEventListener(AppEvents.FILE_A_SELECTED, (e) => this.handleFileSelected('A', e.detail.file)); + window.addEventListener(AppEvents.FILE_B_SELECTED, (e) => this.handleFileSelected('B', e.detail.file)); + window.addEventListener(AppEvents.FILE_A_REMOVED, () => this.handleFileRemoved('A')); + window.addEventListener(AppEvents.FILE_B_REMOVED, () => this.handleFileRemoved('B')); + window.addEventListener(AppEvents.COMPARE_REQUESTED, (e) => this.handleCompare(e.detail.mapping)); + window.addEventListener(AppEvents.EXPORT_REQUESTED, (e) => this.handleExport(e.detail)); + window.addEventListener(AppEvents.RESET_REQUESTED, () => this.handleReset()); + } + + /** + * @param {'A' | 'B'} slot + * @param {File} file + */ + async handleFileSelected(slot, file) { + try { + const inspection = await this.service.inspectFile(file); + const patch = slot === 'A' ? { fileA: inspection } : { fileB: inspection }; + this.store.patch(patch); + this._recomputeState(); + } catch (err) { + this.store.pushToast({ + severity: 'error', + title: "We couldn't read that file", + message: 'Try a different CSV or TSV export.', + }); + // eslint-disable-next-line no-console + console.error('[AppController] inspectFile failed:', err); + } + } + + /** + * @param {'A' | 'B'} slot + */ + handleFileRemoved(slot) { + const patch = slot === 'A' ? { fileA: null } : { fileB: null }; + this.store.patch(patch); + this._recomputeState(); + } + + /** + * @param {import('../models/contracts.js').MappingConfig} mapping + */ + async handleCompare(mapping) { + const { fileA, fileB } = this.store; + if (!fileA || !fileB) return; + + this.store.patch({ mapping, progress: null, result: null }); + this.store.setState('COMPARING'); + + try { + const result = await this.service.compare( + { fileAId: fileA.id, fileBId: fileB.id, mapping }, + { + onProgress: (progress) => { + this.store.patch({ progress }); + window.dispatchEvent(new CustomEvent(AppEvents.COMPARE_PROGRESS, { detail: progress })); + }, + } + ); + this.store.patch({ result, progress: null }); + this.store.setState('RESULTS'); + window.dispatchEvent(new CustomEvent(AppEvents.COMPARE_SUCCEEDED, { detail: result })); + } catch (err) { + this.store.patch({ progress: null }); + this.store.setState('ERROR'); + this.store.pushToast({ + severity: 'error', + title: 'Comparison failed', + message: 'Try adjusting the mapping or the files.', + }); + window.dispatchEvent(new CustomEvent(AppEvents.COMPARE_FAILED, { detail: err })); + // eslint-disable-next-line no-console + console.error('[AppController] compare failed:', err); + } + } + + /** + * @param {import('../models/contracts.js').ExportOptions} options + */ + async handleExport(options = {}) { + if (!this.store.result) return; + try { + const blob = await this.service.createExport(this.store.result, options); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `comparison-${this.store.result.runId ?? 'export'}.csv`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + this.store.pushToast({ severity: 'info', title: 'Export ready', message: 'Your download has started.' }); + } catch (err) { + this.store.pushToast({ severity: 'error', title: 'Export failed' }); + // eslint-disable-next-line no-console + console.error('[AppController] export failed:', err); + } + } + + handleReset() { + this.service.reset(); + this.store.patch({ + fileA: null, + fileB: null, + mapping: null, + mappingValid: false, + progress: null, + result: null, + resultFilter: 'ALL', + openRecordId: null, + }); + this.store.setState('EMPTY'); + } + + _recomputeState() { + const { fileA, fileB } = this.store; + if (!fileA && !fileB) { + this.store.setState('EMPTY'); + } else if (fileA && fileB) { + this.store.setState('MAPPING'); + } else { + this.store.setState('FILES_PARTIAL'); + } + } +} \ No newline at end of file diff --git a/src/app/app-events.js b/src/app/app-events.js index e69de29..568f149 100644 --- a/src/app/app-events.js +++ b/src/app/app-events.js @@ -0,0 +1,34 @@ +/** + * @file Application event names. String constants — no magic strings in code. + */ + +export const AppEvents = Object.freeze({ + // File lifecycle + FILE_A_SELECTED: 'FILE_A_SELECTED', + FILE_B_SELECTED: 'FILE_B_SELECTED', + FILE_A_REMOVED: 'FILE_A_REMOVED', + FILE_B_REMOVED: 'FILE_B_REMOVED', + + // Mapping + MAPPING_UPDATED: 'MAPPING_UPDATED', + NORMALIZATION_UPDATED: 'NORMALIZATION_UPDATED', + + // Comparison + COMPARE_REQUESTED: 'COMPARE_REQUESTED', + COMPARE_PROGRESS: 'COMPARE_PROGRESS', + COMPARE_SUCCEEDED: 'COMPARE_SUCCEEDED', + COMPARE_FAILED: 'COMPARE_FAILED', + + // Results + RESULT_FILTER_CHANGED: 'RESULT_FILTER_CHANGED', + RESULT_RECORD_OPENED: 'RESULT_RECORD_OPENED', + RESULT_RECORD_CLOSED: 'RESULT_RECORD_CLOSED', + + // Export / reset + EXPORT_REQUESTED: 'EXPORT_REQUESTED', + RESET_REQUESTED: 'RESET_REQUESTED', + + // UI + TOAST_SHOWN: 'TOAST_SHOWN', + TOAST_DISMISSED: 'TOAST_DISMISSED', +}); \ No newline at end of file diff --git a/src/app/app-store.js b/src/app/app-store.js index e69de29..058b34b 100644 --- a/src/app/app-store.js +++ b/src/app/app-store.js @@ -0,0 +1,85 @@ +import { AppEvents } from './app-events.js'; + +/** + * @typedef {'EMPTY' | 'FILES_PARTIAL' | 'FILES_READY' | 'MAPPING' + * | 'READY_TO_COMPARE' | 'COMPARING' | 'RESULTS' | 'ERROR'} AppStateName + */ + +/** + * AppStore — single source of truth for UI state. + * Never persists imported business data to browser storage. + */ +export class AppStore extends EventTarget { + constructor() { + super(); + + /** @type {AppStateName} */ + this.state = 'EMPTY'; + + /** @type {import('../models/contracts.js').FileInspection | null} */ + this.fileA = null; + + /** @type {import('../models/contracts.js').FileInspection | null} */ + this.fileB = null; + + /** @type {import('../models/contracts.js').MappingConfig | null} */ + this.mapping = null; + + /** @type {boolean} */ + this.mappingValid = false; + + /** @type {import('../models/contracts.js').ProgressEvent | null} */ + this.progress = null; + + /** @type {import('../models/contracts.js').ComparisonResult | null} */ + this.result = null; + + /** @type {import('../models/contracts.js').ResultStatus | 'ALL'} */ + this.resultFilter = 'ALL'; + + /** @type {string | null} */ + this.openRecordId = null; + + /** @type {Array<{ id: string, severity: string, title: string, message?: string }>} */ + this.toasts = []; + } + + /** + * Transition to a new state and notify subscribers. + * @param {AppStateName} next + */ + setState(next) { + if (this.state === next) return; + const prev = this.state; + this.state = next; + this.dispatchEvent(new CustomEvent('state', { detail: { prev, next } })); + } + + /** + * Merge a partial patch into the store and emit a change event. + * @param {object} patch + */ + patch(patch) { + Object.assign(this, patch); + this.dispatchEvent(new CustomEvent('change', { detail: patch })); + } + + /** + * Show a transient toast. + * @param {{ severity: 'info'|'warning'|'error', title: string, message?: string }} toast + */ + pushToast(toast) { + const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; + this.toasts = [...this.toasts, { id, ...toast }]; + this.dispatchEvent(new CustomEvent(AppEvents.TOAST_SHOWN, { detail: { id, ...toast } })); + return id; + } + + /** + * @param {string} id + */ + dismissToast(id) { + this.toasts = this.toasts.filter((t) => t.id !== id); + this.dispatchEvent(new CustomEvent(AppEvents.TOAST_DISMISSED, { detail: { id } })); + } +} \ No newline at end of file diff --git a/src/main.js b/src/main.js index 80cea3f..3ae6b22 100644 --- a/src/main.js +++ b/src/main.js @@ -1,8 +1,73 @@ -import "./styles/tokens.css"; -import "./styles/base.css"; -import "./styles/components.css"; -import "./styles/utilities.css"; +import './styles/tokens.css'; +import './styles/base.css'; +import './styles/utilities.css'; +import './styles/components.css'; -const app = document.querySelector("#app"); +import { AppStore } from './app/app-store.js'; +import { AppController } from './app/app-controller.js'; +import { MockComparisonService } from './services/mock-comparison-service.js'; +import { LandingView } from './ui/views/landing-view.js'; +import { MappingView } from './ui/views/mapping-view.js'; +import { ComparingView } from './ui/views/comparing-view.js'; +import { ResultsView } from './ui/views/results-view.js'; +import { ToastHost } from './ui/components/toast-host.js'; +import { SkipLink } from './ui/components/skip-link.js'; +import { el, replaceChildren } from './ui/renderers/dom.js'; -app.textContent = "Two Export Comparator — frontend scaffold ready."; +/** + * Bootstrap the application. + * @param {{ comparisonService?: import('./services/comparison-service.js').ComparisonService }} [config] + */ +export function startApp(config = {}) { + const service = config.comparisonService ?? new MockComparisonService(); + const store = new AppStore(); + const controller = new AppController(store, service); + + const root = document.getElementById('app'); + + const header = el('header', { class: 'app-header' }, [ + el('div', { class: 'app-header__brand' }, [ + el('div', { class: 'app-header__mark', text: '⇄' }), + el('span', { text: 'Two-Export Comparator' }), + ]), + el('div', { class: 'app-header__meta' }, [ + el('span', { class: 'app-header__tagline', text: 'Local processing · No uploads' }), + el('span', { class: 'privacy-badge', title: 'Files never leave this browser' }, [ + el('span', { class: 'privacy-badge__dot', 'aria-hidden': 'true' }), + el('span', { text: 'Local only' }), + ]), + ]), + ]); + + const landingView = new LandingView(store); + const mappingView = new MappingView(store, service); + const comparingView = new ComparingView(store); + const resultsView = new ResultsView(store); + const toastHost = new ToastHost(store); + + replaceChildren(root, [ + SkipLink(), + header, + landingView.root, + mappingView.root, + comparingView.root, + resultsView.root, + toastHost.root, + ]); + + const sync = () => { + root.dataset.state = store.state; + const showLanding = ['EMPTY', 'FILES_PARTIAL', 'FILES_READY'].includes(store.state); + landingView.root.hidden = !showLanding; + }; + store.addEventListener('state', sync); + sync(); + + if (import.meta.env?.DEV) { + window.__app = { store, controller, service }; + } + + return { store, controller, service }; +} + +startApp(); \ No newline at end of file diff --git a/src/models/contracts.js b/src/models/contracts.js index e69de29..313c721 100644 --- a/src/models/contracts.js +++ b/src/models/contracts.js @@ -0,0 +1,148 @@ +/** + * @file Data Transfer Object contracts for Two-Export Comparator. + * The UI depends only on these shapes — never on parser internals. + */ + +/** + * @typedef {'text' | 'number' | 'date' | 'unknown'} InferredType + */ + +/** + * @typedef {Object} ColumnInfo + * @property {string} id + * @property {string} label + * @property {InferredType} inferredType + */ + +/** + * @typedef {Object} FileWarning + * @property {string} code + * @property {'info' | 'warning' | 'error'} severity + * @property {string} title + * @property {string} [message] + * @property {boolean} [recoverable] + * @property {string[]} [actions] + */ + +/** + * @typedef {Object} FileInspection + * @property {string} id + * @property {string} name + * @property {number} sizeBytes + * @property {number | null} rowCount + * @property {string | null} delimiter + * @property {string | null} encoding + * @property {ColumnInfo[]} columns + * @property {Record[]} previewRows + * @property {FileWarning[]} warnings + */ + +/** + * @typedef {'text' | 'number' | 'date'} MappingFieldType + */ + +/** + * @typedef {Object} Tolerance + * @property {'absolute' | 'percentage'} mode + * @property {number} value + */ + +/** + * @typedef {Object} KeyMapping + * @property {string} columnA + * @property {string} columnB + * @property {MappingFieldType} type + */ + +/** + * @typedef {Object} CompareMapping + * @property {string} columnA + * @property {string} columnB + * @property {MappingFieldType} type + * @property {Tolerance} [tolerance] + */ + +/** + * @typedef {Object} NormalizationConfig + * @property {boolean} trimText + * @property {boolean} caseInsensitive + * @property {boolean} collapseWhitespace + * @property {string} numberLocale + * @property {string | null} dateFormatA + * @property {string | null} dateFormatB + */ + +/** + * @typedef {Object} MappingConfig + * @property {KeyMapping[]} keys + * @property {CompareMapping[]} comparisons + * @property {{ fileA: string[], fileB: string[] }} displayOnly + * @property {NormalizationConfig} normalization + */ + +/** + * @typedef {Object} ComparisonRequest + * @property {string} fileAId + * @property {string} fileBId + * @property {MappingConfig} mapping + */ + +/** + * @typedef {'MATCHED' | 'ONLY_A' | 'ONLY_B' | 'MISMATCH' | 'DUPLICATE' | 'AMBIGUOUS'} ResultStatus + */ + +/** + * @typedef {Object} FieldDifference + * @property {string} field + * @property {unknown} valueA + * @property {unknown} valueB + * @property {number} [delta] + */ + +/** + * @typedef {Object} ResultRecord + * @property {string} id + * @property {ResultStatus} status + * @property {string} keyLabel + * @property {{ rowA: number | null, rowB: number | null }} source + * @property {Record} [displayA] + * @property {Record} [displayB] + * @property {FieldDifference[]} [differences] + */ + +/** + * @typedef {Object} ComparisonSummary + * @property {number} totalA + * @property {number} totalB + * @property {number} matched + * @property {number} onlyA + * @property {number} onlyB + * @property {number} mismatched + * @property {number} duplicates + * @property {number} ambiguous + */ + +/** + * @typedef {Object} ComparisonResult + * @property {string} runId + * @property {string} createdAt + * @property {ComparisonSummary} summary + * @property {ResultRecord[]} records + * @property {FileWarning[]} warnings + */ + +/** + * @typedef {Object} ProgressEvent + * @property {'parse-a' | 'parse-b' | 'index' | 'compare' | 'finalize'} phase + * @property {string} message + * @property {number} [completed] + * @property {number} [total] + */ + +/** + * @typedef {Object} ExportOptions + * @property {ResultStatus[]} [includeStatuses] + * @property {'csv'} [format] + */ + +export {}; \ No newline at end of file diff --git a/src/services/browser-comparison-service.js b/src/services/browser-comparison-service.js index e69de29..cdcd02d 100644 --- a/src/services/browser-comparison-service.js +++ b/src/services/browser-comparison-service.js @@ -0,0 +1,24 @@ +import { ComparisonService } from './comparison-service.js'; + +/** + * Placeholder for the real browser-side engine. + * Will be implemented during the technical phase — do not fill in yet. + * @extends ComparisonService + */ +export class BrowserComparisonService extends ComparisonService { + async inspectFile() { + throw new Error('BrowserComparisonService is not implemented in the design phase.'); + } + async suggestMappings() { + throw new Error('BrowserComparisonService is not implemented in the design phase.'); + } + async compare() { + throw new Error('BrowserComparisonService is not implemented in the design phase.'); + } + async createExport() { + throw new Error('BrowserComparisonService is not implemented in the design phase.'); + } + reset() { + throw new Error('BrowserComparisonService is not implemented in the design phase.'); + } +} \ No newline at end of file diff --git a/src/services/comparison-service.js b/src/services/comparison-service.js index e69de29..c0074f1 100644 --- a/src/services/comparison-service.js +++ b/src/services/comparison-service.js @@ -0,0 +1,58 @@ +/** + * @file ComparisonService — the single integration boundary. + * All technical logic lives behind this contract. + * During the GUI phase, MockComparisonService is used. + * Later, BrowserComparisonService replaces it without UI redesign. + */ + +/** + * @interface + */ +export class ComparisonService { + /** + * Inspect a file and return metadata, columns and a safe preview. + * @param {File} file + * @param {Object} [options] + * @returns {Promise} + */ + async inspectFile(_file, _options = {}) { + throw new Error('ComparisonService.inspectFile not implemented'); + } + + /** + * Suggest column mappings. Proposals only — user must confirm. + * @param {import('../models/contracts.js').FileInspection} fileA + * @param {import('../models/contracts.js').FileInspection} fileB + * @returns {Promise>} + */ + async suggestMappings(_fileA, _fileB) { + throw new Error('ComparisonService.suggestMappings not implemented'); + } + + /** + * Run the comparison. + * @param {import('../models/contracts.js').ComparisonRequest} request + * @param {{ onProgress?: (e: import('../models/contracts.js').ProgressEvent) => void }} [callbacks] + * @returns {Promise} + */ + async compare(_request, _callbacks = {}) { + throw new Error('ComparisonService.compare not implemented'); + } + + /** + * Produce a downloadable Blob for the given result. + * @param {import('../models/contracts.js').ComparisonResult} result + * @param {import('../models/contracts.js').ExportOptions} [options] + * @returns {Promise} + */ + async createExport(_result, _options = {}) { + throw new Error('ComparisonService.createExport not implemented'); + } + + /** + * Clear in-memory file/parsed data and caches. + */ + reset() { + throw new Error('ComparisonService.reset not implemented'); + } +} \ No newline at end of file diff --git a/src/services/mock-comparison-service.js b/src/services/mock-comparison-service.js index e69de29..e50bc4f 100644 --- a/src/services/mock-comparison-service.js +++ b/src/services/mock-comparison-service.js @@ -0,0 +1,202 @@ +import { ComparisonService } from './comparison-service.js'; + +/** + * Deterministic mock service for design/demo purposes. + * Does NOT pretend to be production comparison logic. + * @extends ComparisonService + */ +export class MockComparisonService extends ComparisonService { + constructor() { + super(); + this._files = new Map(); + } + + /** + * @param {File} file + * @param {object} [options] + * @returns {Promise} + */ + async inspectFile(file, _options = {}) { + await this._delay(420); + + const id = `file-${this._files.size === 0 ? 'a' : 'b'}`; + const isA = id === 'file-a'; + + /** @type {import('../models/contracts.js').FileInspection} */ + const inspection = { + id, + name: file.name, + sizeBytes: file.size, + rowCount: isA ? 1204 : 1197, + delimiter: ',', + encoding: 'UTF-8', + columns: isA + ? [ + { id: 'Reference', label: 'Reference', inferredType: 'text' }, + { id: 'Date', label: 'Date', inferredType: 'date' }, + { id: 'Amount', label: 'Amount', inferredType: 'number' }, + { id: 'Description', label: 'Description', inferredType: 'text' }, + ] + : [ + { id: 'Payment Ref', label: 'Payment Ref', inferredType: 'text' }, + { id: 'Paid Date', label: 'Paid Date', inferredType: 'date' }, + { id: 'Total', label: 'Total', inferredType: 'number' }, + { id: 'Customer', label: 'Customer', inferredType: 'text' }, + ], + previewRows: isA + ? [ + { Reference: 'INV-1001', Date: '2026-09-15', Amount: '125.00', Description: 'Consulting' }, + { Reference: 'INV-1002', Date: '2026-09-18', Amount: '125.00', Description: 'Design' }, + { Reference: 'INV-1003', Date: '2026-09-20', Amount: '89.50', Description: 'Hosting' }, + ] + : [ + { 'Payment Ref': 'INV-1001', 'Paid Date': '2026-09-15', Total: '125.00', Customer: 'Acme' }, + { 'Payment Ref': 'INV-1002', 'Paid Date': '2026-09-18', Total: '152.00', Customer: 'Acme' }, + { 'Payment Ref': 'INV-1004', 'Paid Date': '2026-09-22', Total: '45.00', Customer: 'Beta' }, + ], + warnings: [], + }; + + this._files.set(id, { file, inspection }); + return inspection; + } + + /** + * @param {import('../models/contracts.js').FileInspection} fileA + * @param {import('../models/contracts.js').FileInspection} fileB + */ + async suggestMappings(fileA, fileB) { + await this._delay(220); + + const suggestions = []; + const pairs = [ + ['Reference', 'Payment Ref'], + ['Amount', 'Total'], + ['Date', 'Paid Date'], + ['Description', 'Customer'], + ]; + + for (const [a, b] of pairs) { + if ( + fileA.columns.some((c) => c.id === a) && + fileB.columns.some((c) => c.id === b) + ) { + suggestions.push({ columnA: a, columnB: b, confidence: 0.91 }); + } + } + return suggestions; + } + + /** + * @param {import('../models/contracts.js').ComparisonRequest} request + * @param {{ onProgress?: (e: import('../models/contracts.js').ProgressEvent) => void }} [callbacks] + */ + async compare(_request, { onProgress } = {}) { + const phases = [ + { phase: 'parse-a', message: 'Parsing File A…', completed: 0, total: 100 }, + { phase: 'parse-b', message: 'Parsing File B…', completed: 40, total: 100 }, + { phase: 'index', message: 'Indexing records…', completed: 70, total: 100 }, + { phase: 'compare', message: 'Comparing records…', completed: 90, total: 100 }, + { phase: 'finalize', message: 'Finalizing…', completed: 100, total: 100 }, + ]; + + for (const p of phases) { + onProgress?.(p); + await this._delay(360); + } + + /** @type {import('../models/contracts.js').ComparisonResult} */ + return { + runId: 'cmp-mock-001', + createdAt: new Date().toISOString(), + summary: { + totalA: 1204, + totalB: 1197, + matched: 1176, + onlyA: 12, + onlyB: 7, + mismatched: 4, + duplicates: 2, + ambiguous: 3, + }, + records: this._sampleRecords(), + warnings: [], + }; + } + + async createExport(_result, _options = {}) { + const csv = [ + 'status,key,rowA,rowB,note', + 'MISMATCH,INV-1002,482,391,amount differs', + 'ONLY_A,INV-1003,483,,missing in B', + 'ONLY_B,INV-1004,,392,missing in A', + ].join('\n'); + + return new Blob([csv], { type: 'text/csv;charset=utf-8' }); + } + + reset() { + this._files.clear(); + } + + /** @private */ + _sampleRecords() { + return [ + { + id: 'result-1', + status: 'MISMATCH', + keyLabel: 'INV-1002', + source: { rowA: 482, rowB: 391 }, + displayA: { Date: '2026-09-18', Amount: '125.00' }, + displayB: { 'Paid Date': '2026-09-18', Total: '152.00' }, + differences: [ + { field: 'amount', valueA: 125.0, valueB: 152.0, delta: 27.0 }, + ], + }, + { + id: 'result-2', + status: 'ONLY_A', + keyLabel: 'INV-1003', + source: { rowA: 483, rowB: null }, + displayA: { Date: '2026-09-20', Amount: '89.50' }, + }, + { + id: 'result-3', + status: 'ONLY_B', + keyLabel: 'INV-1004', + source: { rowA: null, rowB: 392 }, + displayB: { 'Paid Date': '2026-09-22', Total: '45.00' }, + }, + { + id: 'result-4', + status: 'MATCHED', + keyLabel: 'INV-1001', + source: { rowA: 481, rowB: 390 }, + displayA: { Date: '2026-09-15', Amount: '125.00' }, + displayB: { 'Paid Date': '2026-09-15', Total: '125.00' }, + differences: [], + }, + { + id: 'result-5', + status: 'DUPLICATE', + keyLabel: 'INV-1005', + source: { rowA: 484, rowB: 393 }, + displayA: { Date: '2026-09-21', Amount: '50.00' }, + displayB: { 'Paid Date': '2026-09-21', Total: '50.00' }, + }, + { + id: 'result-6', + status: 'AMBIGUOUS', + keyLabel: 'INV-1006', + source: { rowA: 485, rowB: 394 }, + displayA: { Date: '2026-09-22', Amount: '75.00' }, + displayB: { 'Paid Date': '2026-09-22', Total: '75.00' }, + }, + ]; + } + + /** @private */ + _delay(ms) { + return new Promise((r) => setTimeout(r, ms)); + } +} \ No newline at end of file diff --git a/src/styles/base.css b/src/styles/base.css index e69de29..93c32d9 100644 --- a/src/styles/base.css +++ b/src/styles/base.css @@ -0,0 +1,184 @@ +/* ========================================================================== + Base — reset, typography, focus + ========================================================================== */ + +*, +*::before, +*::after { + box-sizing: border-box; +} + +* { + margin: 0; + padding: 0; +} + +html { + -webkit-text-size-adjust: 100%; + text-size-adjust: 100%; + scroll-behavior: smooth; +} + +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } +} + +body { + font-family: var(--font-sans); + font-size: var(--text-base); + line-height: var(--leading-base); + color: var(--color-text-0); + background-color: var(--color-bg-0); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; + min-height: 100dvh; + overflow-x: hidden; +} + +/* Subtle ambient gradient — premium, calm, no motion */ +body::before { + content: ''; + position: fixed; + inset: 0; + pointer-events: none; + z-index: 0; + background: + radial-gradient( + 1200px 600px at 15% -10%, + rgba(110, 231, 168, 0.06), + transparent 60% + ), + radial-gradient( + 900px 500px at 90% 0%, + rgba(96, 165, 250, 0.05), + transparent 55% + ); +} + +img, +svg, +video { + display: block; + max-width: 100%; +} + +button, +input, +select, +textarea { + font: inherit; + color: inherit; +} + +button { + cursor: pointer; + background: none; + border: none; +} + +a { + color: var(--color-accent); + text-decoration: none; +} + +a:hover { + color: var(--color-accent-hi); + text-decoration: underline; + text-underline-offset: 3px; +} + +h1, h2, h3, h4, h5, h6 { + font-weight: var(--weight-semi); + line-height: var(--leading-tight); + letter-spacing: var(--tracking-tight); + color: var(--color-text-0); +} + +h1 { font-size: var(--text-4xl); } +h2 { font-size: var(--text-2xl); } +h3 { font-size: var(--text-xl); } +h4 { font-size: var(--text-lg); } + +p { + color: var(--color-text-1); +} + +code, +pre, +kbd, +samp { + font-family: var(--font-mono); + font-size: 0.9em; +} + +/* ---------- Focus ring — accessible, consistent ---------- */ +:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; + border-radius: var(--radius-sm); +} + +:focus:not(:focus-visible) { + outline: none; +} + +/* ---------- Selection ---------- */ +::selection { + background-color: rgba(110, 231, 168, 0.28); + color: var(--color-text-0); +} + +/* ---------- Scrollbar (webkit) ---------- */ +::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--color-border-1); + border-radius: var(--radius-pill); + border: 2px solid transparent; + background-clip: padding-box; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--color-border-2); + background-clip: padding-box; +} + +/* ---------- Reduced motion — global kill switch ---------- */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + scroll-behavior: auto !important; + } +} + +/* ---------- Visually hidden ---------- */ +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +/* ---------- Hidden attribute ---------- */ +[hidden] { + display: none !important; +} \ No newline at end of file diff --git a/src/styles/components.css b/src/styles/components.css index e69de29..87f53dc 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -0,0 +1,1682 @@ +/* ========================================================================== + Components — Two-Export Comparator + shell · header · buttons · cards · badges · toasts · skeletons · + landing · file panel · mapping · comparing · results · drawer + ========================================================================== */ + +/* ========================================================================== + App shell + ========================================================================== */ +.app-shell { + position: relative; + z-index: var(--z-base); + min-height: 100dvh; + display: flex; + flex-direction: column; +} + +.app-main { + flex: 1; + padding: var(--space-10) 0 var(--space-16); +} + +/* ========================================================================== + App header + ========================================================================== */ +.app-header { + position: sticky; + top: 0; + z-index: 50; + height: var(--header-height); + display: flex; + align-items: center; + justify-content: space-between; + padding-inline: var(--space-6); + background: rgba(11, 13, 16, 0.72); + backdrop-filter: blur(14px) saturate(1.4); + -webkit-backdrop-filter: blur(14px) saturate(1.4); + border-bottom: 1px solid var(--color-border-0); +} + +[data-theme='light'] .app-header { + background: rgba(250, 251, 252, 0.78); +} + +.app-header__brand { + display: flex; + align-items: center; + gap: var(--space-3); + font-weight: var(--weight-semi); + font-size: var(--text-md); + letter-spacing: var(--tracking-tight); + color: var(--color-text-0); +} + +.app-header__mark { + width: 26px; + height: 26px; + border-radius: var(--radius-sm); + background: linear-gradient(135deg, var(--color-accent), #3b82f6); + display: grid; + place-items: center; + color: #0b0d10; + font-weight: var(--weight-bold); + font-size: var(--text-xs); + box-shadow: var(--shadow-glow-accent); +} + +.app-header__meta { + display: flex; + align-items: center; + gap: var(--space-4); +} + +.app-header__tagline { + font-size: var(--text-sm); + color: var(--color-text-2); +} + +@media (max-width: 640px) { + .app-header { + padding-inline: var(--space-4); + } + .app-header__tagline { + display: none; + } +} + +/* ========================================================================== + Privacy badge + ========================================================================== */ +.privacy-badge { + display: inline-flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-1) var(--space-3); + font-size: var(--text-xs); + font-weight: var(--weight-medium); + color: var(--color-match); + background: var(--tint-match); + border: 1px solid rgba(74, 222, 128, 0.22); + border-radius: var(--radius-pill); + white-space: nowrap; +} + +.privacy-badge__dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--color-match); + box-shadow: 0 0 8px rgba(74, 222, 128, 0.7); +} + +/* ========================================================================== + Buttons + ========================================================================== */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-4); + font-size: var(--text-base); + font-weight: var(--weight-medium); + line-height: 1; + border-radius: var(--radius-md); + border: 1px solid transparent; + transition: + background-color var(--dur-fast) var(--ease-standard), + border-color var(--dur-fast) var(--ease-standard), + color var(--dur-fast) var(--ease-standard), + transform var(--dur-fast) var(--ease-standard), + box-shadow var(--dur-base) var(--ease-standard); + user-select: none; + white-space: nowrap; +} + +.btn:active { + transform: translateY(1px); +} + +.btn:disabled, +.btn[aria-disabled='true'] { + opacity: 0.45; + cursor: not-allowed; + transform: none; +} + +.btn--primary { + background: var(--color-accent); + color: #0b0d10; + font-weight: var(--weight-semi); + box-shadow: var(--shadow-glow-accent); +} + +.btn--primary:hover:not(:disabled) { + background: var(--color-accent-hi); + box-shadow: + 0 0 0 1px rgba(110, 231, 168, 0.35), + 0 10px 36px rgba(110, 231, 168, 0.18); +} + +.btn--secondary { + background: var(--color-surface-1); + color: var(--color-text-0); + border-color: var(--color-border-1); +} + +.btn--secondary:hover:not(:disabled) { + background: var(--color-surface-2); + border-color: var(--color-border-2); +} + +.btn--ghost { + background: transparent; + color: var(--color-text-1); +} + +.btn--ghost:hover:not(:disabled) { + background: var(--color-surface-1); + color: var(--color-text-0); +} + +.btn--danger { + background: transparent; + color: var(--color-mismatch); + border-color: rgba(248, 113, 113, 0.30); +} + +.btn--danger:hover:not(:disabled) { + background: var(--tint-mismatch); + border-color: rgba(248, 113, 113, 0.55); +} + +.btn--sm { + padding: var(--space-1) var(--space-3); + font-size: var(--text-sm); + border-radius: var(--radius-sm); +} + +.btn--lg { + padding: var(--space-3) var(--space-6); + font-size: var(--text-md); + border-radius: var(--radius-lg); +} + +.btn--icon { + padding: var(--space-2); + width: 36px; + height: 36px; +} + +/* ========================================================================== + Cards + ========================================================================== */ +.card { + background: var(--color-surface-0); + border: 1px solid var(--color-border-0); + border-radius: var(--radius-lg); + padding: var(--space-6); + transition: + border-color var(--dur-base) var(--ease-standard), + background-color var(--dur-base) var(--ease-standard), + box-shadow var(--dur-base) var(--ease-standard), + transform var(--dur-base) var(--ease-standard); +} + +.card--interactive:hover { + border-color: var(--color-border-1); + background: var(--color-surface-1); + box-shadow: var(--shadow-md); +} + +.card--elevated { + background: var(--color-bg-2); + border-color: var(--color-border-1); + box-shadow: var(--shadow-sm); +} + +/* ========================================================================== + Badges + ========================================================================== */ +.badge { + display: inline-flex; + align-items: center; + gap: var(--space-1); + padding: 2px var(--space-2); + font-size: var(--text-xs); + font-weight: var(--weight-medium); + border-radius: var(--radius-sm); + white-space: nowrap; + letter-spacing: var(--tracking-wide); + text-transform: uppercase; +} + +.badge[data-status='MATCHED'] { color: var(--color-match); background: var(--tint-match); } +.badge[data-status='MISMATCH'] { color: var(--color-mismatch); background: var(--tint-mismatch); } +.badge[data-status='ONLY_A'] { color: var(--color-only-a); background: var(--tint-only-a); } +.badge[data-status='ONLY_B'] { color: var(--color-only-b); background: var(--tint-only-b); } +.badge[data-status='DUPLICATE'] { color: var(--color-dup); background: var(--tint-dup); } +.badge[data-status='AMBIGUOUS'] { color: var(--color-ambig); background: var(--tint-ambig); } + +/* ========================================================================== + Divider + ========================================================================== */ +.divider { + height: 1px; + background: var(--color-border-0); + border: none; + margin-block: var(--space-6); +} + +/* ========================================================================== + Toast + ========================================================================== */ +.toast { + display: flex; + align-items: flex-start; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + border-radius: var(--radius-md); + border: 1px solid var(--color-border-1); + background: var(--color-bg-2); + font-size: var(--text-sm); + color: var(--color-text-1); +} + +.toast[data-severity='info'] { border-color: rgba(96, 165, 250, 0.35); background: var(--tint-info); } +.toast[data-severity='warning'] { border-color: rgba(251, 191, 36, 0.35); background: var(--tint-only-a); } +.toast[data-severity='error'] { border-color: rgba(248, 113, 113, 0.35); background: var(--tint-mismatch); } + +/* ========================================================================== + Landing + ========================================================================== */ +.landing { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + padding: var(--space-16) var(--space-6) var(--space-12); + gap: var(--space-12); +} + +.landing__hero { + max-width: 640px; + text-align: center; + display: flex; + flex-direction: column; + gap: var(--space-4); + animation: landing-rise var(--dur-slower) var(--ease-out) both; +} + +.landing__title { + font-size: clamp(1.75rem, 3.5vw + 1rem, 2.75rem); + font-weight: var(--weight-semi); + line-height: var(--leading-tight); + letter-spacing: -0.02em; + color: var(--color-text-0); +} + +.landing__title-accent { + background: linear-gradient(135deg, var(--color-accent) 0%, #60a5fa 100%); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + -webkit-text-fill-color: transparent; +} + +.landing__subtitle { + font-size: var(--text-lg); + color: var(--color-text-2); + line-height: var(--leading-snug); +} + +.landing__workflow { + width: 100%; + max-width: var(--container-max); + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: stretch; + gap: var(--space-6); + animation: landing-rise var(--dur-slower) var(--ease-out) 80ms both; +} + +.landing__connector { + align-self: center; + color: var(--color-text-3); + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 50%; + background: var(--color-surface-1); + border: 1px solid var(--color-border-0); +} + +.landing__footer { + display: flex; + justify-content: center; + animation: landing-rise var(--dur-slower) var(--ease-out) 160ms both; +} + +@keyframes landing-rise { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (max-width: 860px) { + .landing { + padding: var(--space-10) var(--space-4) var(--space-8); + gap: var(--space-8); + } + .landing__workflow { + grid-template-columns: 1fr; + } + .landing__connector { + transform: rotate(90deg); + margin-inline: auto; + } +} + +/* ========================================================================== + FilePanel + ========================================================================== */ +.file-panel { + position: relative; + display: flex; + flex-direction: column; + background: var(--color-surface-0); + border: 1px solid var(--color-border-0); + border-radius: var(--radius-lg); + overflow: hidden; + transition: + border-color var(--dur-base) var(--ease-standard), + background-color var(--dur-base) var(--ease-standard), + box-shadow var(--dur-base) var(--ease-standard); + min-height: 280px; +} + +.file-panel[data-state='empty']:hover { + border-color: var(--color-border-1); +} + +.file-panel[data-state='ready'] { + border-color: rgba(110, 231, 168, 0.20); +} + +.file-panel[data-state='warning'] { + border-color: rgba(251, 191, 36, 0.28); +} + +.file-panel[data-state='error'] { + border-color: rgba(248, 113, 113, 0.30); +} + +.file-panel__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + padding: var(--space-4) var(--space-5); + border-bottom: 1px solid var(--color-border-0); + background: var(--color-surface-0); +} + +.file-panel[data-state='empty'] .file-panel__header { + border-bottom-color: transparent; +} + +.file-panel__label { + display: flex; + align-items: baseline; + gap: var(--space-3); + min-width: 0; + flex: 1; +} + +.file-panel__slot { + font-size: var(--text-xs); + font-weight: var(--weight-bold); + letter-spacing: 0.08em; + color: var(--color-text-3); + white-space: nowrap; +} + +.file-panel__name { + font-size: var(--text-base); + font-weight: var(--weight-medium); + color: var(--color-text-0); + min-width: 0; +} + +.file-panel__name--empty { + color: var(--color-text-3); + font-weight: var(--weight-regular); +} + +/* ---------- Dropzone ---------- */ +.file-panel__dropzone { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--space-4); + margin: var(--space-4); + padding: var(--space-8) var(--space-6); + background: transparent; + border: 1.5px dashed var(--color-border-1); + border-radius: var(--radius-md); + color: var(--color-text-2); + text-align: center; + transition: + border-color var(--dur-base) var(--ease-standard), + background-color var(--dur-base) var(--ease-standard), + color var(--dur-base) var(--ease-standard), + transform var(--dur-base) var(--ease-standard); +} + +.file-panel__dropzone:hover, +.file-panel__dropzone[data-dragover='true'] { + border-color: var(--color-accent); + background: rgba(110, 231, 168, 0.04); + color: var(--color-text-0); +} + +.file-panel__dropzone[data-dragover='true'] { + transform: scale(1.01); + box-shadow: 0 0 0 4px rgba(110, 231, 168, 0.08); +} + +.file-panel__dropzone-icon { + width: 44px; + height: 44px; + border-radius: var(--radius-md); + display: grid; + place-items: center; + background: var(--color-surface-2); + color: var(--color-text-1); + transition: + background-color var(--dur-base) var(--ease-standard), + color var(--dur-base) var(--ease-standard); +} + +.file-panel__dropzone:hover .file-panel__dropzone-icon, +.file-panel__dropzone[data-dragover='true'] .file-panel__dropzone-icon { + background: rgba(110, 231, 168, 0.12); + color: var(--color-accent); +} + +.file-panel__dropzone-text { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.file-panel__dropzone-primary { + font-size: var(--text-base); + font-weight: var(--weight-medium); + color: var(--color-text-0); +} + +.file-panel__dropzone-secondary { + font-size: var(--text-sm); + color: var(--color-text-3); +} + +/* ---------- FilePanel body ---------- */ +.file-panel__body { + padding: var(--space-5); + display: flex; + flex-direction: column; + gap: var(--space-4); + flex: 1; +} + +.file-panel__meta { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(84px, 1fr)); + gap: var(--space-3); + padding-bottom: var(--space-4); + border-bottom: 1px solid var(--color-border-0); +} + +.file-panel__meta-item { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.file-panel__meta-label { + font-size: var(--text-xs); + color: var(--color-text-3); + letter-spacing: var(--tracking-wide); + text-transform: uppercase; + font-weight: var(--weight-medium); +} + +.file-panel__meta-value { + font-size: var(--text-base); + color: var(--color-text-0); + font-weight: var(--weight-medium); + font-variant-numeric: tabular-nums; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.file-panel__warnings { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +/* ---------- Preview ---------- */ +.file-panel__preview { + display: flex; + flex-direction: column; + gap: var(--space-2); + min-width: 0; +} + +.file-panel__preview-label { + letter-spacing: var(--tracking-wide); + font-weight: var(--weight-semi); +} + +.file-panel__preview-scroll { + overflow: auto; + border: 1px solid var(--color-border-0); + border-radius: var(--radius-sm); + background: var(--color-bg-1); + max-height: 180px; +} + +.file-panel__preview-table { + width: 100%; + border-collapse: collapse; + font-size: var(--text-xs); + font-family: var(--font-mono); +} + +.file-panel__preview-table th, +.file-panel__preview-table td { + padding: var(--space-2) var(--space-3); + text-align: left; + white-space: nowrap; + border-bottom: 1px solid var(--color-border-0); + max-width: 180px; + overflow: hidden; + text-overflow: ellipsis; +} + +.file-panel__preview-table th { + position: sticky; + top: 0; + background: var(--color-bg-2); + color: var(--color-text-2); + font-weight: var(--weight-semi); + letter-spacing: var(--tracking-wide); + text-transform: uppercase; + font-size: 10px; + z-index: 1; +} + +.file-panel__preview-table td { + color: var(--color-text-1); +} + +.file-panel__preview-table tr:last-child td { + border-bottom: none; +} + +.file-panel__preview-foot { + text-align: right; +} + +/* ---------- Skeletons ---------- */ +.file-panel__body--loading { + gap: var(--space-3); +} + +.skeleton { + background: linear-gradient( + 90deg, + var(--color-surface-1) 0%, + var(--color-surface-2) 50%, + var(--color-surface-1) 100% + ); + background-size: 200% 100%; + border-radius: var(--radius-sm); + animation: skeleton-shimmer 1.4s var(--ease-standard) infinite; +} + +.skeleton--line { + height: 12px; +} + +.skeleton--block { + height: 120px; + border-radius: var(--radius-md); +} + +.skeleton--w40 { width: 40%; } +.skeleton--w60 { width: 60%; } +.skeleton--w80 { width: 80%; } + +@keyframes skeleton-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +@media (prefers-reduced-motion: reduce) { + .skeleton { + animation: none; + background: var(--color-surface-2); + } +} + +/* ========================================================================== + PrivacyNote + ========================================================================== */ +.privacy-note { + display: inline-flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-4); + font-size: var(--text-sm); + color: var(--color-text-2); + background: var(--color-surface-0); + border: 1px solid var(--color-border-0); + border-radius: var(--radius-pill); +} + +.privacy-note svg { + color: var(--color-match); + flex-shrink: 0; +} + +/* ========================================================================== + ToastHost + ========================================================================== */ +.toast-host { + position: fixed; + bottom: var(--space-6); + right: var(--space-6); + display: flex; + flex-direction: column; + gap: var(--space-2); + z-index: var(--z-toast); + pointer-events: none; + max-width: min(380px, calc(100vw - var(--space-8))); +} + +.toast--floating { + pointer-events: auto; + background: var(--color-bg-2); + box-shadow: var(--shadow-lg); + animation: toast-in var(--dur-slow) var(--ease-out) both; +} + +.toast__body { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +@keyframes toast-in { + from { + opacity: 0; + transform: translateY(8px) scale(0.98); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@media (max-width: 640px) { + .toast-host { + bottom: var(--space-4); + right: var(--space-4); + left: var(--space-4); + max-width: none; + } +} + +/* ========================================================================== + Form controls + ========================================================================== */ +.select, +.input { + width: 100%; + padding: var(--space-2) var(--space-3); + font-size: var(--text-base); + color: var(--color-text-0); + background: var(--color-bg-2); + border: 1px solid var(--color-border-1); + border-radius: var(--radius-md); + transition: + border-color var(--dur-fast) var(--ease-standard), + background-color var(--dur-fast) var(--ease-standard), + box-shadow var(--dur-fast) var(--ease-standard); + appearance: none; + -webkit-appearance: none; +} + +.select { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath d='M3 5l3 3 3-3' stroke='%238b939e' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right var(--space-3) center; + background-size: 12px; + padding-right: var(--space-8); +} + +.select:hover, +.input:hover { + border-color: var(--color-border-2); +} + +.select:focus, +.input:focus { + outline: none; + border-color: var(--color-accent); + box-shadow: 0 0 0 3px rgba(110, 231, 168, 0.15); +} + +.select--sm, +.input--sm { + padding: var(--space-1) var(--space-2); + font-size: var(--text-sm); + border-radius: var(--radius-sm); + width: auto; +} + +.input--sm { + width: 80px; +} + +/* ========================================================================== + Toggle + ========================================================================== */ +.toggle { + display: inline-flex; + align-items: center; + gap: var(--space-3); + cursor: pointer; + user-select: none; +} + +.toggle input { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.toggle__track { + position: relative; + width: 34px; + height: 20px; + border-radius: var(--radius-pill); + background: var(--color-bg-4); + border: 1px solid var(--color-border-1); + transition: + background-color var(--dur-base) var(--ease-standard), + border-color var(--dur-base) var(--ease-standard); + flex-shrink: 0; +} + +.toggle__thumb { + position: absolute; + top: 2px; + left: 2px; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--color-text-2); + transition: + transform var(--dur-base) var(--ease-emphasis), + background-color var(--dur-base) var(--ease-standard); +} + +.toggle input:checked + .toggle__track { + background: rgba(110, 231, 168, 0.20); + border-color: rgba(110, 231, 168, 0.55); +} + +.toggle input:checked + .toggle__track .toggle__thumb { + transform: translateX(14px); + background: var(--color-accent); +} + +.toggle input:focus-visible + .toggle__track { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} + +.toggle__label { + font-size: var(--text-base); + color: var(--color-text-1); +} + +/* ========================================================================== + Mapping workspace + ========================================================================== */ +.mapping-workspace { + display: flex; + flex-direction: column; + gap: var(--space-8); + animation: fade-rise var(--dur-slow) var(--ease-out) both; +} + +.mapping-workspace__header { + display: flex; + flex-direction: column; + gap: var(--space-2); + max-width: 720px; +} + +.mapping-workspace__title { + font-size: var(--text-2xl); +} + +.mapping-workspace__hint { + color: var(--color-text-2); + font-size: var(--text-md); +} + +.mapping-workspace__col-heads { + display: grid; + grid-template-columns: 1fr auto 1fr; + gap: var(--space-4); + padding-bottom: var(--space-3); + border-bottom: 1px solid var(--color-border-0); +} + +.mapping-workspace__col-head { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.mapping-workspace__col-head--spacer { + width: 32px; +} + +.mapping-workspace__col-label { + font-size: var(--text-xs); + font-weight: var(--weight-bold); + letter-spacing: 0.08em; + color: var(--color-text-3); +} + +.mapping-workspace__col-name { + font-size: var(--text-sm); + color: var(--color-text-1); + font-family: var(--font-mono); +} + +.mapping-workspace__group { + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.mapping-workspace__group-head { + display: flex; + align-items: center; + gap: var(--space-3); + flex-wrap: wrap; +} + +.mapping-workspace__group-title { + font-size: var(--text-lg); +} + +.mapping-workspace__group-hint { + flex: 1; +} + +.mapping-workspace__rows { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.mapping-workspace__empty { + padding: var(--space-6); + text-align: center; + color: var(--color-text-3); + font-size: var(--text-sm); + border: 1px dashed var(--color-border-1); + border-radius: var(--radius-md); +} + +.mapping-workspace__summary { + cursor: pointer; + list-style: none; +} + +.mapping-workspace__summary::-webkit-details-marker { + display: none; +} + +.mapping-workspace__summary::before { + content: '▸'; + color: var(--color-text-3); + transition: transform var(--dur-fast) var(--ease-standard); + display: inline-block; +} + +.mapping-workspace__group--collapsible[open] .mapping-workspace__summary::before { + transform: rotate(90deg); +} + +.mapping-workspace__normalization { + display: flex; + flex-wrap: wrap; + gap: var(--space-6) var(--space-8); + padding: var(--space-4) var(--space-5); + background: var(--color-surface-0); + border: 1px solid var(--color-border-0); + border-radius: var(--radius-md); +} + +.mapping-workspace__actions { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-4); + padding-top: var(--space-6); + border-top: 1px solid var(--color-border-0); +} + +@media (max-width: 720px) { + .mapping-workspace__col-heads { + grid-template-columns: 1fr; + } + .mapping-workspace__col-head--spacer { + display: none; + } + .mapping-workspace__actions { + flex-direction: column-reverse; + } + .mapping-workspace__actions .btn { + width: 100%; + } +} + +/* ========================================================================== + Mapping row + ========================================================================== */ +.mapping-row { + display: flex; + flex-direction: column; + gap: var(--space-2); + padding: var(--space-4); + background: var(--color-surface-0); + border: 1px solid var(--color-border-0); + border-radius: var(--radius-md); + transition: + border-color var(--dur-base) var(--ease-standard), + background-color var(--dur-base) var(--ease-standard); + animation: fade-rise var(--dur-base) var(--ease-out) both; +} + +.mapping-row:hover { + border-color: var(--color-border-1); +} + +.mapping-row[data-role='key'] { + border-left: 3px solid var(--color-accent); +} + +.mapping-row[data-role='compare'] { + border-left: 3px solid var(--color-info); +} + +.mapping-row__role { + display: flex; + align-items: center; +} + +.mapping-row__role-chip { + font-size: 10px; + font-weight: var(--weight-bold); + letter-spacing: 0.08em; + padding: 2px var(--space-2); + border-radius: var(--radius-xs); + text-transform: uppercase; +} + +.mapping-row__role-chip--key { + color: var(--color-accent); + background: rgba(110, 231, 168, 0.10); +} + +.mapping-row__role-chip--compare { + color: var(--color-info); + background: var(--tint-info); +} + +.mapping-row__role-chip--display { + color: var(--color-text-2); + background: var(--color-surface-2); +} + +.mapping-row__body { + display: grid; + grid-template-columns: 1fr auto 1fr auto auto; + align-items: center; + gap: var(--space-3); +} + +.mapping-row__arrow { + color: var(--color-text-3); + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 50%; + background: var(--color-surface-1); +} + +.mapping-row__tolerance { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.mapping-row__remove { + opacity: 0.5; + transition: opacity var(--dur-fast) var(--ease-standard); +} + +.mapping-row:hover .mapping-row__remove { + opacity: 1; +} + +@media (max-width: 720px) { + .mapping-row__body { + grid-template-columns: 1fr; + gap: var(--space-2); + } + .mapping-row__arrow { + transform: rotate(90deg); + justify-self: center; + } + .mapping-row__remove { + align-self: flex-end; + } +} + +/* ========================================================================== + Comparing + ========================================================================== */ +.comparing { + max-width: 560px; + margin: var(--space-16) auto; + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-4); + text-align: center; +} + +.comparing__spinner { + width: 36px; + height: 36px; + border-radius: 50%; + border: 2px solid var(--color-border-1); + border-top-color: var(--color-accent); + animation: spin 900ms linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +@media (prefers-reduced-motion: reduce) { + .comparing__spinner { animation: none; } +} + +.comparing__title { + font-size: var(--text-xl); +} + +.comparing__subtitle { + font-size: var(--text-sm); +} + +.comparing__bar { + width: 100%; + height: 6px; + border-radius: var(--radius-pill); + background: var(--color-surface-2); + overflow: hidden; + position: relative; +} + +.comparing__bar-fill { + height: 100%; + background: linear-gradient(90deg, var(--color-accent), #60a5fa); + border-radius: var(--radius-pill); + transition: width var(--dur-slow) var(--ease-standard); +} + +.comparing__bar-fill[data-indeterminate='true'] { + width: 40%; + animation: indeterminate 1.6s var(--ease-standard) infinite; +} + +@keyframes indeterminate { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(250%); } +} + +@media (prefers-reduced-motion: reduce) { + .comparing__bar-fill[data-indeterminate='true'] { + animation: none; + width: 100%; + opacity: 0.4; + } +} + +.comparing__phases { + list-style: none; + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: var(--space-2) var(--space-4); + margin-top: var(--space-4); +} + +.comparing__phase { + display: inline-flex; + align-items: center; + gap: var(--space-2); + font-size: var(--text-sm); + color: var(--color-text-3); + transition: color var(--dur-base) var(--ease-standard); +} + +.comparing__phase[data-state='active'] { color: var(--color-text-0); } +.comparing__phase[data-state='done'] { color: var(--color-match); } + +.comparing__phase-dot { + width: 16px; + height: 16px; + border-radius: 50%; + border: 1.5px solid currentColor; + display: grid; + place-items: center; + flex-shrink: 0; +} + +.comparing__phase[data-state='active'] .comparing__phase-dot { + background: var(--color-accent); + border-color: var(--color-accent); + box-shadow: 0 0 0 4px rgba(110, 231, 168, 0.15); +} + +.comparing__phase[data-state='done'] .comparing__phase-dot { + background: var(--color-match); + border-color: var(--color-match); + color: #0b0d10; +} + +@keyframes fade-rise { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ========================================================================== + Results dashboard + ========================================================================== */ +.results-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: var(--space-4); + flex-wrap: wrap; + margin-bottom: var(--space-6); +} + +.results-header__title { + font-size: var(--text-2xl); +} + +.results-header__sub { + font-size: var(--text-sm); + margin-top: var(--space-1); +} + +.results-header__actions { + display: flex; + gap: var(--space-2); +} + +/* ---------- Summary grid ---------- */ +.summary-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: var(--space-3); + margin-bottom: var(--space-6); +} + +.summary-card { + position: relative; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--space-1); + padding: var(--space-4); + background: var(--color-surface-0); + border: 1px solid var(--color-border-0); + border-radius: var(--radius-md); + text-align: left; + overflow: hidden; + transition: + border-color var(--dur-base) var(--ease-standard), + background-color var(--dur-base) var(--ease-standard), + transform var(--dur-base) var(--ease-standard); +} + +.summary-card:hover { + border-color: var(--color-border-1); + transform: translateY(-1px); +} + +.summary-card[data-active='true'] { + background: var(--color-surface-1); + border-color: var(--color-border-2); +} + +.summary-card__count { + font-size: var(--text-2xl); + font-weight: var(--weight-semi); + font-variant-numeric: tabular-nums; + color: var(--color-text-0); + line-height: 1; +} + +.summary-card__label { + font-size: var(--text-xs); + font-weight: var(--weight-semi); + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--color-text-2); +} + +.summary-card__bar { + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 2px; + background: transparent; +} + +.summary-card[data-status='MISMATCH'] .summary-card__bar { background: var(--color-mismatch); } +.summary-card[data-status='ONLY_A'] .summary-card__bar { background: var(--color-only-a); } +.summary-card[data-status='ONLY_B'] .summary-card__bar { background: var(--color-only-b); } +.summary-card[data-status='DUPLICATE'] .summary-card__bar { background: var(--color-dup); } +.summary-card[data-status='AMBIGUOUS'] .summary-card__bar { background: var(--color-ambig); } +.summary-card[data-status='MATCHED'] .summary-card__bar { background: var(--color-match); } +.summary-card[data-status='ALL'] .summary-card__bar { background: var(--color-text-3); } + +.summary-card[data-active='true'][data-status='MISMATCH'] { border-color: rgba(248, 113, 113, 0.55); } +.summary-card[data-active='true'][data-status='ONLY_A'] { border-color: rgba(251, 191, 36, 0.55); } +.summary-card[data-active='true'][data-status='ONLY_B'] { border-color: rgba(251, 146, 60, 0.55); } +.summary-card[data-active='true'][data-status='DUPLICATE'] { border-color: rgba(167, 139, 250, 0.55); } +.summary-card[data-active='true'][data-status='AMBIGUOUS'] { border-color: rgba(250, 204, 21, 0.55); } +.summary-card[data-active='true'][data-status='MATCHED'] { border-color: rgba(74, 222, 128, 0.55); } + +/* ---------- Results table ---------- */ +.results-table { + display: flex; + flex-direction: column; + gap: var(--space-3); + background: var(--color-surface-0); + border: 1px solid var(--color-border-0); + border-radius: var(--radius-lg); + overflow: hidden; +} + +.results-table__scroll { + overflow: auto; + max-height: 60vh; +} + +.results-table__table { + width: 100%; + border-collapse: collapse; + font-size: var(--text-base); +} + +.results-table__table th { + position: sticky; + top: 0; + z-index: 1; + text-align: left; + padding: var(--space-3) var(--space-4); + font-size: var(--text-xs); + font-weight: var(--weight-semi); + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--color-text-3); + background: var(--color-bg-2); + border-bottom: 1px solid var(--color-border-0); +} + +.results-table__table td { + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--color-border-0); + color: var(--color-text-1); +} + +.results-table__row { + cursor: pointer; + transition: background-color var(--dur-fast) var(--ease-standard); +} + +.results-table__row:hover, +.results-table__row:focus-visible { + background: var(--color-surface-1); + outline: none; +} + +.results-table__row[data-status='MISMATCH'] { box-shadow: inset 3px 0 0 var(--color-mismatch); } +.results-table__row[data-status='ONLY_A'] { box-shadow: inset 3px 0 0 var(--color-only-a); } +.results-table__row[data-status='ONLY_B'] { box-shadow: inset 3px 0 0 var(--color-only-b); } +.results-table__row[data-status='DUPLICATE'] { box-shadow: inset 3px 0 0 var(--color-dup); } +.results-table__row[data-status='AMBIGUOUS'] { box-shadow: inset 3px 0 0 var(--color-ambig); } +.results-table__row[data-status='MATCHED'] { box-shadow: inset 3px 0 0 var(--color-match); } + +.results-table__key { + color: var(--color-text-0); + font-weight: var(--weight-medium); +} + +.results-table__chevron { + color: var(--color-text-3); + text-align: right; + width: 40px; +} + +.results-table__row:hover .results-table__chevron { + color: var(--color-accent); +} + +.results-table__pager { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + border-top: 1px solid var(--color-border-0); + background: var(--color-surface-0); +} + +.results-table__empty { + display: flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + padding: var(--space-10); + color: var(--color-text-3); + font-size: var(--text-sm); +} + +/* ========================================================================== + Record drawer + ========================================================================== */ +.record-drawer { + position: fixed; + inset: 0; + z-index: var(--z-drawer); + display: flex; + justify-content: flex-end; +} + +.record-drawer__backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(2px); + animation: fade-in var(--dur-base) var(--ease-standard) both; +} + +.record-drawer__panel { + position: relative; + width: min(640px, 100%); + height: 100%; + background: var(--color-bg-1); + border-left: 1px solid var(--color-border-1); + display: flex; + flex-direction: column; + gap: var(--space-5); + padding: var(--space-6); + overflow-y: auto; + animation: slide-in var(--dur-slow) var(--ease-out) both; +} + +@keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes slide-in { + from { transform: translateX(16px); opacity: 0; } + to { transform: translateX(0); opacity: 1; } +} + +@media (prefers-reduced-motion: reduce) { + .record-drawer__backdrop, + .record-drawer__panel { animation: none; } +} + +.record-drawer__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); +} + +.record-drawer__head-main { + display: flex; + align-items: center; + gap: var(--space-3); + min-width: 0; +} + +.record-drawer__key { + font-size: var(--text-md); + font-weight: var(--weight-medium); + color: var(--color-text-0); +} + +.record-drawer__explain { + font-size: var(--text-sm); + color: var(--color-text-2); + padding: var(--space-3) var(--space-4); + background: var(--color-surface-0); + border: 1px solid var(--color-border-0); + border-radius: var(--radius-md); +} + +.record-drawer__grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-4); +} + +@media (max-width: 640px) { + .record-drawer__grid { grid-template-columns: 1fr; } + .record-drawer__panel { padding: var(--space-4); } +} + +.record-drawer__side { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.record-drawer__side-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--space-2); + padding-bottom: var(--space-2); + border-bottom: 1px solid var(--color-border-0); +} + +.record-drawer__side-title { + font-size: var(--text-sm); + font-weight: var(--weight-semi); + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--color-text-2); +} + +.record-drawer__fields { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.record-drawer__field { + display: grid; + grid-template-columns: 40% 1fr; + gap: var(--space-3); + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-sm); + background: var(--color-surface-0); + border: 1px solid transparent; +} + +.record-drawer__field[data-diff='true'] { + background: var(--tint-mismatch); + border-color: rgba(248, 113, 113, 0.30); +} + +.record-drawer__field-label { + font-size: var(--text-xs); + color: var(--color-text-3); + text-transform: uppercase; + letter-spacing: 0.06em; + font-weight: var(--weight-semi); + align-self: center; +} + +.record-drawer__field-value { + font-size: var(--text-sm); + color: var(--color-text-0); + word-break: break-word; +} + +.record-drawer__diffs { + display: flex; + flex-direction: column; + gap: var(--space-3); + padding-top: var(--space-4); + border-top: 1px solid var(--color-border-0); +} + +.record-drawer__diffs-title { + font-size: var(--text-sm); + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-text-3); +} + +.record-drawer__diffs-list { + list-style: none; + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.record-drawer__diff { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + background: var(--color-surface-0); + border: 1px solid var(--color-border-0); + border-radius: var(--radius-md); + flex-wrap: wrap; +} + +.record-drawer__diff-field { + font-size: var(--text-sm); + color: var(--color-text-2); + min-width: 80px; +} + +.record-drawer__diff-values { + display: inline-flex; + align-items: center; + gap: var(--space-2); + font-family: var(--font-mono); + font-size: var(--text-sm); +} + +.record-drawer__diff-a { color: var(--color-mismatch); } +.record-drawer__diff-b { color: var(--color-match); } +.record-drawer__diff-arrow { color: var(--color-text-3); } + +.record-drawer__diff-delta { + margin-left: auto; + font-size: var(--text-xs); + color: var(--color-text-3); +} + +/* ========================================================================== + Skip link + ========================================================================== */ +.skip-link { + position: absolute; + top: -100px; + left: var(--space-4); + padding: var(--space-2) var(--space-4); + background: var(--color-accent); + color: #0b0d10; + border-radius: var(--radius-md); + font-weight: var(--weight-semi); + text-decoration: none; + z-index: 1000; + transition: top var(--dur-base) var(--ease-standard); +} + +.skip-link:focus { + top: var(--space-4); +} + +/* ========================================================================== + Responsive refinements + ========================================================================== */ +@media (max-width: 860px) { + .landing__title { font-size: 1.75rem; } + .results-header { flex-direction: column; align-items: stretch; } + .results-header__actions { width: 100%; } + .results-header__actions .btn { flex: 1; } + .summary-grid { grid-template-columns: repeat(2, 1fr); } +} + +@media (max-width: 480px) { + .app-header__brand span:last-child { font-size: var(--text-sm); } + .summary-grid { grid-template-columns: 1fr; } +} \ No newline at end of file diff --git a/src/styles/tokens.css b/src/styles/tokens.css index e69de29..332894c 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -0,0 +1,164 @@ +/* ========================================================================== + Design Tokens — Two-Export Comparator + Dark-first, premium, calm. Graphite foundation, restrained accents. + ========================================================================== */ + +:root { + /* ---------- Color: Graphite foundation ---------- */ + --color-bg-0: #0b0d10; + --color-bg-1: #101317; + --color-bg-2: #161a20; + --color-bg-3: #1c2128; + --color-bg-4: #232a33; + + --color-surface-0: rgba(255, 255, 255, 0.02); + --color-surface-1: rgba(255, 255, 255, 0.04); + --color-surface-2: rgba(255, 255, 255, 0.06); + --color-surface-3: rgba(255, 255, 255, 0.09); + + --color-border-0: rgba(255, 255, 255, 0.06); + --color-border-1: rgba(255, 255, 255, 0.10); + --color-border-2: rgba(255, 255, 255, 0.16); + + /* ---------- Text ---------- */ + --color-text-0: #f4f6f8; + --color-text-1: #c8cdd4; + --color-text-2: #8b939e; + --color-text-3: #5c6470; + + /* ---------- Semantic accents ---------- */ + --color-match: #4ade80; + --color-mismatch: #f87171; + --color-only-a: #fbbf24; + --color-only-b: #fb923c; + --color-dup: #a78bfa; + --color-ambig: #facc15; + --color-info: #60a5fa; + + --color-accent: #6ee7a8; + --color-accent-hi: #8cf0bd; + + /* Soft tints for backgrounds */ + --tint-match: rgba(74, 222, 128, 0.10); + --tint-mismatch: rgba(248, 113, 113, 0.10); + --tint-only-a: rgba(251, 191, 36, 0.10); + --tint-only-b: rgba(251, 146, 60, 0.10); + --tint-dup: rgba(167, 139, 250, 0.10); + --tint-ambig: rgba(250, 204, 21, 0.10); + --tint-info: rgba(96, 165, 250, 0.10); + + /* ---------- Spacing scale (4px base) ---------- */ + --space-0: 0; + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-3: 0.75rem; + --space-4: 1rem; + --space-5: 1.25rem; + --space-6: 1.5rem; + --space-8: 2rem; + --space-10: 2.5rem; + --space-12: 3rem; + --space-16: 4rem; + --space-20: 5rem; + + /* ---------- Radius ---------- */ + --radius-xs: 4px; + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 14px; + --radius-xl: 20px; + --radius-pill: 999px; + + /* ---------- Typography ---------- */ + --font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, + "Helvetica Neue", Arial, "Noto Sans", sans-serif; + --font-mono: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", + Menlo, Consolas, monospace; + + --text-xs: 0.75rem; + --text-sm: 0.8125rem; + --text-base: 0.875rem; + --text-md: 0.9375rem; + --text-lg: 1.0625rem; + --text-xl: 1.25rem; + --text-2xl: 1.5rem; + --text-3xl: 1.875rem; + --text-4xl: 2.25rem; + + --weight-regular: 400; + --weight-medium: 500; + --weight-semi: 600; + --weight-bold: 700; + + --leading-tight: 1.2; + --leading-snug: 1.35; + --leading-base: 1.5; + --leading-loose: 1.65; + + --tracking-tight: -0.01em; + --tracking-normal: 0; + --tracking-wide: 0.02em; + + /* ---------- Shadows ---------- */ + --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.25); + --shadow-sm: 0 2px 6px rgba(0, 0, 0, 0.30); + --shadow-md: 0 8px 24px rgba(0, 0, 0, 0.35); + --shadow-lg: 0 20px 48px rgba(0, 0, 0, 0.45); + --shadow-glow-accent: 0 0 0 1px rgba(110, 231, 168, 0.25), + 0 8px 32px rgba(110, 231, 168, 0.12); + + /* ---------- Motion ---------- */ + --ease-standard: cubic-bezier(0.2, 0.8, 0.2, 1); + --ease-emphasis: cubic-bezier(0.2, 0.9, 0.1, 1.05); + --ease-out: cubic-bezier(0.16, 1, 0.3, 1); + + --dur-fast: 120ms; + --dur-base: 200ms; + --dur-slow: 360ms; + --dur-slower: 600ms; + + /* ---------- Layout ---------- */ + --container-max: 1200px; + --container-narrow: 880px; + --header-height: 60px; + + /* ---------- Z-index ---------- */ + --z-base: 1; + --z-dropdown: 100; + --z-drawer: 200; + --z-modal: 300; + --z-toast: 400; +} + +/* ========================================================================== + Light theme (optional — scoped) + ========================================================================== */ +[data-theme='light'] { + --color-bg-0: #fafbfc; + --color-bg-1: #ffffff; + --color-bg-2: #f4f6f8; + --color-bg-3: #eceff3; + --color-bg-4: #e2e6ec; + + --color-surface-0: rgba(15, 17, 21, 0.02); + --color-surface-1: rgba(15, 17, 21, 0.04); + --color-surface-2: rgba(15, 17, 21, 0.06); + --color-surface-3: rgba(15, 17, 21, 0.09); + + --color-border-0: rgba(15, 17, 21, 0.08); + --color-border-1: rgba(15, 17, 21, 0.12); + --color-border-2: rgba(15, 17, 21, 0.20); + + --color-text-0: #0f1115; + --color-text-1: #2d333c; + --color-text-2: #5c6470; + --color-text-3: #8b939e; + + --color-accent: #10a37f; + --color-accent-hi: #0d8a6a; + + --shadow-xs: 0 1px 2px rgba(15, 17, 21, 0.06); + --shadow-sm: 0 2px 6px rgba(15, 17, 21, 0.08); + --shadow-md: 0 8px 24px rgba(15, 17, 21, 0.10); + --shadow-lg: 0 20px 48px rgba(15, 17, 21, 0.14); +} \ No newline at end of file diff --git a/src/styles/utilities.css b/src/styles/utilities.css index e69de29..34b8b8c 100644 --- a/src/styles/utilities.css +++ b/src/styles/utilities.css @@ -0,0 +1,53 @@ +/* ========================================================================== + Utilities — small, composable helpers + ========================================================================== */ + +.u-flex { display: flex; } +.u-flex-col { display: flex; flex-direction: column; } +.u-items-center { align-items: center; } +.u-items-start { align-items: flex-start; } +.u-justify-between { justify-content: space-between; } +.u-justify-center { justify-content: center; } +.u-gap-1 { gap: var(--space-1); } +.u-gap-2 { gap: var(--space-2); } +.u-gap-3 { gap: var(--space-3); } +.u-gap-4 { gap: var(--space-4); } +.u-gap-6 { gap: var(--space-6); } + +.u-text-xs { font-size: var(--text-xs); } +.u-text-sm { font-size: var(--text-sm); } +.u-text-lg { font-size: var(--text-lg); } +.u-text-muted { color: var(--color-text-2); } +.u-text-faint { color: var(--color-text-3); } +.u-text-mono { font-family: var(--font-mono); } +.u-text-center { text-align: center; } + +.u-weight-medium { font-weight: var(--weight-medium); } +.u-weight-semi { font-weight: var(--weight-semi); } + +.u-mt-2 { margin-top: var(--space-2); } +.u-mt-4 { margin-top: var(--space-4); } +.u-mt-6 { margin-top: var(--space-6); } +.u-mb-2 { margin-bottom: var(--space-2); } +.u-mb-4 { margin-bottom: var(--space-4); } +.u-mb-6 { margin-bottom: var(--space-6); } + +.u-hidden { display: none !important; } +.u-truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.u-container { + width: 100%; + max-width: var(--container-max); + margin-inline: auto; + padding-inline: var(--space-6); +} + +@media (max-width: 640px) { + .u-container { + padding-inline: var(--space-4); + } +} \ No newline at end of file diff --git a/src/ui/components/file-panel.js b/src/ui/components/file-panel.js new file mode 100644 index 0000000..eb20b0b --- /dev/null +++ b/src/ui/components/file-panel.js @@ -0,0 +1,286 @@ +import { el, replaceChildren, icon, Icons } from '../renderers/dom.js'; +import { AppEvents } from '../../app/app-events.js'; +import { formatBytes, formatCount, orDash, delimiterLabel } from '../renderers/format.js'; + +/** + * FilePanel — renders a single file slot (A or B). + * Does NOT read or parse file contents itself. + * Emits FILE_A_SELECTED / FILE_B_SELECTED / FILE_A_REMOVED / FILE_B_REMOVED. + */ +export class FilePanel { + /** + * @param {{ slot: 'A' | 'B' }} config + */ + constructor({ slot }) { + this.slot = slot; + this.fileEvent = slot === 'A' ? AppEvents.FILE_A_SELECTED : AppEvents.FILE_B_SELECTED; + this.removeEvent = slot === 'A' ? AppEvents.FILE_A_REMOVED : AppEvents.FILE_B_REMOVED; + + this.root = el('section', { + class: 'file-panel', + dataset: { slot, state: 'empty' }, + 'aria-label': `File ${slot}`, + }); + + /** @type {import('../../models/contracts.js').FileInspection | null} */ + this.inspection = null; + + /** @type {'empty' | 'loading' | 'ready' | 'warning' | 'error'} */ + this.state = 'empty'; + + /** @type {string | null} */ + this.errorMessage = null; + + this.input = el('input', { + type: 'file', + accept: '.csv,.tsv,text/csv,text/tab-separated-values', + class: 'visually-hidden', + 'aria-label': `Choose File ${slot}`, + }); + this.input.addEventListener('change', () => { + const file = this.input.files?.[0]; + if (file) this._emitSelect(file); + this.input.value = ''; + }); + + this._render(); + } + + /** + * Update the panel with new inspection data. + * @param {import('../../models/contracts.js').FileInspection | null} inspection + */ + setInspection(inspection) { + this.inspection = inspection; + if (!inspection) { + this.state = 'empty'; + this.errorMessage = null; + } else if (inspection.warnings?.some((w) => w.severity === 'error')) { + this.state = 'error'; + this.errorMessage = inspection.warnings.find((w) => w.severity === 'error')?.title ?? 'Something went wrong'; + } else if (inspection.warnings?.length) { + this.state = 'warning'; + } else { + this.state = 'ready'; + } + this._render(); + } + + /** Show a loading state while the service inspects the file. */ + setLoading() { + this.state = 'loading'; + this._render(); + } + + /** + * Show an error state. + * @param {string} message + */ + setError(message) { + this.state = 'error'; + this.errorMessage = message; + this._render(); + } + + /** @private */ + _emitSelect(file) { + window.dispatchEvent(new CustomEvent(this.fileEvent, { detail: { file } })); + } + + /** @private */ + _emitRemove() { + window.dispatchEvent(new CustomEvent(this.removeEvent)); + } + + /** @private */ + _openPicker() { + this.input.click(); + } + + /** @private */ + _render() { + this.root.dataset.state = this.state; + replaceChildren(this.root, [ + this.input, + this._renderHeader(), + this._renderBody(), + ]); + } + + /** @private */ + _renderHeader() { + return el('header', { class: 'file-panel__header' }, [ + el('div', { class: 'file-panel__label' }, [ + el('span', { class: 'file-panel__slot', text: `FILE ${this.slot}` }), + this.inspection + ? el('span', { + class: 'file-panel__name u-truncate', + text: this.inspection.name, + title: this.inspection.name, + }) + : el('span', { class: 'file-panel__name file-panel__name--empty', text: 'No file selected' }), + ]), + this.inspection + ? el('button', { + type: 'button', + class: 'btn btn--ghost btn--icon', + 'aria-label': `Remove File ${this.slot}`, + title: 'Remove', + onclick: () => this._emitRemove(), + }, [icon(Icons.x, { size: 14 })]) + : null, + ]); + } + + /** @private */ + _renderBody() { + switch (this.state) { + case 'loading': + return this._renderLoading(); + case 'ready': + case 'warning': + return this._renderReady(); + case 'error': + return this._renderError(); + case 'empty': + default: + return this._renderDropzone(); + } + } + + /** @private */ + _renderDropzone() { + const zone = el('button', { + type: 'button', + class: 'file-panel__dropzone', + 'aria-label': `Choose File ${this.slot} — CSV or TSV`, + onclick: () => this._openPicker(), + }, [ + el('div', { class: 'file-panel__dropzone-icon' }, [icon(Icons.upload, { size: 22 })]), + el('div', { class: 'file-panel__dropzone-text' }, [ + el('span', { class: 'file-panel__dropzone-primary', text: 'Drop CSV/TSV or browse' }), + el('span', { class: 'file-panel__dropzone-secondary', text: 'Files stay in this browser' }), + ]), + ]); + + zone.addEventListener('dragover', (e) => { + e.preventDefault(); + zone.dataset.dragover = 'true'; + }); + zone.addEventListener('dragleave', () => { + delete zone.dataset.dragover; + }); + zone.addEventListener('drop', (e) => { + e.preventDefault(); + delete zone.dataset.dragover; + const file = e.dataTransfer?.files?.[0]; + if (file) this._emitSelect(file); + }); + + return zone; + } + + /** @private */ + _renderLoading() { + return el('div', { class: 'file-panel__body file-panel__body--loading' }, [ + el('div', { class: 'skeleton skeleton--line skeleton--w60' }), + el('div', { class: 'skeleton skeleton--line skeleton--w40' }), + el('div', { class: 'skeleton skeleton--line skeleton--w80' }), + el('div', { class: 'skeleton skeleton--block' }), + ]); + } + + /** @private */ + _renderReady() { + const i = this.inspection; + const children = []; + + children.push(el('dl', { class: 'file-panel__meta' }, [ + this._metaItem('Rows', formatCount(i.rowCount)), + this._metaItem('Columns', formatCount(i.columns.length)), + this._metaItem('Delimiter', delimiterLabel(i.delimiter)), + this._metaItem('Encoding', orDash(i.encoding)), + this._metaItem('Size', formatBytes(i.sizeBytes)), + ])); + + if (i.warnings?.length) { + children.push(el('div', { class: 'file-panel__warnings' }, + i.warnings.slice(0, 3).map((w) => + el('div', { + class: 'toast', + dataset: { severity: w.severity }, + role: w.severity === 'error' ? 'alert' : 'status', + }, [ + icon(Icons.alert, { size: 14 }), + el('div', {}, [ + el('div', { class: 'u-weight-semi', text: w.title }), + w.message ? el('div', { class: 'u-text-sm u-text-muted', text: w.message }) : null, + ]), + ]) + ) + )); + } + + if (i.previewRows?.length && i.columns?.length) { + children.push(this._renderPreview(i)); + } + + return el('div', { class: 'file-panel__body' }, children); + } + + /** @private */ + _metaItem(label, value) { + return el('div', { class: 'file-panel__meta-item' }, [ + el('dt', { class: 'file-panel__meta-label', text: label }), + el('dd', { class: 'file-panel__meta-value', text: value }), + ]); + } + + /** @private */ + _renderPreview(i) { + const cols = i.columns.slice(0, 5); + + const thead = el('thead', {}, [ + el('tr', {}, cols.map((c) => + el('th', { scope: 'col', text: c.label, title: c.label }) + )), + ]); + + const tbody = el('tbody', {}, i.previewRows.slice(0, 5).map((row) => + el('tr', {}, cols.map((c) => + el('td', { text: orDash(row[c.id]) }) + )) + )); + + return el('div', { class: 'file-panel__preview' }, [ + el('div', { class: 'file-panel__preview-label u-text-xs u-text-faint', text: 'PREVIEW' }), + el('div', { class: 'file-panel__preview-scroll' }, [ + el('table', { class: 'file-panel__preview-table' }, [thead, tbody]), + ]), + i.rowCount && i.rowCount > i.previewRows.length + ? el('div', { + class: 'file-panel__preview-foot u-text-xs u-text-faint', + text: `Showing ${Math.min(i.previewRows.length, 5)} of ${formatCount(i.rowCount)} rows`, + }) + : null, + ]); + } + + /** @private */ + _renderError() { + return el('div', { class: 'file-panel__body' }, [ + el('div', { class: 'toast', dataset: { severity: 'error' }, role: 'alert' }, [ + icon(Icons.alert, { size: 16 }), + el('div', {}, [ + el('div', { class: 'u-weight-semi', text: "We couldn't read that file" }), + el('div', { class: 'u-text-sm u-text-muted', text: this.errorMessage ?? 'Try a different CSV or TSV export.' }), + ]), + ]), + el('button', { + type: 'button', + class: 'btn btn--secondary btn--sm u-mt-2', + onclick: () => this._openPicker(), + }, [icon(Icons.refresh, { size: 14 }), 'Choose another file']), + ]); + } +} \ No newline at end of file diff --git a/src/ui/components/mapping-editor.js b/src/ui/components/mapping-editor.js new file mode 100644 index 0000000..62e5b3a --- /dev/null +++ b/src/ui/components/mapping-editor.js @@ -0,0 +1,259 @@ +import { el, replaceChildren, icon, Icons } from '../renderers/dom.js'; +import { MappingRow } from './mapping-row.js'; +import { AppEvents } from '../../app/app-events.js'; + +/** + * MappingEditor — key/compare/display mapping UX and validation. + * Suggests, but never commits mappings without user approval. + */ +export class MappingEditor { + /** + * @param {import('../../app/app-store.js').AppStore} store + * @param {import('../../services/comparison-service.js').ComparisonService} service + */ + constructor(store, service) { + this.store = store; + this.service = service; + + /** @type {import('../../models/contracts.js').MappingConfig} */ + this.mapping = this._emptyMapping(); + + this.root = el('section', { + class: 'mapping-workspace', + 'aria-label': 'Column mapping', + }); + + this._render(); + this.store.addEventListener('change', () => this._onStoreChange()); + this.store.addEventListener('state', () => this._onStoreChange()); + } + + _emptyMapping() { + return { + keys: [], + comparisons: [], + displayOnly: { fileA: [], fileB: [] }, + normalization: { + trimText: true, + caseInsensitive: true, + collapseWhitespace: true, + numberLocale: 'auto', + dateFormatA: null, + dateFormatB: null, + }, + }; + } + + async _onStoreChange() { + if (this.store.fileA && this.store.fileB && !this._suggestionsFetched) { + this._suggestionsFetched = true; + try { + const suggestions = await this.service.suggestMappings(this.store.fileA, this.store.fileB); + if (suggestions?.length) { + this.mapping.keys = suggestions + .filter((_, i) => i === 0) + .map((s) => ({ columnA: s.columnA, columnB: s.columnB, type: 'text' })); + this.mapping.comparisons = suggestions + .slice(1) + .map((s) => ({ + columnA: s.columnA, + columnB: s.columnB, + type: this._guessType(s.columnA, s.columnB), + tolerance: { mode: 'absolute', value: 0.01 }, + })); + } + } catch { + // Suggestions are optional; never block the UI. + } + this._render(); + this._emitUpdate(); + } + if (!this.store.fileA || !this.store.fileB) { + this._suggestionsFetched = false; + this.mapping = this._emptyMapping(); + this._render(); + } + } + + _guessType(colA, colB) { + const a = this.store.fileA?.columns.find((c) => c.id === colA)?.inferredType; + const b = this.store.fileB?.columns.find((c) => c.id === colB)?.inferredType; + if (a === 'number' || b === 'number') return 'number'; + if (a === 'date' || b === 'date') return 'date'; + return 'text'; + } + + _emitUpdate() { + this.store.patch({ mapping: this.mapping }); + this._validate(); + window.dispatchEvent(new CustomEvent(AppEvents.MAPPING_UPDATED, { detail: this.mapping })); + } + + _emitNormalization() { + this.store.patch({ mapping: this.mapping }); + window.dispatchEvent(new CustomEvent(AppEvents.NORMALIZATION_UPDATED, { detail: this.mapping.normalization })); + } + + _addRow(role) { + const item = { columnA: null, columnB: null, type: 'text' }; + if (role === 'key') this.mapping.keys.push(item); + else if (role === 'compare') this.mapping.comparisons.push({ ...item, tolerance: { mode: 'absolute', value: 0.01 } }); + this._emitUpdate(); + this._render(); + } + + _removeRow(role, index) { + if (role === 'key') this.mapping.keys.splice(index, 1); + else this.mapping.comparisons.splice(index, 1); + this._emitUpdate(); + this._render(); + } + + _validate() { + const validKeys = this.mapping.keys.filter((k) => k.columnA && k.columnB); + const valid = validKeys.length > 0 && + this.mapping.keys.every((k) => k.columnA && k.columnB); + this.store.patch({ mappingValid: valid }); + if (valid && this.store.state === 'MAPPING') { + this.store.setState('READY_TO_COMPARE'); + } else if (!valid && this.store.state === 'READY_TO_COMPARE') { + this.store.setState('MAPPING'); + } + } + + _render() { + const columnsA = this.store.fileA?.columns ?? []; + const columnsB = this.store.fileB?.columns ?? []; + + replaceChildren(this.root, [ + el('header', { class: 'mapping-workspace__header' }, [ + el('div', {}, [ + el('h2', { class: 'mapping-workspace__title', text: 'Match the columns' }), + el('p', { + class: 'mapping-workspace__hint', + text: 'Define which columns identify a record and which values to compare. Suggestions are proposals — review before running.', + }), + ]), + ]), + + this._renderColumnHeaders(), + + el('div', { class: 'mapping-workspace__group' }, [ + el('div', { class: 'mapping-workspace__group-head' }, [ + el('h3', { class: 'mapping-workspace__group-title', text: 'Match keys' }), + el('span', { class: 'mapping-workspace__group-hint u-text-xs u-text-muted', text: 'Required · identifies records across both files' }), + el('button', { + type: 'button', + class: 'btn btn--ghost btn--sm', + onclick: () => this._addRow('key'), + }, ['+ Add key']), + ]), + this.mapping.keys.length === 0 + ? el('div', { class: 'mapping-workspace__empty', text: 'No match keys yet. Add at least one.' }) + : el('div', { class: 'mapping-workspace__rows' }, + this.mapping.keys.map((k, i) => + new MappingRow({ + role: 'key', + columnsA, columnsB, + value: k, + onChange: (v) => { this.mapping.keys[i] = v; this._emitUpdate(); }, + onRemove: () => this._removeRow('key', i), + }).root + ) + ), + ]), + + el('div', { class: 'mapping-workspace__group' }, [ + el('div', { class: 'mapping-workspace__group-head' }, [ + el('h3', { class: 'mapping-workspace__group-title', text: 'Compare fields' }), + el('span', { class: 'mapping-workspace__group-hint u-text-xs u-text-muted', text: 'Optional · values that must match' }), + el('button', { + type: 'button', + class: 'btn btn--ghost btn--sm', + onclick: () => this._addRow('compare'), + }, ['+ Add compare']), + ]), + this.mapping.comparisons.length === 0 + ? el('div', { class: 'mapping-workspace__empty', text: 'No compare fields yet.' }) + : el('div', { class: 'mapping-workspace__rows' }, + this.mapping.comparisons.map((c, i) => + new MappingRow({ + role: 'compare', + columnsA, columnsB, + value: c, + onChange: (v) => { this.mapping.comparisons[i] = v; this._emitUpdate(); }, + onRemove: () => this._removeRow('compare', i), + }).root + ) + ), + ]), + + this._renderNormalization(), + this._renderActions(), + ]); + } + + _renderColumnHeaders() { + return el('div', { class: 'mapping-workspace__col-heads' }, [ + el('div', { class: 'mapping-workspace__col-head' }, [ + el('span', { class: 'mapping-workspace__col-label', text: 'FILE A' }), + el('span', { class: 'mapping-workspace__col-name u-truncate', text: this.store.fileA?.name ?? '—' }), + ]), + el('div', { class: 'mapping-workspace__col-head mapping-workspace__col-head--spacer', 'aria-hidden': 'true' }), + el('div', { class: 'mapping-workspace__col-head' }, [ + el('span', { class: 'mapping-workspace__col-label', text: 'FILE B' }), + el('span', { class: 'mapping-workspace__col-name u-truncate', text: this.store.fileB?.name ?? '—' }), + ]), + ]); + } + + _renderNormalization() { + const n = this.mapping.normalization; + const toggle = (label, key) => + el('label', { class: 'toggle' }, [ + el('input', { + type: 'checkbox', + checked: n[key] ? 'checked' : null, + onchange: (e) => { + this.mapping.normalization[key] = e.target.checked; + this._emitNormalization(); + }, + }), + el('span', { class: 'toggle__track' }, [el('span', { class: 'toggle__thumb' })]), + el('span', { class: 'toggle__label', text: label }), + ]); + + return el('details', { class: 'mapping-workspace__group mapping-workspace__group--collapsible' }, [ + el('summary', { class: 'mapping-workspace__group-head mapping-workspace__summary' }, [ + el('h3', { class: 'mapping-workspace__group-title', text: 'Normalization' }), + el('span', { class: 'mapping-workspace__group-hint u-text-xs u-text-muted', text: 'How values are treated before matching' }), + ]), + el('div', { class: 'mapping-workspace__normalization' }, [ + toggle('Trim whitespace', 'trimText'), + toggle('Ignore case', 'caseInsensitive'), + toggle('Collapse internal whitespace', 'collapseWhitespace'), + ]), + ]); + } + + _renderActions() { + const disabled = !this.store.mappingValid; + return el('div', { class: 'mapping-workspace__actions' }, [ + el('button', { + type: 'button', + class: 'btn btn--ghost', + onclick: () => window.dispatchEvent(new CustomEvent(AppEvents.RESET_REQUESTED)), + }, [icon(Icons.refresh, { size: 14 }), 'Start over']), + el('button', { + type: 'button', + class: 'btn btn--primary btn--lg', + disabled: disabled ? 'true' : null, + 'aria-disabled': disabled ? 'true' : null, + onclick: () => { + if (disabled) return; + window.dispatchEvent(new CustomEvent(AppEvents.COMPARE_REQUESTED, { detail: { mapping: this.mapping } })); + }, + }, [icon(Icons.zap, { size: 16 }), 'Compare files']), + ]); + } +} \ No newline at end of file diff --git a/src/ui/components/mapping-row.js b/src/ui/components/mapping-row.js new file mode 100644 index 0000000..d8d3f61 --- /dev/null +++ b/src/ui/components/mapping-row.js @@ -0,0 +1,137 @@ +import { el, icon, Icons } from '../renderers/dom.js'; + +/** + * MappingRow — one row in the mapping workspace. + * Visual: source column → target column, with role selector. + * Drag & drop is a convenience; the select is the accessible path. + */ +export class MappingRow { + /** + * @param {{ + * role: 'key' | 'compare' | 'display' | 'ignore', + * columnsA: import('../../models/contracts.js').ColumnInfo[], + * columnsB: import('../../models/contracts.js').ColumnInfo[], + * value: { columnA: string | null, columnB: string | null, type?: string, tolerance?: { mode: string, value: number } }, + * onChange: (value: object) => void, + * onRemove: () => void, + * }} config + */ + constructor(config) { + this.config = config; + this.value = { ...config.value }; + this.root = el('div', { + class: 'mapping-row', + dataset: { role: config.role }, + }); + this._render(); + } + + update(value) { + this.value = { ...this.value, ...value }; + this._render(); + } + + _render() { + const { role, columnsA, columnsB } = this.config; + + const roleBadge = el('div', { class: 'mapping-row__role' }, [ + el('span', { + class: `mapping-row__role-chip mapping-row__role-chip--${role}`, + text: this._roleLabel(role), + }), + ]); + + const selectA = this._buildSelect(columnsA, this.value.columnA, 'File A column', (v) => { + this.value.columnA = v; + this.config.onChange({ ...this.value }); + }); + + const selectB = this._buildSelect(columnsB, this.value.columnB, 'File B column', (v) => { + this.value.columnB = v; + this.config.onChange({ ...this.value }); + }); + + const arrow = el('div', { class: 'mapping-row__arrow', 'aria-hidden': 'true' }, [ + icon(Icons.arrowRight, { size: 14 }), + ]); + + const extras = []; + if (role === 'compare' && this.value.type === 'number') { + extras.push(this._renderTolerance()); + } + + const removeBtn = el('button', { + type: 'button', + class: 'btn btn--ghost btn--icon btn--sm mapping-row__remove', + 'aria-label': 'Remove mapping', + title: 'Remove', + onclick: () => this.config.onRemove(), + }, [icon(Icons.x, { size: 12 })]); + + const body = el('div', { class: 'mapping-row__body' }, [ + el('div', { class: 'mapping-row__cell mapping-row__cell--a' }, [selectA]), + arrow, + el('div', { class: 'mapping-row__cell mapping-row__cell--b' }, [selectB]), + ...extras, + removeBtn, + ]); + + this.root.replaceChildren(roleBadge, body); + } + + _roleLabel(role) { + if (role === 'key') return 'MATCH KEY'; + if (role === 'compare') return 'COMPARE'; + if (role === 'display') return 'DISPLAY'; + return 'IGNORE'; + } + + _buildSelect(columns, value, label, onChange) { + const select = el('select', { + class: 'select', + 'aria-label': label, + onchange: (e) => onChange(e.target.value || null), + }); + select.appendChild(el('option', { value: '', text: '— select —' })); + for (const col of columns) { + const opt = el('option', { value: col.id, text: col.label }); + if (col.id === value) opt.selected = true; + select.appendChild(opt); + } + return select; + } + + _renderTolerance() { + const tol = this.value.tolerance ?? { mode: 'absolute', value: 0.01 }; + const modeSelect = el('select', { + class: 'select select--sm', + 'aria-label': 'Tolerance mode', + onchange: (e) => { + this.value.tolerance = { ...tol, mode: e.target.value }; + this.config.onChange({ ...this.value }); + }, + }, [ + this._opt('absolute', '±', tol.mode === 'absolute'), + this._opt('percentage', '%', tol.mode === 'percentage'), + ]); + const valueInput = el('input', { + type: 'number', + class: 'input input--sm', + value: String(tol.value), + step: '0.01', + min: '0', + 'aria-label': 'Tolerance value', + oninput: (e) => { + this.value.tolerance = { ...tol, value: Number(e.target.value) }; + this.config.onChange({ ...this.value }); + }, + }); + return el('div', { class: 'mapping-row__tolerance' }, [modeSelect, valueInput]); + } + + _opt(value, label, selected) { + const o = el('option', { value, text: label }); + if (selected) o.selected = true; + return o; + } +} \ No newline at end of file diff --git a/src/ui/components/privacy-note.js b/src/ui/components/privacy-note.js new file mode 100644 index 0000000..85747fc --- /dev/null +++ b/src/ui/components/privacy-note.js @@ -0,0 +1,13 @@ +import { el, icon, Icons } from '../renderers/dom.js'; + +/** + * PrivacyNote — accurately states local processing. No legal guarantees. + */ +export function PrivacyNote() { + return el('div', { class: 'privacy-note', role: 'note' }, [ + icon(Icons.shield, { size: 14 }), + el('span', { + text: 'Your files are processed locally in this browser and are not uploaded by this application.', + }), + ]); +} \ No newline at end of file diff --git a/src/ui/components/record-detail-drawer.js b/src/ui/components/record-detail-drawer.js new file mode 100644 index 0000000..c5d3036 --- /dev/null +++ b/src/ui/components/record-detail-drawer.js @@ -0,0 +1,148 @@ +import { el, replaceChildren, icon, Icons } from '../renderers/dom.js'; +import { orDash } from '../renderers/format.js'; +import { AppEvents } from '../../app/app-events.js'; + +const STATUS_LABEL = { + MATCHED: 'Matched', + ONLY_A: 'Only A', + ONLY_B: 'Only B', + MISMATCH: 'Mismatch', + DUPLICATE: 'Duplicate', + AMBIGUOUS: 'Ambiguous', +}; + +const STATUS_EXPLAIN = { + MATCHED: 'Key exists in both files and all compared values pass.', + ONLY_A: 'Record exists only in File A.', + ONLY_B: 'Record exists only in File B.', + MISMATCH: 'Key matched, but one or more compared fields differ.', + DUPLICATE: 'The same normalized key occurs multiple times.', + AMBIGUOUS: 'The engine cannot determine a unique pairing safely. No guess was made.', +}; + +/** + * RecordDetailDrawer — side-by-side inspection. Never mutates source rows. + */ +export class RecordDetailDrawer { + /** @param {import('../../app/app-store.js').AppStore} store */ + constructor(store) { + this.store = store; + this.root = el('aside', { + class: 'record-drawer', + role: 'dialog', + 'aria-modal': 'true', + 'aria-label': 'Record detail', + hidden: '', + }); + store.addEventListener('change', () => this._sync()); + store.addEventListener('state', () => this._sync()); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && this.store.openRecordId) this._close(); + }); + this._sync(); + } + + _close() { + this.store.patch({ openRecordId: null }); + window.dispatchEvent(new CustomEvent(AppEvents.RESULT_RECORD_CLOSED)); + } + + _sync() { + const id = this.store.openRecordId; + if (!id || this.store.state !== 'RESULTS') { + this.root.hidden = true; + replaceChildren(this.root, []); + return; + } + const rec = this.store.result?.records.find((r) => r.id === id); + if (!rec) { + this.root.hidden = true; + return; + } + this.root.hidden = false; + this._render(rec); + } + + _render(rec) { + const backdrop = el('div', { + class: 'record-drawer__backdrop', + onclick: () => this._close(), + }); + + const panel = el('div', { class: 'record-drawer__panel' }, [ + this._renderHead(rec), + this._renderExplain(rec), + this._renderSideBySide(rec), + rec.differences?.length ? this._renderDifferences(rec) : null, + ]); + + replaceChildren(this.root, [backdrop, panel]); + } + + _renderHead(rec) { + return el('header', { class: 'record-drawer__head' }, [ + el('div', { class: 'record-drawer__head-main' }, [ + el('span', { class: 'badge', dataset: { status: rec.status }, text: STATUS_LABEL[rec.status] ?? rec.status }), + el('span', { class: 'record-drawer__key u-text-mono', text: rec.keyLabel }), + ]), + el('button', { + type: 'button', + class: 'btn btn--ghost btn--icon', + 'aria-label': 'Close', + onclick: () => this._close(), + }, [icon(Icons.x, { size: 16 })]), + ]); + } + + _renderExplain(rec) { + return el('p', { class: 'record-drawer__explain', text: STATUS_EXPLAIN[rec.status] ?? '' }); + } + + _renderSideBySide(rec) { + const fields = new Set([ + ...Object.keys(rec.displayA ?? {}), + ...Object.keys(rec.displayB ?? {}), + ]); + return el('div', { class: 'record-drawer__grid' }, [ + this._renderSide('File A', rec.source?.rowA, rec.displayA ?? {}, fields, rec.differences), + this._renderSide('File B', rec.source?.rowB, rec.displayB ?? {}, fields, rec.differences), + ]); + } + + _renderSide(title, rowNum, data, fields, differences) { + const diffFields = new Set((differences ?? []).map((d) => d.field)); + return el('div', { class: 'record-drawer__side' }, [ + el('div', { class: 'record-drawer__side-head' }, [ + el('span', { class: 'record-drawer__side-title', text: title }), + el('span', { class: 'record-drawer__side-row u-text-mono u-text-muted', text: rowNum != null ? `row ${rowNum}` : '—' }), + ]), + el('dl', { class: 'record-drawer__fields' }, [...fields].map((f) => { + const isDiff = diffFields.has(f.toLowerCase()); + return el('div', { + class: 'record-drawer__field', + dataset: { diff: isDiff ? 'true' : 'false' }, + }, [ + el('dt', { class: 'record-drawer__field-label', text: f }), + el('dd', { class: 'record-drawer__field-value u-text-mono', text: orDash(data[f]) }), + ]); + })), + ]); + } + + _renderDifferences(rec) { + return el('div', { class: 'record-drawer__diffs' }, [ + el('h4', { class: 'record-drawer__diffs-title', text: 'Differences' }), + el('ul', { class: 'record-drawer__diffs-list' }, rec.differences.map((d) => + el('li', { class: 'record-drawer__diff' }, [ + el('span', { class: 'record-drawer__diff-field u-text-mono', text: d.field }), + el('span', { class: 'record-drawer__diff-values' }, [ + el('span', { class: 'record-drawer__diff-a', text: String(d.valueA ?? '—') }), + el('span', { class: 'record-drawer__diff-arrow', text: '→' }), + el('span', { class: 'record-drawer__diff-b', text: String(d.valueB ?? '—') }), + ]), + d.delta != null ? el('span', { class: 'record-drawer__diff-delta u-text-mono', text: `Δ ${d.delta}` }) : null, + ]) + )), + ]); + } +} \ No newline at end of file diff --git a/src/ui/components/results-table.js b/src/ui/components/results-table.js new file mode 100644 index 0000000..772483d --- /dev/null +++ b/src/ui/components/results-table.js @@ -0,0 +1,130 @@ +import { el, replaceChildren, icon, Icons } from '../renderers/dom.js'; +import { formatCount } from '../renderers/format.js'; +import { AppEvents } from '../../app/app-events.js'; + +const STATUS_LABEL = { + MATCHED: 'Matched', + ONLY_A: 'Only A', + ONLY_B: 'Only B', + MISMATCH: 'Mismatch', + DUPLICATE: 'Duplicate', + AMBIGUOUS: 'Ambiguous', +}; + +const PAGE_SIZE = 50; + +/** + * ResultsTable — paginated result presentation. Never renders all rows. + * Holds no authoritative dataset; it reads from AppStore. + */ +export class ResultsTable { + /** @param {import('../../app/app-store.js').AppStore} store */ + constructor(store) { + this.store = store; + this.page = 0; + this.root = el('div', { class: 'results-table' }); + store.addEventListener('change', () => this._render()); + store.addEventListener('state', () => this._render()); + this._render(); + } + + _filtered() { + const all = this.store.result?.records ?? []; + const f = this.store.resultFilter; + return f === 'ALL' ? all : all.filter((r) => r.status === f); + } + + _render() { + if (this.store.state !== 'RESULTS' || !this.store.result) { + replaceChildren(this.root, []); + return; + } + + const rows = this._filtered(); + const totalPages = Math.max(1, Math.ceil(rows.length / PAGE_SIZE)); + if (this.page >= totalPages) this.page = totalPages - 1; + const slice = rows.slice(this.page * PAGE_SIZE, this.page * PAGE_SIZE + PAGE_SIZE); + + if (rows.length === 0) { + replaceChildren(this.root, [ + el('div', { class: 'results-table__empty' }, [ + icon(Icons.check, { size: 20 }), + el('span', { text: 'Nothing in this category.' }), + ]), + ]); + return; + } + + const table = el('table', { class: 'results-table__table' }, [ + el('thead', {}, [ + el('tr', {}, [ + el('th', { scope: 'col', text: 'Status' }), + el('th', { scope: 'col', text: 'Key' }), + el('th', { scope: 'col', class: 'u-text-mono', text: 'Row A' }), + el('th', { scope: 'col', class: 'u-text-mono', text: 'Row B' }), + el('th', { scope: 'col', text: '' }), + ]), + ]), + el('tbody', {}, slice.map((rec) => this._renderRow(rec))), + ]); + + replaceChildren(this.root, [ + el('div', { class: 'results-table__scroll' }, [table]), + this._renderPager(rows.length, totalPages), + ]); + } + + _renderRow(rec) { + return el('tr', { + class: 'results-table__row', + tabindex: '0', + dataset: { status: rec.status }, + role: 'button', + 'aria-label': `Open record ${rec.keyLabel}`, + onclick: () => this._open(rec.id), + onkeydown: (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + this._open(rec.id); + } + }, + }, [ + el('td', {}, [ + el('span', { class: 'badge', dataset: { status: rec.status }, text: STATUS_LABEL[rec.status] ?? rec.status }), + ]), + el('td', { class: 'u-text-mono results-table__key', text: rec.keyLabel }), + el('td', { class: 'u-text-mono u-text-muted', text: rec.source?.rowA ?? '—' }), + el('td', { class: 'u-text-mono u-text-muted', text: rec.source?.rowB ?? '—' }), + el('td', { class: 'results-table__chevron' }, [icon(Icons.arrowRight, { size: 14 })]), + ]); + } + + _open(id) { + this.store.patch({ openRecordId: id }); + window.dispatchEvent(new CustomEvent(AppEvents.RESULT_RECORD_OPENED, { detail: { id } })); + } + + _renderPager(total, totalPages) { + if (totalPages <= 1) { + return el('div', { class: 'results-table__pager' }, [ + el('span', { class: 'u-text-sm u-text-muted', text: `${formatCount(total)} records` }), + ]); + } + const prev = el('button', { + type: 'button', + class: 'btn btn--ghost btn--sm', + disabled: this.page === 0 ? 'true' : null, + onclick: () => { this.page = Math.max(0, this.page - 1); this._render(); }, + }, ['← Prev']); + const next = el('button', { + type: 'button', + class: 'btn btn--ghost btn--sm', + disabled: this.page >= totalPages - 1 ? 'true' : null, + onclick: () => { this.page = Math.min(totalPages - 1, this.page + 1); this._render(); }, + }, ['Next →']); + return el('div', { class: 'results-table__pager' }, [ + el('span', { class: 'u-text-sm u-text-muted', text: `${formatCount(total)} records · page ${this.page + 1} of ${totalPages}` }), + el('div', { class: 'u-flex u-gap-2' }, [prev, next]), + ]); + } +} \ No newline at end of file diff --git a/src/ui/components/skip-link.js b/src/ui/components/skip-link.js new file mode 100644 index 0000000..a2b9fd5 --- /dev/null +++ b/src/ui/components/skip-link.js @@ -0,0 +1,9 @@ +import { el } from '../renderers/dom.js'; + +export function SkipLink() { + return el('a', { + href: '#main', + class: 'skip-link', + text: 'Skip to content', + }); +} \ No newline at end of file diff --git a/src/ui/components/summary-card.js b/src/ui/components/summary-card.js new file mode 100644 index 0000000..2ae65cc --- /dev/null +++ b/src/ui/components/summary-card.js @@ -0,0 +1,31 @@ +import { el } from '../renderers/dom.js'; +import { formatCount } from '../renderers/format.js'; + +/** + * SummaryCard — one status card. Acts as a filter toggle. + */ +export class SummaryCard { + /** + * @param {{ + * status: import('../../models/contracts.js').ResultStatus | 'ALL', + * label: string, + * count: number, + * active: boolean, + * onClick: () => void, + * }} config + */ + constructor(config) { + this.config = config; + this.root = el('button', { + type: 'button', + class: 'summary-card', + dataset: { status: config.status, active: config.active ? 'true' : 'false' }, + 'aria-pressed': config.active ? 'true' : 'false', + onclick: () => config.onClick(), + }, [ + el('span', { class: 'summary-card__count', text: formatCount(config.count) }), + el('span', { class: 'summary-card__label', text: config.label }), + el('span', { class: 'summary-card__bar', 'aria-hidden': 'true' }), + ]); + } +} \ No newline at end of file diff --git a/src/ui/components/toast-host.js b/src/ui/components/toast-host.js new file mode 100644 index 0000000..632ff37 --- /dev/null +++ b/src/ui/components/toast-host.js @@ -0,0 +1,49 @@ +import { el, replaceChildren, icon, Icons } from '../renderers/dom.js'; +import { AppEvents } from '../../app/app-events.js'; + +/** + * ToastHost — renders transient messages from AppStore.toasts. + */ +export class ToastHost { + /** @param {import('../../app/app-store.js').AppStore} store */ + constructor(store) { + this.store = store; + this.root = el('div', { + class: 'toast-host', + role: 'region', + 'aria-label': 'Notifications', + }); + + store.addEventListener('change', () => this._render()); + store.addEventListener(AppEvents.TOAST_SHOWN, () => this._render()); + store.addEventListener(AppEvents.TOAST_DISMISSED, () => this._render()); + + this._render(); + } + + /** @private */ + _render() { + replaceChildren( + this.root, + this.store.toasts.map((t) => + el('div', { + class: 'toast toast--floating', + dataset: { severity: t.severity }, + role: t.severity === 'error' ? 'alert' : 'status', + }, [ + icon(t.severity === 'error' ? Icons.alert : Icons.check, { size: 14 }), + el('div', { class: 'toast__body' }, [ + el('div', { class: 'u-weight-semi', text: t.title }), + t.message ? el('div', { class: 'u-text-sm u-text-muted', text: t.message }) : null, + ]), + el('button', { + type: 'button', + class: 'btn btn--ghost btn--icon btn--sm', + 'aria-label': 'Dismiss', + onclick: () => this.store.dismissToast(t.id), + }, [icon(Icons.x, { size: 12 })]), + ]) + ) + ); + } +} \ No newline at end of file diff --git a/src/ui/renderers/dom.js b/src/ui/renderers/dom.js new file mode 100644 index 0000000..e9d6da5 --- /dev/null +++ b/src/ui/renderers/dom.js @@ -0,0 +1,106 @@ +/** + * @file Safe DOM helpers. All imported cell values MUST go through here. + * Rule: never assign untrusted content to innerHTML. + */ + +/** + * Create an element with attributes and children. + * @param {string} tag + * @param {Record} [attrs] + * @param {Array} [children] + * @returns {HTMLElement} + */ +export function el(tag, attrs = {}, children = []) { + const node = document.createElement(tag); + + for (const [key, value] of Object.entries(attrs)) { + if (value === null || value === undefined || value === false) continue; + + if (key === 'class') { + node.className = String(value); + } else if (key === 'text') { + // SAFE: only textContent, never innerHTML + node.textContent = String(value); + } else if (key === 'dataset' && typeof value === 'object') { + for (const [dk, dv] of Object.entries(value)) { + if (dv !== null && dv !== undefined) node.dataset[dk] = String(dv); + } + } else if (key.startsWith('on') && typeof value === 'function') { + node.addEventListener(key.slice(2).toLowerCase(), value); + } else if (key === 'html') { + // Explicitly forbidden — throw early to catch mistakes in dev + throw new Error('el(): use "text", not "html". Untrusted content must never be HTML.'); + } else { + node.setAttribute(key, String(value)); + } + } + + for (const child of children) { + if (child === null || child === undefined || child === false) continue; + if (typeof child === 'string' || typeof child === 'number') { + node.appendChild(document.createTextNode(String(child))); + } else if (child instanceof Node) { + node.appendChild(child); + } + } + + return node; +} + +/** + * Replace all children of a container safely. + * @param {HTMLElement} container + * @param {Array} children + */ +export function replaceChildren(container, children) { + container.replaceChildren(); + for (const child of children) { + if (child === null || child === undefined || child === false) continue; + if (typeof child === 'string' || typeof child === 'number') { + container.appendChild(document.createTextNode(String(child))); + } else if (child instanceof Node) { + container.appendChild(child); + } + } +} + +/** + * Inline SVG icon factory. No external network dependency. + * @param {string} pathData + * @param {{ size?: number, className?: string }} [options] + * @returns {SVGElement} + */ +export function icon(pathData, { size = 16, className = '' } = {}) { + const SVG_NS = 'http://www.w3.org/2000/svg'; + const svg = document.createElementNS(SVG_NS, 'svg'); + svg.setAttribute('viewBox', '0 0 24 24'); + svg.setAttribute('width', String(size)); + svg.setAttribute('height', String(size)); + svg.setAttribute('fill', 'none'); + svg.setAttribute('stroke', 'currentColor'); + svg.setAttribute('stroke-width', '1.75'); + svg.setAttribute('stroke-linecap', 'round'); + svg.setAttribute('stroke-linejoin', 'round'); + svg.setAttribute('aria-hidden', 'true'); + if (className) svg.setAttribute('class', className); + + const path = document.createElementNS(SVG_NS, 'path'); + path.setAttribute('d', pathData); + svg.appendChild(path); + + return svg; +} + +/** @type {Record} */ +export const Icons = Object.freeze({ + upload: 'M12 3v12m0 0l-4-4m4 4l4-4M4 17v2a2 2 0 002 2h12a2 2 0 002-2v-2', + file: 'M14 3v5h5M14 3H7a2 2 0 00-2 2v14a2 2 0 002 2h10a2 2 0 002-2V8l-5-5z', + x: 'M6 6l12 12M18 6L6 18', + check: 'M4 12l5 5L20 6', + alert: 'M12 9v4m0 4h.01M10.29 3.86l-8.02 13.9a1.5 1.5 0 001.3 2.24h16.06a1.5 1.5 0 001.3-2.24l-8.02-13.9a1.5 1.5 0 00-2.6 0z', + shield: 'M12 2l8 4v6c0 5-3.5 9-8 10-4.5-1-8-5-8-10V6l8-4z', + arrowRight: 'M5 12h14m0 0l-6-6m6 6l-6 6', + refresh: 'M4 4v6h6M20 20v-6h-6M4 10a8 8 0 0114-4m2 8a8 8 0 01-14 4', + table: 'M3 3h18v18H3zM3 9h18M3 15h18M9 3v18M15 3v18', + zap: 'M13 2L3 14h8l-1 8 10-12h-8l1-8z', +}); \ No newline at end of file diff --git a/src/ui/renderers/format.js b/src/ui/renderers/format.js new file mode 100644 index 0000000..1178acd --- /dev/null +++ b/src/ui/renderers/format.js @@ -0,0 +1,46 @@ +/** + * @file Formatting utilities for displaying metadata. + */ + +/** + * @param {number} bytes + * @returns {string} + */ +export function formatBytes(bytes) { + if (!Number.isFinite(bytes) || bytes < 0) return '—'; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; +} + +/** + * @param {number | null | undefined} n + * @returns {string} + */ +export function formatCount(n) { + if (n === null || n === undefined || !Number.isFinite(n)) return '—'; + return new Intl.NumberFormat('en-US').format(n); +} + +/** + * @param {string | null | undefined} s + * @returns {string} + */ +export function orDash(s) { + return s === null || s === undefined || s === '' ? '—' : String(s); +} + +/** + * Human-readable delimiter label. + * @param {string | null | undefined} d + * @returns {string} + */ +export function delimiterLabel(d) { + if (!d) return '—'; + if (d === ',') return 'comma'; + if (d === '\t') return 'tab'; + if (d === ';') return 'semicolon'; + if (d === '|') return 'pipe'; + return `"${d}"`; +} \ No newline at end of file diff --git a/src/ui/views/comparing-view.js b/src/ui/views/comparing-view.js new file mode 100644 index 0000000..5b631e2 --- /dev/null +++ b/src/ui/views/comparing-view.js @@ -0,0 +1,71 @@ +import { el, replaceChildren, icon, Icons } from '../renderers/dom.js'; + +export class ComparingView { + /** @param {import('../../app/app-store.js').AppStore} store */ + constructor(store) { + this.store = store; + this.root = el('main', { class: 'app-main', id: 'main', hidden: '' }); + store.addEventListener('state', () => this._sync()); + store.addEventListener('change', () => this._sync()); + this._sync(); + } + + _sync() { + const active = this.store.state === 'COMPARING'; + this.root.hidden = !active; + if (!active) return; + this._render(); + } + + _render() { + const p = this.store.progress; + const determinate = p && Number.isFinite(p.completed) && Number.isFinite(p.total) && p.total > 0; + const pct = determinate ? Math.min(100, Math.round((p.completed / p.total) * 100)) : null; + + const phases = [ + { id: 'parse-a', label: 'Parse File A' }, + { id: 'parse-b', label: 'Parse File B' }, + { id: 'index', label: 'Index records' }, + { id: 'compare', label: 'Compare' }, + { id: 'finalize', label: 'Finalize' }, + ]; + const currentIdx = p ? phases.findIndex((x) => x.id === p.phase) : -1; + + replaceChildren(this.root, [ + el('div', { class: 'u-container' }, [ + el('section', { class: 'comparing' }, [ + el('div', { class: 'comparing__spinner', 'aria-hidden': 'true' }), + el('h2', { class: 'comparing__title', text: p?.message ?? 'Preparing…' }), + el('p', { class: 'comparing__subtitle u-text-muted', text: 'Working locally. Nothing leaves this browser.' }), + el('div', { + class: 'comparing__bar', + role: 'progressbar', + 'aria-label': 'Comparison progress', + 'aria-valuemin': '0', + 'aria-valuemax': '100', + ...(determinate ? { 'aria-valuenow': String(pct) } : {}), + }, [ + el('div', { + class: 'comparing__bar-fill', + style: determinate ? `width:${pct}%` : '', + dataset: { indeterminate: determinate ? 'false' : 'true' }, + }), + ]), + el('ol', { class: 'comparing__phases' }, phases.map((ph, i) => + el('li', { + class: 'comparing__phase', + dataset: { + state: i < currentIdx ? 'done' : i === currentIdx ? 'active' : 'pending', + }, + }, [ + el('span', { class: 'comparing__phase-dot' }, [ + i < currentIdx ? icon(Icons.check, { size: 12 }) : null, + ]), + el('span', { class: 'comparing__phase-label', text: ph.label }), + ]) + )), + ]), + ]), + ]); + } +} \ No newline at end of file diff --git a/src/ui/views/landing-view.js b/src/ui/views/landing-view.js new file mode 100644 index 0000000..3923393 --- /dev/null +++ b/src/ui/views/landing-view.js @@ -0,0 +1,40 @@ +import { el, icon, Icons } from '../renderers/dom.js'; +import { FilePanel } from '../components/file-panel.js'; +import { PrivacyNote } from '../components/privacy-note.js'; + +export class LandingView { + /** @param {import('../../app/app-store.js').AppStore} store */ + constructor(store) { + this.store = store; + this.filePanelA = new FilePanel({ slot: 'A' }); + this.filePanelB = new FilePanel({ slot: 'B' }); + + this.root = el('main', { class: 'landing', id: 'main' }, [ + el('section', { class: 'landing__hero' }, [ + el('h1', { class: 'landing__title' }, [ + 'Compare two exports. ', + el('span', { class: 'landing__title-accent', text: "Find what doesn't match." }), + ]), + el('p', { + class: 'landing__subtitle', + text: 'Two files in. Differences out. No account, no upload, no cloud database.', + }), + ]), + el('section', { class: 'landing__workflow', 'aria-label': 'File selection' }, [ + this.filePanelA.root, + el('div', { class: 'landing__connector', 'aria-hidden': 'true' }, [icon(Icons.arrowRight, { size: 18 })]), + this.filePanelB.root, + ]), + el('div', { class: 'landing__footer' }, [PrivacyNote()]), + ]); + + this._syncFromStore(); + store.addEventListener('change', () => this._syncFromStore()); + store.addEventListener('state', () => this._syncFromStore()); + } + + _syncFromStore() { + this.filePanelA.setInspection(this.store.fileA); + this.filePanelB.setInspection(this.store.fileB); + } +} \ No newline at end of file diff --git a/src/ui/views/mapping-view.js b/src/ui/views/mapping-view.js new file mode 100644 index 0000000..4adc89f --- /dev/null +++ b/src/ui/views/mapping-view.js @@ -0,0 +1,25 @@ +import { el } from '../renderers/dom.js'; +import { MappingEditor } from '../components/mapping-editor.js'; + +export class MappingView { + /** + * @param {import('../../app/app-store.js').AppStore} store + * @param {import('../../services/comparison-service.js').ComparisonService} service + */ + constructor(store, service) { + this.store = store; + this.editor = new MappingEditor(store, service); + + this.root = el('main', { class: 'app-main', id: 'main', hidden: '' }, [ + el('div', { class: 'u-container' }, [this.editor.root]), + ]); + + this._sync(); + store.addEventListener('state', () => this._sync()); + } + + _sync() { + const active = ['MAPPING', 'READY_TO_COMPARE'].includes(this.store.state); + this.root.hidden = !active; + } +} \ No newline at end of file diff --git a/src/ui/views/results-view.js b/src/ui/views/results-view.js new file mode 100644 index 0000000..612deef --- /dev/null +++ b/src/ui/views/results-view.js @@ -0,0 +1,106 @@ +import { el, replaceChildren, icon, Icons } from '../renderers/dom.js'; +import { formatCount } from '../renderers/format.js'; +import { SummaryCard } from '../components/summary-card.js'; +import { ResultsTable } from '../components/results-table.js'; +import { RecordDetailDrawer } from '../components/record-detail-drawer.js'; +import { AppEvents } from '../../app/app-events.js'; + +const CARDS = [ + { status: 'ALL', label: 'All' }, + { status: 'MISMATCH', label: 'Mismatch' }, + { status: 'ONLY_A', label: 'Only A' }, + { status: 'ONLY_B', label: 'Only B' }, + { status: 'DUPLICATE', label: 'Duplicate' }, + { status: 'AMBIGUOUS', label: 'Ambiguous' }, + { status: 'MATCHED', label: 'Matched' }, +]; + +/** + * ResultsView — summary cards, filters, table, drawer, export. + */ +export class ResultsView { + /** @param {import('../../app/app-store.js').AppStore} store */ + constructor(store) { + this.store = store; + this.table = new ResultsTable(store); + this.drawer = new RecordDetailDrawer(store); + + this.root = el('main', { class: 'app-main', id: 'main', hidden: '' }); + store.addEventListener('state', () => this._sync()); + store.addEventListener('change', () => this._sync()); + this._sync(); + } + + _sync() { + const active = this.store.state === 'RESULTS' && this.store.result; + this.root.hidden = !active; + if (!active) { + replaceChildren(this.root, []); + return; + } + this._render(); + } + + _countFor(status) { + const s = this.store.result.summary; + if (status === 'ALL') return s.totalA + s.totalB; + if (status === 'MISMATCH') return s.mismatched; + if (status === 'ONLY_A') return s.onlyA; + if (status === 'ONLY_B') return s.onlyB; + if (status === 'DUPLICATE') return s.duplicates; + if (status === 'AMBIGUOUS') return s.ambiguous; + if (status === 'MATCHED') return s.matched; + return 0; + } + + _setFilter(status) { + this.store.patch({ resultFilter: status }); + window.dispatchEvent(new CustomEvent(AppEvents.RESULT_FILTER_CHANGED, { detail: { status } })); + this._render(); + } + + _render() { + const s = this.store.result.summary; + + const cards = CARDS.map((c) => + new SummaryCard({ + status: c.status, + label: c.label, + count: this._countFor(c.status), + active: this.store.resultFilter === c.status, + onClick: () => this._setFilter(c.status), + }).root + ); + + replaceChildren(this.root, [ + el('div', { class: 'u-container' }, [ + el('header', { class: 'results-header' }, [ + el('div', {}, [ + el('h2', { class: 'results-header__title', text: 'Comparison complete' }), + el('p', { + class: 'results-header__sub u-text-muted', + text: `${formatCount(s.totalA)} rows in File A · ${formatCount(s.totalB)} rows in File B`, + }), + ]), + el('div', { class: 'results-header__actions' }, [ + el('button', { + type: 'button', + class: 'btn btn--ghost', + onclick: () => window.dispatchEvent(new CustomEvent(AppEvents.RESET_REQUESTED)), + }, [icon(Icons.refresh, { size: 14 }), 'Start over']), + el('button', { + type: 'button', + class: 'btn btn--primary', + onclick: () => window.dispatchEvent(new CustomEvent(AppEvents.EXPORT_REQUESTED, { detail: { format: 'csv' } })), + }, [icon(Icons.table, { size: 14 }), 'Export CSV']), + ]), + ]), + + el('div', { class: 'summary-grid', role: 'tablist', 'aria-label': 'Result filters' }, cards), + + this.table.root, + ]), + this.drawer.root, + ]); + } +} \ No newline at end of file diff --git a/vite.config.js b/vite.config.js index 879d500..8bc6797 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,8 +1,16 @@ -import { defineConfig } from "vite"; +import { defineConfig } from 'vite'; export default defineConfig({ + base: './', build: { - outDir: "dist", - emptyOutDir: true - } -}); + target: 'es2022', + outDir: 'dist', + assetsDir: 'assets', + sourcemap: false, + cssCodeSplit: false, + }, + server: { + port: 5173, + open: true, + }, +}); \ No newline at end of file From e492d38b19124c348bbc7a12596d23d9a44f18ab Mon Sep 17 00:00:00 2001 From: QvarcY <118832438+QvarcY@users.noreply.github.com> Date: Sat, 26 Sep 2026 18:43:09 +0300 Subject: [PATCH 2/9] feat: add bilingual docs and i18n foundation --- .gitattributes | 13 +++ CONTRIBUTING.md | 59 ++++++++++ LICENSE | 21 ++++ README.lv.md | 154 +++++++++++++++++++++++++ README.md | 154 +++++++++++++++++++++++++ README_FRONTEND.md | 35 ++++-- ROADMAP.md | 141 ++++++++++++++++++++++ docs/TRANSLATIONS.md | 51 ++++++++ package.json | 8 +- src/i18n/index.js | 62 ++++++++++ src/i18n/locales/en.js | 129 +++++++++++++++++++++ src/i18n/locales/index.js | 17 +++ src/i18n/locales/lv.js | 129 +++++++++++++++++++++ src/main.js | 29 +++-- src/styles/components.css | 42 ++++++- src/ui/components/file-panel.js | 77 ++++--------- src/ui/components/language-switcher.js | 25 ++++ src/ui/components/privacy-note.js | 7 +- src/ui/components/skip-link.js | 5 +- src/ui/renderers/format.js | 41 +++---- src/ui/views/comparing-view.js | 7 +- src/ui/views/landing-view.js | 13 ++- src/ui/views/mapping-view.js | 8 +- src/ui/views/results-view.js | 14 +-- 24 files changed, 1106 insertions(+), 135 deletions(-) create mode 100644 .gitattributes create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.lv.md create mode 100644 README.md create mode 100644 ROADMAP.md create mode 100644 docs/TRANSLATIONS.md create mode 100644 src/i18n/index.js create mode 100644 src/i18n/locales/en.js create mode 100644 src/i18n/locales/index.js create mode 100644 src/i18n/locales/lv.js create mode 100644 src/ui/components/language-switcher.js diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..a29f639 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,13 @@ +* text=auto eol=lf + +*.bat text eol=crlf +*.cmd text eol=crlf +*.ps1 text eol=crlf + +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.webp binary +*.ico binary +*.pdf binary diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..90e982c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,59 @@ +# Contributing + +Thank you for considering a contribution to Two-Export Comparator. + +The project intentionally stays focused: compare two datasets locally and explain what does not match. + +## Before opening code changes + +- Keep the privacy-first architecture. +- Do not introduce accounts, cloud file storage, analytics, telemetry or unrelated SaaS features. +- Do not put parser or comparison logic inside UI components. +- Imported values must be rendered safely. +- Avoid silent guessing for ambiguous data. +- Keep dependencies minimal. + +## Development + +~~~bash +npm install +npm run dev +npm run build +~~~ + +## Translations + +Translations are a first-class contribution area. + +See [docs/TRANSLATIONS.md](docs/TRANSLATIONS.md). + +A translation PR should normally: + +1. add a locale file under src/i18n/locales/; +2. register it in src/i18n/locales/index.js; +3. keep the same message keys as English; +4. translate meaning, not code identifiers or source-data column names; +5. run the project and inspect the main workflow; +6. optionally add a README.xx.md translation. + +## Tests + +As the real engine is introduced, tests must protect behavior rather than merely execute code. + +A regression test should fail when the protected behavior is removed or restored to the previous broken behavior. + +## Scope + +Good contribution areas include: + +- parsers and file-format support; +- normalization; +- deterministic matching; +- security hardening; +- accessibility; +- performance; +- translations; +- documentation; +- test fixtures. + +Large scope expansions should be discussed before implementation. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e403781 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 QvarcY + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.lv.md b/README.lv.md new file mode 100644 index 0000000..27a11a5 --- /dev/null +++ b/README.lv.md @@ -0,0 +1,154 @@ +# Two-Export Comparator + +[English](README.md) · [Latviešu](README.lv.md) + +**Divi faili iekšā. Atšķirības ārā. Bez konta. Bez augšupielādes. Bez mākoņdatubāzes.** + +Two-Export Comparator ir privātumam draudzīgs pārlūka rīks divu eksportētu datu kopu salīdzināšanai, lai atrastu trūkstošus, dublētus, neskaidri savietojamus vai atšķirīgus ierakstus. + +Projekta mērķis apzināti ir šaurs: **ieliec divus eksportus, savieno vajadzīgās kolonnas, salīdzini lokāli, pārbaudi atšķirības un eksportē rezultātu.** + +> **Pašreizējais statuss:** frontend prototips un arhitektūras pamats. Produkcijas CSV/TSV parseris un īstais salīdzināšanas dzinējs vēl nav ieviests. Pašreizējais interfeiss izmanto determinētus mock datus, kamēr īstais dzinējs tiek veidots aiz stabila servisa kontrakta. + +## Kāpēc šis projekts pastāv + +Mazie uzņēmumi regulāri eksportē datus no bankām, internetveikaliem, maksājumu sistēmām, noliktavas rīkiem, CRM, grāmatvedības sistēmām un izklājlapām. Sarežģītā daļa bieži nav eksporta iegūšana, bet atbilde uz vienkāršu jautājumu: + +**Kas šajos divos failos nesakrīt?** + +Tipiski piemēri: + +- bankas eksports pret rēķinu eksportu; +- veikala pasūtījumi pret maksājumu sistēmas eksportu; +- noliktavas eksports pret internetveikala eksportu; +- vecais piegādātāja cenu saraksts pret jauno; +- divas atskaites, kurās būtu jābūt vienādām atsaucēm un summām. + +Šis projekts nav paredzēts kā ERP, CRM, grāmatvedības sistēma vai mākoņa datu platforma. + +## Privātuma modelis + +Biznesa failos var būt sensitīva komerciāla vai personas informācija, tāpēc aplikācija tiek veidota ap lokālu apstrādi. + +Plānotie produkcijas noteikumi: + +- failu saturs tiek apstrādāts pārlūkā; +- aplikācija failus neaugšupielādē; +- lietotāja konts nav nepieciešams; +- aplikācijas datubāzē importētie faili netiek glabāti; +- salīdzināšanas darbplūsmai nav nepieciešama analītika vai telemetrija; +- ārējs JavaScript CDN nav nepieciešams; +- importētās vērtības tiek attēlotas ar drošām DOM metodēm; +- aizverot vai pārlādējot lapu, atmiņā ielādētie biznesa dati pazūd. + +Šī arhitektūra samazina nevajadzīgu datu pārsūtīšanu, taču projekts neizsaka vispārīgus juridiskus apgalvojumus, piemēram, “100% atbilst GDPR”. + +## Darbplūsma + +~~~text +FAILS A + FAILS B + ↓ +PĀRBAUDE / PRIEKŠSKATS + ↓ +KOLONNU SAVIENOŠANA + ↓ +NORMALIZĀCIJAS IESTATĪŠANA + ↓ +LOKĀLA SALĪDZINĀŠANA + ↓ +SAKRĪT / TIKAI A / TIKAI B / NESAKRĪT / DUBLIKĀTS / NESKAIDRS + ↓ +DETALIZĒTA PĀRBAUDE + ↓ +ATSKAITES EKSPORTS +~~~ + +## Rezultātu kategorijas + +| Statuss | Nozīme | +| --- | --- | +| MATCHED | Atrasts viens drošs ierakstu pāris un salīdzinātās vērtības atbilst. | +| ONLY_A | Ieraksts atrodas tikai Failā A. | +| ONLY_B | Ieraksts atrodas tikai Failā B. | +| MISMATCH | Atslēga sakrīt, bet viena vai vairākas salīdzinātās vērtības atšķiras. | +| DUPLICATE | Viena normalizēta atslēga sastopama vairākas reizes. | +| AMBIGUOUS | Nav iespējams droši noteikt vienu pāri; dzinējs nedrīkst minēt. | + +## Arhitektūra + +~~~text +src/ +├── app/ aplikācijas stāvoklis un kontrolieris +├── i18n/ valodu reģistrs un tulkojumi +├── models/ stabili DTO / servisa kontrakti +├── services/ mock un nākotnes pārlūka salīdzināšanas serviss +├── ui/ +│ ├── components/ +│ ├── renderers/ +│ └── views/ +└── styles/ +~~~ + +UI sazinās tikai ar ComparisonService robežu. Produkcijas parseris, normalizētāji un salīdzināšanas dzinējs atradīsies aiz BrowserComparisonService, tāpēc, nomainot mock servisu pret īsto dzinēju, interfeiss nav jāpārbūvē. + +## Valodas + +Pirmās oficiālās UI valodas: + +- angļu (EN); +- latviešu (LV). + +Valodu pārslēdzējs tiek veidots no valodu reģistra. Contributors var pievienot jaunu valodu, neaiztiekot salīdzināšanas loģiku. + +Skati [Tulkošanas rokasgrāmatu](docs/TRANSLATIONS.md). + +README tulkojumus var pievienot kā README.xx.md failus un pievienot valodu rindai README augšpusē. + +## Izstrāde + +~~~bash +npm install +npm run dev +npm run build +npm run preview +~~~ + +Node/Vite tiek izmantots tikai izstrādei un statiskā build izveidei. Produkcijas aplikācija paliek statiska pārlūka aplikācija. + +## Attīstības plāns + +Detalizētais un nepārtraukti atjauninātais plāns atrodas [ROADMAP.md](ROADMAP.md). + +Augsta līmeņa ceļš: + +1. ✅ Projekta ideja un arhitektūras pamats +2. 🟡 Frontend prototips, divvalodu UI pamats un dokumentācija +3. ⬜ Īsts CSV/TSV failu pārbaudes un parsēšanas slānis +4. ⬜ Normalizācija un kolonnu savienošanas noteikumi +5. ⬜ Determinēts salīdzināšanas dzinējs +6. ⬜ Eksports, privātums un drošības nostiprināšana +7. ⬜ Testu fixtures, veiktspēja un pieejamība +8. ⬜ Offline build un publiska izplatīšana +9. ⬜ Publisks release un contributor ekosistēma + +**Roadmap noteikums:** pēc katras nozīmīgas izstrādes kārtas jāatjaunina ROADMAP.md, lai repo skaidri redzams, kas mainīts, kas pārbaudīts un kas seko tālāk. + +## Contribution + +Contribution būs gaidīti, tiklīdz attiecīgā projekta daļa būs pietiekami stabila drošam darbam. Tulkojumu contribution apzināti veidots vienkāršs. + +Skati [CONTRIBUTING.md](CONTRIBUTING.md). + +## Dizaina principi + +- Viena skaidra problēma, atrisināta labi. +- Local-first, kur tas ir praktiski. +- Nekādas klusas minēšanas neskaidru datu gadījumā. +- Reāla uzvedība svarīgāka par funkciju skaitu. +- Minimālas dependencies. +- Pieejamas vadīklas, ne tikai drag-and-drop. +- Testiem jāpierāda uzvedība un jānokrīt, ja aizsargātā uzvedība tiek sabojāta. + +## Licence + +MIT — skati [LICENSE](LICENSE). diff --git a/README.md b/README.md new file mode 100644 index 0000000..47617da --- /dev/null +++ b/README.md @@ -0,0 +1,154 @@ +# Two-Export Comparator + +[English](README.md) · [Latviešu](README.lv.md) + +**Two files in. Differences out. No account. No upload. No cloud database.** + +Two-Export Comparator is a privacy-first browser utility for comparing two exported datasets and finding records that are missing, duplicated, ambiguous, or different. + +The long-term goal is deliberately narrow: **drop two exports, map the relevant columns, compare them locally, inspect the differences, export the result.** + +> **Current status:** frontend prototype and architecture foundation. The production CSV/TSV parser and real comparison engine are not implemented yet. The current interface uses deterministic mock data while the engine is built behind a stable service contract. + +## Why this project exists + +Small businesses regularly export data from banks, shops, payment systems, inventory tools, CRMs, accounting tools and spreadsheets. The difficult part is often not producing the export — it is answering a simple question: + +**What does not match between these two files?** + +Typical examples: + +- bank export vs invoice export; +- shop orders vs payment provider export; +- warehouse export vs store export; +- old supplier list vs new supplier list; +- two reports that should contain the same references and amounts. + +This project is not intended to become an ERP, CRM, accounting suite or cloud data platform. + +## Privacy model + +Business files may contain sensitive commercial or personal data, so the application is designed around local processing. + +Planned production rules: + +- file contents are processed in the browser; +- files are not uploaded by the application; +- no account is required; +- no application database stores imported files; +- no analytics or telemetry is required for the comparison workflow; +- no external JavaScript CDN is required; +- imported values are rendered with safe DOM APIs; +- closing or refreshing the page clears in-memory business data. + +This architecture reduces unnecessary data transfer, but the project does **not** make blanket legal claims such as “100% GDPR compliant”. + +## Workflow + +~~~text +DROP FILE A + FILE B + ↓ +INSPECT / PREVIEW + ↓ +MAP COLUMNS + ↓ +CONFIGURE NORMALIZATION + ↓ +COMPARE LOCALLY + ↓ +MATCHED / ONLY A / ONLY B / MISMATCH / DUPLICATE / AMBIGUOUS + ↓ +INSPECT DETAILS + ↓ +EXPORT REPORT +~~~ + +## Result categories + +| Status | Meaning | +| --- | --- | +| MATCHED | A unique record pair was found and compared values pass. | +| ONLY_A | A record exists only in File A. | +| ONLY_B | A record exists only in File B. | +| MISMATCH | The key matched, but one or more compared values differ. | +| DUPLICATE | The same normalized key occurs multiple times. | +| AMBIGUOUS | A unique safe pairing cannot be determined; the engine must not guess. | + +## Architecture + +~~~text +src/ +├── app/ application state and controller +├── i18n/ locale registry and translations +├── models/ stable DTO / service contracts +├── services/ mock and future browser comparison service +├── ui/ +│ ├── components/ +│ ├── renderers/ +│ └── views/ +└── styles/ +~~~ + +The UI talks only to the ComparisonService boundary. The production parser, normalizers and comparison engine will live behind BrowserComparisonService so the interface does not need to be redesigned when the real engine replaces the mock. + +## Languages + +The first official UI languages are: + +- English (EN) +- Latvian / Latviešu (LV) + +The language selector is generated from a locale registry. Contributors can add another language without changing comparison logic. + +See [Translation guide](docs/TRANSLATIONS.md). + +README translations may be added as README.xx.md files and linked from the language line at the top. + +## Development + +~~~bash +npm install +npm run dev +npm run build +npm run preview +~~~ + +Node/Vite is used only for development and static bundling. The production application remains a browser-side static application. + +## Roadmap + +The detailed, continuously maintained roadmap is in [ROADMAP.md](ROADMAP.md). + +High-level path: + +1. ✅ Project concept and architecture baseline +2. 🟡 Frontend prototype, bilingual UI foundation and documentation +3. ⬜ Real CSV/TSV inspection and parsing +4. ⬜ Normalization and column mapping rules +5. ⬜ Deterministic comparison engine +6. ⬜ Export, privacy and security hardening +7. ⬜ Test fixtures, performance and accessibility +8. ⬜ Offline build and public distribution +9. ⬜ Public release and contributor ecosystem + +**Roadmap rule:** every meaningful development round must update ROADMAP.md so the repository shows what changed, what is verified and what comes next. + +## Contributing + +Contributions are welcome once the relevant area is stable enough to work on safely. Translation contributions are intentionally low-friction. + +See [CONTRIBUTING.md](CONTRIBUTING.md). + +## Design principles + +- One focused job, done well. +- Local-first where practical. +- No silent guessing when data is ambiguous. +- Behavior over decorative feature count. +- Minimal dependencies. +- Accessible controls, not drag-and-drop only. +- Tests must prove behavior and must fail when the protected behavior is broken. + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/README_FRONTEND.md b/README_FRONTEND.md index f8893ce..a821e43 100644 --- a/README_FRONTEND.md +++ b/README_FRONTEND.md @@ -1,14 +1,31 @@ -# Two-Export Comparator — Frontend +# Frontend architecture notes -A privacy-first browser utility that compares two CSV/TSV exports locally -and identifies missing, duplicate, ambiguous and mismatched records. +The public project description lives in [README.md](README.md) and [README.lv.md](README.lv.md). -**Two files in. Differences out. No account. No upload. No cloud database.** +The frontend is intentionally separated from the future comparison engine: + +~~~text +UI / views / components + ↓ +AppController + AppStore + ↓ +ComparisonService + ↓ +MockComparisonService (current prototype) +BrowserComparisonService (future production engine) +~~~ ## Commands -```bash -npm install # install dev dependencies -npm run dev # start Vite dev server -npm run build # produce static ./dist -npm run preview # preview the production build \ No newline at end of file +~~~bash +npm install +npm run dev +npm run build +npm run preview +~~~ + +## Important + +Do not implement parser, normalization or comparison algorithms inside UI components. + +Language files live under src/i18n/locales/. See [docs/TRANSLATIONS.md](docs/TRANSLATIONS.md). diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..e19594f --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,141 @@ +# Two-Export Comparator — Roadmap / Attīstības plāns + +This file is the canonical development ledger for the project. +Šis fails ir projekta galvenais attīstības žurnāls. + +**Rule / Noteikums:** every meaningful development round updates this file with completed work, verification and the next target. / Pēc katras nozīmīgas izstrādes kārtas šis fails tiek atjaunināts ar paveikto, pārbaudi un nākamo mērķi. + +Status: ✅ complete · 🟡 in progress · ⬜ planned · ⛔ blocked + +## Phase 0 — Concept and architecture / Ideja un arhitektūra ✅ + +- ✅ Narrow product definition: compare two exports and explain differences. +- ✅ Browser-first, local-processing privacy model. +- ✅ Stable service boundary between UI and future engine. +- ✅ AppStore + AppController separation. +- ✅ MockComparisonService for deterministic UI development. +- ✅ Baseline Git repository and frontend branch strategy. + +## Phase 1 — Frontend prototype and project foundation / Frontend prototips un projekta pamats 🟡 + +- ✅ Modern dark-first UI prototype. +- ✅ File A / File B panels. +- ✅ Mapping workspace. +- ✅ Compare progress screen. +- ✅ Results cards, table and record drawer. +- ✅ Safe DOM renderer using textContent for imported values. +- ✅ Pagination instead of rendering the whole result set. +- ✅ English + Latvian i18n registry foundation. +- ✅ Language selector driven by locale registry. +- ✅ English and Latvian README. +- ✅ Translation contribution guide. +- ✅ Canonical roadmap. +- 🟡 Translate every remaining mapping/results/error string through i18n. +- 🟡 Resolve frontend technical audit findings. +- ⬜ Verify clean production build after the full frontend cleanup. + +**Exit criteria:** complete bilingual UI, clean build, no duplicate IDs, package metadata consistent, no known frontend blocker. + +## Phase 2 — Real file inspection and parsing / Īsta failu pārbaude un parsēšana ⬜ + +- ⬜ Replace mock file inspection in BrowserComparisonService. +- ⬜ CSV parser with quoted fields and multiline quoted values. +- ⬜ TSV support. +- ⬜ Comma / semicolon / tab delimiter detection. +- ⬜ UTF-8 and UTF-8 BOM handling. +- ⬜ Clear unsupported-encoding error. +- ⬜ Header discovery and safe preview. +- ⬜ Row and column metadata. +- ⬜ File-size and malformed-input safeguards. +- ⬜ Parser fixtures for real edge cases. + +**Exit criteria:** real CSV/TSV files can be inspected locally without upload and parser fixtures prove edge-case behavior. + +## Phase 3 — Mapping and normalization / Kolonnu savienošana un normalizācija ⬜ + +- ⬜ Exact text keys. +- ⬜ Composite keys. +- ⬜ Trim / case / repeated-whitespace normalization. +- ⬜ Explicit numeric parsing for dot/comma decimals. +- ⬜ Conservative date handling. +- ⬜ User-selected ambiguous date format. +- ⬜ Numeric tolerance: absolute and percentage. +- ⬜ Deterministic column mapping suggestions without AI. +- ⬜ Mapping validation and readable error states. + +**Exit criteria:** mappings are explicit, auditable and never silently guess ambiguous formats. + +## Phase 4 — Comparison engine / Salīdzināšanas dzinējs ⬜ + +- ⬜ Hash-map indexing; avoid O(n²) pair scans. +- ⬜ MATCHED. +- ⬜ ONLY_A. +- ⬜ ONLY_B. +- ⬜ MISMATCH. +- ⬜ DUPLICATE. +- ⬜ AMBIGUOUS. +- ⬜ Source row traceability. +- ⬜ Deterministic pairing rules. +- ⬜ No silent first-match behavior for duplicate/ambiguous records. +- ⬜ Large-file performance tests. + +**Exit criteria:** removing or breaking any comparison rule causes the relevant regression fixture to fail. + +## Phase 5 — Export, privacy and security hardening / Eksports, privātums un drošība ⬜ + +- ⬜ Result CSV export. +- ⬜ Filtered exports. +- ⬜ CSV formula-injection protection. +- ⬜ CSP suitable for static deployment, including connect-src 'none' where deployment permits it. +- ⬜ No analytics, telemetry or external data APIs. +- ⬜ No business-file content in localStorage, IndexedDB or cookies. +- ⬜ Security review of imported values and generated downloads. +- ⬜ Privacy explanation verified against actual behavior. + +## Phase 6 — Quality gate / Kvalitātes vārti ⬜ + +- ⬜ Unit tests. +- ⬜ Behavior-driven fixture tests. +- ⬜ Integration tests. +- ⬜ Regression tests that fail on the pre-fix behavior. +- ⬜ Accessibility audit: keyboard, labels, focus, screen reader semantics. +- ⬜ Responsive review. +- ⬜ EN/LV translation completeness check. +- ⬜ Contributor translation validation. +- ⬜ Performance baseline for large files. +- ⬜ Dependency audit. + +## Phase 7 — Distribution / Izplatīšana ⬜ + +- ⬜ GitHub Pages deployment. +- ⬜ Offline-capable static build. +- ⬜ Evaluate a self-contained single HTML release. +- ⬜ Release checksums where useful. +- ⬜ Public demo with sample data only. +- ⬜ Verify that demo hosting never receives selected business files. + +## Phase 8 — Public open-source release / Publisks open-source release ⬜ + +- ⬜ Final project name/branding review. +- ⬜ MIT license and public repository metadata. +- ⬜ CONTRIBUTING polish. +- ⬜ Issue and PR templates. +- ⬜ Good first issues. +- ⬜ Help-wanted issues for parsers, formats and translations. +- ⬜ First tagged release. +- ⬜ Public launch documentation and screenshots. + +--- + +## Development rounds / Izstrādes kārtas + +| Date | Round | Status | What changed / Kas mainīts | +| --- | --- | --- | --- | +| 2026-09-26 | R0 — Scaffold | ✅ | Vite project, service boundary, initial repository baseline. | +| 2026-09-26 | R1 — Frontend prototype | ✅ | File panels, mapping UI, progress view, result dashboard and drawer added. | +| 2026-09-26 | R2 — i18n + documentation foundation | ✅ | EN/LV locale registry, language selector foundation, bilingual README, translation guide, canonical roadmap, line-ending policy and package metadata cleanup. | +| — | R3 — Full UI localization + frontend audit | 🟡 | Next: wire every remaining UI string to i18n and close known frontend audit findings. | + +## Next target / Nākamais mērķis + +**R3:** complete EN/LV coverage through the whole workflow, finish frontend technical cleanup, then produce the first clean production build before implementing the real parser. diff --git a/docs/TRANSLATIONS.md b/docs/TRANSLATIONS.md new file mode 100644 index 0000000..761f1df --- /dev/null +++ b/docs/TRANSLATIONS.md @@ -0,0 +1,51 @@ +# Translation guide + +Two-Export Comparator uses a small locale registry instead of embedding language logic into the comparison engine. + +## Current official locales + +- en — English +- lv — Latviešu + +## Add a UI language + +1. Copy src/i18n/locales/en.js. +2. Rename it to the ISO-style language code you want to add, for example de.js or pl.js. +3. Translate every message value. Do not rename message keys. +4. Import the file in src/i18n/locales/index.js. +5. Add one registry entry containing: + - code + - nativeName + - shortLabel + - messages +6. Run npm run dev. +7. Select the language from the header. +8. Check file selection, mapping, progress, results, errors and accessibility labels. +9. Run npm run build. + +The language selector is generated from the registry, so a correctly registered language appears automatically. + +## Important rules + +- Do not translate source file column names or imported business data. +- Keep placeholders such as {slot}, {shown}, {total}, {count}, {page} unchanged. +- Do not add HTML to translation values. +- Keep translations concise enough for buttons and narrow screens. +- Translate meaning naturally; word-for-word translation is not required. +- English is the fallback locale. + +## README translations + +A contributor may also add a README translation: + +~~~text +README.de.md +README.pl.md +README.fr.md +~~~ + +Then add the new language link to the language line at the top of README.md and other maintained README translations. + +## Privacy note + +Language preference is not stored together with business-file contents. The initial implementation uses a URL language parameter and browser-language detection instead of persisting imported business data. diff --git a/package.json b/package.json index cfff6dc..0a7c3ff 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,15 @@ { "name": "two-export-comparator", - "version": "0.1.0", + "version": "0.0.1", "private": true, "type": "module", - "description": "Compare two CSV/TSV exports locally. Privacy-first browser utility.", + "description": "Privacy-first browser utility for comparing two CSV/TSV exports locally.", "scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview" }, "devDependencies": { - "vite": "^5.4.0" + "vite": "^7.0.0" } -} \ No newline at end of file +} diff --git a/src/i18n/index.js b/src/i18n/index.js new file mode 100644 index 0000000..77c73d2 --- /dev/null +++ b/src/i18n/index.js @@ -0,0 +1,62 @@ +import { localeRegistry } from './locales/index.js'; + +const FALLBACK_LOCALE = 'en'; + +function normalizeLocale(value) { + return String(value || '').trim().toLowerCase().split('-')[0]; +} + +function detectLocale() { + const url = new URL(window.location.href); + const fromUrl = normalizeLocale(url.searchParams.get('lang')); + if (localeRegistry[fromUrl]) return fromUrl; + + const candidates = Array.isArray(navigator.languages) && navigator.languages.length + ? navigator.languages + : [navigator.language]; + + for (const candidate of candidates) { + const code = normalizeLocale(candidate); + if (localeRegistry[code]) return code; + } + + return FALLBACK_LOCALE; +} + +let activeLocale = detectLocale(); + +document.documentElement.lang = activeLocale; +document.documentElement.dataset.locale = activeLocale; + +export function getLocale() { + return activeLocale; +} + +export function getSupportedLocales() { + return Object.values(localeRegistry).map(({ code, nativeName, shortLabel }) => ({ + code, + nativeName, + shortLabel, + })); +} + +export function t(key, params = {}) { + const current = localeRegistry[activeLocale]?.messages ?? {}; + const fallback = localeRegistry[FALLBACK_LOCALE]?.messages ?? {}; + const raw = current[key] ?? fallback[key] ?? key; + + return String(raw).replace(/\{([a-zA-Z0-9_]+)\}/g, (_match, name) => { + return Object.prototype.hasOwnProperty.call(params, name) + ? String(params[name]) + : '{' + name + '}'; + }); +} + +export function setLocale(locale) { + const next = normalizeLocale(locale); + if (!localeRegistry[next] || next === activeLocale) return; + + const url = new URL(window.location.href); + url.searchParams.set('lang', next); + window.location.assign(url.toString()); +} diff --git a/src/i18n/locales/en.js b/src/i18n/locales/en.js new file mode 100644 index 0000000..23c2ecc --- /dev/null +++ b/src/i18n/locales/en.js @@ -0,0 +1,129 @@ +export default { + 'meta.title': 'Two-Export Comparator', + 'meta.description': "Compare two CSV/TSV exports locally. Find what doesn't match — no upload, no account, no cloud.", + + 'language.label': 'Language', + + 'app.brand': 'Two-Export Comparator', + 'app.tagline': 'Local processing · No uploads', + 'app.localOnly': 'Local only', + 'app.filesNeverLeave': 'Files never leave this browser', + + 'skip.content': 'Skip to content', + + 'landing.titlePrefix': 'Compare two exports. ', + 'landing.titleAccent': "Find what doesn't match.", + 'landing.subtitle': 'Two files in. Differences out. No account, no upload, no cloud database.', + 'landing.fileSelection': 'File selection', + + 'privacy.local': 'Your files are processed locally in this browser and are not uploaded by this application.', + + 'file.label': 'File {slot}', + 'file.choose': 'Choose File {slot}', + 'file.chooseCsv': 'Choose File {slot} — CSV or TSV', + 'file.none': 'No file selected', + 'file.remove': 'Remove', + 'file.removeSlot': 'Remove File {slot}', + 'file.drop': 'Drop CSV/TSV or browse', + 'file.staysLocal': 'Files stay in this browser', + 'file.rows': 'Rows', + 'file.columns': 'Columns', + 'file.delimiter': 'Delimiter', + 'file.encoding': 'Encoding', + 'file.size': 'Size', + 'file.preview': 'PREVIEW', + 'file.showingRows': 'Showing {shown} of {total} rows', + 'file.readError': "We couldn't read that file", + 'file.tryDifferent': 'Try a different CSV or TSV export.', + 'file.chooseAnother': 'Choose another file', + 'file.genericError': 'Something went wrong', + + 'delimiter.comma': 'comma', + 'delimiter.tab': 'tab', + 'delimiter.semicolon': 'semicolon', + 'delimiter.pipe': 'pipe', + + 'mapping.aria': 'Column mapping', + 'mapping.title': 'Match the columns', + 'mapping.hint': 'Define which columns identify a record and which values to compare. Suggestions are proposals — review before running.', + 'mapping.matchKeys': 'Match keys', + 'mapping.matchKeysHint': 'Required · identifies records across both files', + 'mapping.addKey': '+ Add key', + 'mapping.noKeys': 'No match keys yet. Add at least one.', + 'mapping.compareFields': 'Compare fields', + 'mapping.compareHint': 'Optional · values that must match', + 'mapping.addCompare': '+ Add compare', + 'mapping.noCompare': 'No compare fields yet.', + 'mapping.normalization': 'Normalization', + 'mapping.normalizationHint': 'How values are treated before matching', + 'mapping.trim': 'Trim whitespace', + 'mapping.ignoreCase': 'Ignore case', + 'mapping.collapseWhitespace': 'Collapse internal whitespace', + 'mapping.startOver': 'Start over', + 'mapping.compareFiles': 'Compare files', + 'mapping.fileAColumn': 'File A column', + 'mapping.fileBColumn': 'File B column', + 'mapping.remove': 'Remove mapping', + 'mapping.select': '— select —', + 'mapping.toleranceMode': 'Tolerance mode', + 'mapping.toleranceValue': 'Tolerance value', + 'mapping.role.key': 'MATCH KEY', + 'mapping.role.compare': 'COMPARE', + 'mapping.role.display': 'DISPLAY', + 'mapping.role.ignore': 'IGNORE', + + 'comparing.preparing': 'Preparing…', + 'comparing.local': 'Working locally. Nothing leaves this browser.', + 'comparing.progress': 'Comparison progress', + 'comparing.phase.parse-a': 'Parse File A', + 'comparing.phase.parse-b': 'Parse File B', + 'comparing.phase.index': 'Index records', + 'comparing.phase.compare': 'Compare', + 'comparing.phase.finalize': 'Finalize', + + 'results.complete': 'Comparison complete', + 'results.rowsSummary': '{a} rows in File A · {b} rows in File B', + 'results.startOver': 'Start over', + 'results.exportCsv': 'Export CSV', + 'results.filters': 'Result filters', + 'results.all': 'All', + 'results.mismatch': 'Mismatch', + 'results.onlyA': 'Only A', + 'results.onlyB': 'Only B', + 'results.duplicate': 'Duplicate', + 'results.ambiguous': 'Ambiguous', + 'results.matched': 'Matched', + 'results.status': 'Status', + 'results.key': 'Key', + 'results.rowA': 'Row A', + 'results.rowB': 'Row B', + 'results.openRecord': 'Open record {key}', + 'results.nothing': 'Nothing in this category.', + 'results.records': '{count} records', + 'results.page': '{count} records · page {page} of {pages}', + 'results.prev': '← Prev', + 'results.next': 'Next →', + + 'record.detail': 'Record detail', + 'record.close': 'Close', + 'record.fileA': 'File A', + 'record.fileB': 'File B', + 'record.row': 'row {row}', + 'record.differences': 'Differences', + 'record.explain.MATCHED': 'Key exists in both files and all compared values pass.', + 'record.explain.ONLY_A': 'Record exists only in File A.', + 'record.explain.ONLY_B': 'Record exists only in File B.', + 'record.explain.MISMATCH': 'Key matched, but one or more compared fields differ.', + 'record.explain.DUPLICATE': 'The same normalized key occurs multiple times.', + 'record.explain.AMBIGUOUS': 'The engine cannot determine a unique pairing safely. No guess was made.', + + 'toast.notifications': 'Notifications', + 'toast.dismiss': 'Dismiss', + 'toast.readErrorTitle': "We couldn't read that file", + 'toast.readErrorMessage': 'Try a different CSV or TSV export.', + 'toast.compareFailedTitle': 'Comparison failed', + 'toast.compareFailedMessage': 'Try adjusting the mapping or the files.', + 'toast.exportReadyTitle': 'Export ready', + 'toast.exportReadyMessage': 'Your download has started.', + 'toast.exportFailedTitle': 'Export failed' +}; diff --git a/src/i18n/locales/index.js b/src/i18n/locales/index.js new file mode 100644 index 0000000..5f14c4c --- /dev/null +++ b/src/i18n/locales/index.js @@ -0,0 +1,17 @@ +import en from './en.js'; +import lv from './lv.js'; + +export const localeRegistry = { + en: { + code: 'en', + nativeName: 'English', + shortLabel: 'EN', + messages: en, + }, + lv: { + code: 'lv', + nativeName: 'Latviešu', + shortLabel: 'LV', + messages: lv, + }, +}; diff --git a/src/i18n/locales/lv.js b/src/i18n/locales/lv.js new file mode 100644 index 0000000..eccbb4a --- /dev/null +++ b/src/i18n/locales/lv.js @@ -0,0 +1,129 @@ +export default { + 'meta.title': 'Two-Export Comparator', + 'meta.description': 'Salīdzini divus CSV/TSV eksportus lokāli. Atrodi, kas nesakrīt — bez augšupielādes, konta un mākoņa.', + + 'language.label': 'Valoda', + + 'app.brand': 'Two-Export Comparator', + 'app.tagline': 'Lokāla apstrāde · Bez augšupielādes', + 'app.localOnly': 'Tikai lokāli', + 'app.filesNeverLeave': 'Faili nekad neatstāj šo pārlūku', + + 'skip.content': 'Pāriet uz saturu', + + 'landing.titlePrefix': 'Salīdzini divus eksportus. ', + 'landing.titleAccent': 'Atrodi, kas nesakrīt.', + 'landing.subtitle': 'Divi faili iekšā. Atšķirības ārā. Bez konta, bez augšupielādes un bez mākoņdatubāzes.', + 'landing.fileSelection': 'Failu izvēle', + + 'privacy.local': 'Faili tiek apstrādāti lokāli šajā pārlūkā, un aplikācija tos neaugšupielādē.', + + 'file.label': 'Fails {slot}', + 'file.choose': 'Izvēlēties Failu {slot}', + 'file.chooseCsv': 'Izvēlēties Failu {slot} — CSV vai TSV', + 'file.none': 'Fails nav izvēlēts', + 'file.remove': 'Noņemt', + 'file.removeSlot': 'Noņemt Failu {slot}', + 'file.drop': 'Ievelc CSV/TSV vai izvēlies failu', + 'file.staysLocal': 'Faili paliek šajā pārlūkā', + 'file.rows': 'Rindas', + 'file.columns': 'Kolonnas', + 'file.delimiter': 'Atdalītājs', + 'file.encoding': 'Kodējums', + 'file.size': 'Izmērs', + 'file.preview': 'PRIEKŠSKATĪJUMS', + 'file.showingRows': 'Rāda {shown} no {total} rindām', + 'file.readError': 'Failu neizdevās nolasīt', + 'file.tryDifferent': 'Pamēģini citu CSV vai TSV eksportu.', + 'file.chooseAnother': 'Izvēlēties citu failu', + 'file.genericError': 'Radās kļūda', + + 'delimiter.comma': 'komats', + 'delimiter.tab': 'tabulācija', + 'delimiter.semicolon': 'semikols', + 'delimiter.pipe': 'vertikālā svītra', + + 'mapping.aria': 'Kolonnu savienošana', + 'mapping.title': 'Savieno kolonnas', + 'mapping.hint': 'Norādi, kuras kolonnas identificē ierakstu un kuras vērtības jāsalīdzina. Ieteikumi ir tikai priekšlikumi — pārbaudi tos pirms palaišanas.', + 'mapping.matchKeys': 'Atbilstības atslēgas', + 'mapping.matchKeysHint': 'Obligāti · identificē ierakstus abos failos', + 'mapping.addKey': '+ Pievienot atslēgu', + 'mapping.noKeys': 'Atbilstības atslēgu vēl nav. Pievieno vismaz vienu.', + 'mapping.compareFields': 'Salīdzināmie lauki', + 'mapping.compareHint': 'Neobligāti · vērtības, kurām jāsakrīt', + 'mapping.addCompare': '+ Pievienot lauku', + 'mapping.noCompare': 'Salīdzināmo lauku vēl nav.', + 'mapping.normalization': 'Normalizācija', + 'mapping.normalizationHint': 'Kā vērtības tiek apstrādātas pirms salīdzināšanas', + 'mapping.trim': 'Noņemt liekās malas atstarpes', + 'mapping.ignoreCase': 'Ignorēt burtu reģistru', + 'mapping.collapseWhitespace': 'Apvienot atkārtotas atstarpes', + 'mapping.startOver': 'Sākt no jauna', + 'mapping.compareFiles': 'Salīdzināt failus', + 'mapping.fileAColumn': 'Faila A kolonna', + 'mapping.fileBColumn': 'Faila B kolonna', + 'mapping.remove': 'Noņemt savienojumu', + 'mapping.select': '— izvēlies —', + 'mapping.toleranceMode': 'Pielaides režīms', + 'mapping.toleranceValue': 'Pielaides vērtība', + 'mapping.role.key': 'ATSLĒGA', + 'mapping.role.compare': 'SALĪDZINĀT', + 'mapping.role.display': 'RĀDĪT', + 'mapping.role.ignore': 'IGNORĒT', + + 'comparing.preparing': 'Sagatavo…', + 'comparing.local': 'Apstrāde notiek lokāli. Nekas neatstāj šo pārlūku.', + 'comparing.progress': 'Salīdzināšanas progress', + 'comparing.phase.parse-a': 'Nolasīt Failu A', + 'comparing.phase.parse-b': 'Nolasīt Failu B', + 'comparing.phase.index': 'Indeksēt ierakstus', + 'comparing.phase.compare': 'Salīdzināt', + 'comparing.phase.finalize': 'Pabeigt', + + 'results.complete': 'Salīdzināšana pabeigta', + 'results.rowsSummary': '{a} rindas Failā A · {b} rindas Failā B', + 'results.startOver': 'Sākt no jauna', + 'results.exportCsv': 'Eksportēt CSV', + 'results.filters': 'Rezultātu filtri', + 'results.all': 'Visi', + 'results.mismatch': 'Nesakrīt', + 'results.onlyA': 'Tikai A', + 'results.onlyB': 'Tikai B', + 'results.duplicate': 'Dublikāti', + 'results.ambiguous': 'Neskaidri', + 'results.matched': 'Sakrīt', + 'results.status': 'Statuss', + 'results.key': 'Atslēga', + 'results.rowA': 'Rinda A', + 'results.rowB': 'Rinda B', + 'results.openRecord': 'Atvērt ierakstu {key}', + 'results.nothing': 'Šajā kategorijā ierakstu nav.', + 'results.records': '{count} ieraksti', + 'results.page': '{count} ieraksti · {page}. lapa no {pages}', + 'results.prev': '← Iepriekšējā', + 'results.next': 'Nākamā →', + + 'record.detail': 'Ieraksta detaļas', + 'record.close': 'Aizvērt', + 'record.fileA': 'Fails A', + 'record.fileB': 'Fails B', + 'record.row': 'rinda {row}', + 'record.differences': 'Atšķirības', + 'record.explain.MATCHED': 'Atslēga ir abos failos, un visas salīdzinātās vērtības atbilst.', + 'record.explain.ONLY_A': 'Ieraksts atrodas tikai Failā A.', + 'record.explain.ONLY_B': 'Ieraksts atrodas tikai Failā B.', + 'record.explain.MISMATCH': 'Atslēga sakrīt, bet viens vai vairāki salīdzinātie lauki atšķiras.', + 'record.explain.DUPLICATE': 'Viena normalizēta atslēga sastopama vairākas reizes.', + 'record.explain.AMBIGUOUS': 'Dzinējs nevar droši noteikt vienu unikālu pāri. Minējums netika veikts.', + + 'toast.notifications': 'Paziņojumi', + 'toast.dismiss': 'Aizvērt', + 'toast.readErrorTitle': 'Failu neizdevās nolasīt', + 'toast.readErrorMessage': 'Pamēģini citu CSV vai TSV eksportu.', + 'toast.compareFailedTitle': 'Salīdzināšana neizdevās', + 'toast.compareFailedMessage': 'Pārbaudi kolonnu savienojumus vai failus.', + 'toast.exportReadyTitle': 'Eksports gatavs', + 'toast.exportReadyMessage': 'Lejupielāde ir sākta.', + 'toast.exportFailedTitle': 'Eksports neizdevās' +}; diff --git a/src/main.js b/src/main.js index 3ae6b22..0e45b9d 100644 --- a/src/main.js +++ b/src/main.js @@ -1,4 +1,4 @@ -import './styles/tokens.css'; +import './styles/tokens.css'; import './styles/base.css'; import './styles/utilities.css'; import './styles/components.css'; @@ -12,7 +12,9 @@ import { ComparingView } from './ui/views/comparing-view.js'; import { ResultsView } from './ui/views/results-view.js'; import { ToastHost } from './ui/components/toast-host.js'; import { SkipLink } from './ui/components/skip-link.js'; +import { LanguageSwitcher } from './ui/components/language-switcher.js'; import { el, replaceChildren } from './ui/renderers/dom.js'; +import { t } from './i18n/index.js'; /** * Bootstrap the application. @@ -25,16 +27,21 @@ export function startApp(config = {}) { const root = document.getElementById('app'); + document.title = t('meta.title'); + const description = document.querySelector('meta[name="description"]'); + if (description) description.setAttribute('content', t('meta.description')); + const header = el('header', { class: 'app-header' }, [ el('div', { class: 'app-header__brand' }, [ el('div', { class: 'app-header__mark', text: '⇄' }), - el('span', { text: 'Two-Export Comparator' }), + el('span', { text: t('app.brand') }), ]), el('div', { class: 'app-header__meta' }, [ - el('span', { class: 'app-header__tagline', text: 'Local processing · No uploads' }), - el('span', { class: 'privacy-badge', title: 'Files never leave this browser' }, [ + el('span', { class: 'app-header__tagline', text: t('app.tagline') }), + LanguageSwitcher(), + el('span', { class: 'privacy-badge', title: t('app.filesNeverLeave') }, [ el('span', { class: 'privacy-badge__dot', 'aria-hidden': 'true' }), - el('span', { text: 'Local only' }), + el('span', { text: t('app.localOnly') }), ]), ]), ]); @@ -45,13 +52,17 @@ export function startApp(config = {}) { const resultsView = new ResultsView(store); const toastHost = new ToastHost(store); - replaceChildren(root, [ - SkipLink(), - header, + const main = el('main', { id: 'main', class: 'app-content' }, [ landingView.root, mappingView.root, comparingView.root, resultsView.root, + ]); + + replaceChildren(root, [ + SkipLink(), + header, + main, toastHost.root, ]); @@ -70,4 +81,4 @@ export function startApp(config = {}) { return { store, controller, service }; } -startApp(); \ No newline at end of file +startApp(); diff --git a/src/styles/components.css b/src/styles/components.css index 87f53dc..dc8dbb8 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -1679,4 +1679,44 @@ @media (max-width: 480px) { .app-header__brand span:last-child { font-size: var(--text-sm); } .summary-grid { grid-template-columns: 1fr; } -} \ No newline at end of file +} + +/* ========================================================================== + Language switcher + ========================================================================== */ +.language-switcher { + display: inline-flex; + align-items: center; +} + +.language-switcher__select { + appearance: none; + min-width: 54px; + height: 30px; + padding: 0 22px 0 9px; + border: 1px solid var(--color-border-1); + border-radius: var(--radius-sm); + background-color: var(--color-surface-0); + color: var(--color-text-1); + font: inherit; + font-size: var(--text-xs); + font-weight: var(--weight-semi); + cursor: pointer; + background-image: + linear-gradient(45deg, transparent 50%, var(--color-text-3) 50%), + linear-gradient(135deg, var(--color-text-3) 50%, transparent 50%); + background-position: + calc(100% - 10px) 12px, + calc(100% - 6px) 12px; + background-size: 4px 4px, 4px 4px; + background-repeat: no-repeat; +} + +.language-switcher__select:hover { + border-color: var(--color-border-2); +} + +.language-switcher__select:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} diff --git a/src/ui/components/file-panel.js b/src/ui/components/file-panel.js index eb20b0b..ec967c9 100644 --- a/src/ui/components/file-panel.js +++ b/src/ui/components/file-panel.js @@ -1,16 +1,13 @@ import { el, replaceChildren, icon, Icons } from '../renderers/dom.js'; import { AppEvents } from '../../app/app-events.js'; import { formatBytes, formatCount, orDash, delimiterLabel } from '../renderers/format.js'; +import { t } from '../../i18n/index.js'; /** * FilePanel — renders a single file slot (A or B). * Does NOT read or parse file contents itself. - * Emits FILE_A_SELECTED / FILE_B_SELECTED / FILE_A_REMOVED / FILE_B_REMOVED. */ export class FilePanel { - /** - * @param {{ slot: 'A' | 'B' }} config - */ constructor({ slot }) { this.slot = slot; this.fileEvent = slot === 'A' ? AppEvents.FILE_A_SELECTED : AppEvents.FILE_B_SELECTED; @@ -19,23 +16,18 @@ export class FilePanel { this.root = el('section', { class: 'file-panel', dataset: { slot, state: 'empty' }, - 'aria-label': `File ${slot}`, + 'aria-label': t('file.label', { slot }), }); - /** @type {import('../../models/contracts.js').FileInspection | null} */ this.inspection = null; - - /** @type {'empty' | 'loading' | 'ready' | 'warning' | 'error'} */ this.state = 'empty'; - - /** @type {string | null} */ this.errorMessage = null; this.input = el('input', { type: 'file', accept: '.csv,.tsv,text/csv,text/tab-separated-values', class: 'visually-hidden', - 'aria-label': `Choose File ${slot}`, + 'aria-label': t('file.choose', { slot }), }); this.input.addEventListener('change', () => { const file = this.input.files?.[0]; @@ -46,10 +38,6 @@ export class FilePanel { this._render(); } - /** - * Update the panel with new inspection data. - * @param {import('../../models/contracts.js').FileInspection | null} inspection - */ setInspection(inspection) { this.inspection = inspection; if (!inspection) { @@ -57,7 +45,7 @@ export class FilePanel { this.errorMessage = null; } else if (inspection.warnings?.some((w) => w.severity === 'error')) { this.state = 'error'; - this.errorMessage = inspection.warnings.find((w) => w.severity === 'error')?.title ?? 'Something went wrong'; + this.errorMessage = inspection.warnings.find((w) => w.severity === 'error')?.title ?? t('file.genericError'); } else if (inspection.warnings?.length) { this.state = 'warning'; } else { @@ -66,38 +54,29 @@ export class FilePanel { this._render(); } - /** Show a loading state while the service inspects the file. */ setLoading() { this.state = 'loading'; this._render(); } - /** - * Show an error state. - * @param {string} message - */ setError(message) { this.state = 'error'; this.errorMessage = message; this._render(); } - /** @private */ _emitSelect(file) { window.dispatchEvent(new CustomEvent(this.fileEvent, { detail: { file } })); } - /** @private */ _emitRemove() { window.dispatchEvent(new CustomEvent(this.removeEvent)); } - /** @private */ _openPicker() { this.input.click(); } - /** @private */ _render() { this.root.dataset.state = this.state; replaceChildren(this.root, [ @@ -107,32 +86,30 @@ export class FilePanel { ]); } - /** @private */ _renderHeader() { return el('header', { class: 'file-panel__header' }, [ el('div', { class: 'file-panel__label' }, [ - el('span', { class: 'file-panel__slot', text: `FILE ${this.slot}` }), + el('span', { class: 'file-panel__slot', text: t('file.label', { slot: this.slot }).toUpperCase() }), this.inspection ? el('span', { class: 'file-panel__name u-truncate', text: this.inspection.name, title: this.inspection.name, }) - : el('span', { class: 'file-panel__name file-panel__name--empty', text: 'No file selected' }), + : el('span', { class: 'file-panel__name file-panel__name--empty', text: t('file.none') }), ]), this.inspection ? el('button', { type: 'button', class: 'btn btn--ghost btn--icon', - 'aria-label': `Remove File ${this.slot}`, - title: 'Remove', + 'aria-label': t('file.removeSlot', { slot: this.slot }), + title: t('file.remove'), onclick: () => this._emitRemove(), }, [icon(Icons.x, { size: 14 })]) : null, ]); } - /** @private */ _renderBody() { switch (this.state) { case 'loading': @@ -142,24 +119,22 @@ export class FilePanel { return this._renderReady(); case 'error': return this._renderError(); - case 'empty': default: return this._renderDropzone(); } } - /** @private */ _renderDropzone() { const zone = el('button', { type: 'button', class: 'file-panel__dropzone', - 'aria-label': `Choose File ${this.slot} — CSV or TSV`, + 'aria-label': t('file.chooseCsv', { slot: this.slot }), onclick: () => this._openPicker(), }, [ el('div', { class: 'file-panel__dropzone-icon' }, [icon(Icons.upload, { size: 22 })]), el('div', { class: 'file-panel__dropzone-text' }, [ - el('span', { class: 'file-panel__dropzone-primary', text: 'Drop CSV/TSV or browse' }), - el('span', { class: 'file-panel__dropzone-secondary', text: 'Files stay in this browser' }), + el('span', { class: 'file-panel__dropzone-primary', text: t('file.drop') }), + el('span', { class: 'file-panel__dropzone-secondary', text: t('file.staysLocal') }), ]), ]); @@ -180,7 +155,6 @@ export class FilePanel { return zone; } - /** @private */ _renderLoading() { return el('div', { class: 'file-panel__body file-panel__body--loading' }, [ el('div', { class: 'skeleton skeleton--line skeleton--w60' }), @@ -190,17 +164,16 @@ export class FilePanel { ]); } - /** @private */ _renderReady() { const i = this.inspection; const children = []; children.push(el('dl', { class: 'file-panel__meta' }, [ - this._metaItem('Rows', formatCount(i.rowCount)), - this._metaItem('Columns', formatCount(i.columns.length)), - this._metaItem('Delimiter', delimiterLabel(i.delimiter)), - this._metaItem('Encoding', orDash(i.encoding)), - this._metaItem('Size', formatBytes(i.sizeBytes)), + this._metaItem(t('file.rows'), formatCount(i.rowCount)), + this._metaItem(t('file.columns'), formatCount(i.columns.length)), + this._metaItem(t('file.delimiter'), delimiterLabel(i.delimiter)), + this._metaItem(t('file.encoding'), orDash(i.encoding)), + this._metaItem(t('file.size'), formatBytes(i.sizeBytes)), ])); if (i.warnings?.length) { @@ -228,7 +201,6 @@ export class FilePanel { return el('div', { class: 'file-panel__body' }, children); } - /** @private */ _metaItem(label, value) { return el('div', { class: 'file-panel__meta-item' }, [ el('dt', { class: 'file-panel__meta-label', text: label }), @@ -236,7 +208,6 @@ export class FilePanel { ]); } - /** @private */ _renderPreview(i) { const cols = i.columns.slice(0, 5); @@ -253,34 +224,36 @@ export class FilePanel { )); return el('div', { class: 'file-panel__preview' }, [ - el('div', { class: 'file-panel__preview-label u-text-xs u-text-faint', text: 'PREVIEW' }), + el('div', { class: 'file-panel__preview-label u-text-xs u-text-faint', text: t('file.preview') }), el('div', { class: 'file-panel__preview-scroll' }, [ el('table', { class: 'file-panel__preview-table' }, [thead, tbody]), ]), i.rowCount && i.rowCount > i.previewRows.length ? el('div', { class: 'file-panel__preview-foot u-text-xs u-text-faint', - text: `Showing ${Math.min(i.previewRows.length, 5)} of ${formatCount(i.rowCount)} rows`, + text: t('file.showingRows', { + shown: Math.min(i.previewRows.length, 5), + total: formatCount(i.rowCount), + }), }) : null, ]); } - /** @private */ _renderError() { return el('div', { class: 'file-panel__body' }, [ el('div', { class: 'toast', dataset: { severity: 'error' }, role: 'alert' }, [ icon(Icons.alert, { size: 16 }), el('div', {}, [ - el('div', { class: 'u-weight-semi', text: "We couldn't read that file" }), - el('div', { class: 'u-text-sm u-text-muted', text: this.errorMessage ?? 'Try a different CSV or TSV export.' }), + el('div', { class: 'u-weight-semi', text: t('file.readError') }), + el('div', { class: 'u-text-sm u-text-muted', text: this.errorMessage ?? t('file.tryDifferent') }), ]), ]), el('button', { type: 'button', class: 'btn btn--secondary btn--sm u-mt-2', onclick: () => this._openPicker(), - }, [icon(Icons.refresh, { size: 14 }), 'Choose another file']), + }, [icon(Icons.refresh, { size: 14 }), t('file.chooseAnother')]), ]); } -} \ No newline at end of file +} diff --git a/src/ui/components/language-switcher.js b/src/ui/components/language-switcher.js new file mode 100644 index 0000000..84122ef --- /dev/null +++ b/src/ui/components/language-switcher.js @@ -0,0 +1,25 @@ +import { el } from '../renderers/dom.js'; +import { getLocale, getSupportedLocales, setLocale, t } from '../../i18n/index.js'; + +export function LanguageSwitcher() { + const current = getLocale(); + + const select = el('select', { + class: 'language-switcher__select', + 'aria-label': t('language.label'), + title: t('language.label'), + onchange: (event) => setLocale(event.target.value), + }); + + for (const locale of getSupportedLocales()) { + const option = el('option', { + value: locale.code, + text: locale.shortLabel, + title: locale.nativeName, + }); + if (locale.code === current) option.selected = true; + select.appendChild(option); + } + + return el('div', { class: 'language-switcher' }, [select]); +} diff --git a/src/ui/components/privacy-note.js b/src/ui/components/privacy-note.js index 85747fc..7f60e8c 100644 --- a/src/ui/components/privacy-note.js +++ b/src/ui/components/privacy-note.js @@ -1,4 +1,5 @@ import { el, icon, Icons } from '../renderers/dom.js'; +import { t } from '../../i18n/index.js'; /** * PrivacyNote — accurately states local processing. No legal guarantees. @@ -6,8 +7,6 @@ import { el, icon, Icons } from '../renderers/dom.js'; export function PrivacyNote() { return el('div', { class: 'privacy-note', role: 'note' }, [ icon(Icons.shield, { size: 14 }), - el('span', { - text: 'Your files are processed locally in this browser and are not uploaded by this application.', - }), + el('span', { text: t('privacy.local') }), ]); -} \ No newline at end of file +} diff --git a/src/ui/components/skip-link.js b/src/ui/components/skip-link.js index a2b9fd5..2347435 100644 --- a/src/ui/components/skip-link.js +++ b/src/ui/components/skip-link.js @@ -1,9 +1,10 @@ import { el } from '../renderers/dom.js'; +import { t } from '../../i18n/index.js'; export function SkipLink() { return el('a', { href: '#main', class: 'skip-link', - text: 'Skip to content', + text: t('skip.content'), }); -} \ No newline at end of file +} diff --git a/src/ui/renderers/format.js b/src/ui/renderers/format.js index 1178acd..028bfe6 100644 --- a/src/ui/renderers/format.js +++ b/src/ui/renderers/format.js @@ -1,46 +1,31 @@ +import { getLocale, t } from '../../i18n/index.js'; + /** * @file Formatting utilities for displaying metadata. */ -/** - * @param {number} bytes - * @returns {string} - */ export function formatBytes(bytes) { if (!Number.isFinite(bytes) || bytes < 0) return '—'; - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; - return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; + if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; + return (bytes / (1024 * 1024 * 1024)).toFixed(1) + ' GB'; } -/** - * @param {number | null | undefined} n - * @returns {string} - */ export function formatCount(n) { if (n === null || n === undefined || !Number.isFinite(n)) return '—'; - return new Intl.NumberFormat('en-US').format(n); + return new Intl.NumberFormat(getLocale()).format(n); } -/** - * @param {string | null | undefined} s - * @returns {string} - */ export function orDash(s) { return s === null || s === undefined || s === '' ? '—' : String(s); } -/** - * Human-readable delimiter label. - * @param {string | null | undefined} d - * @returns {string} - */ export function delimiterLabel(d) { if (!d) return '—'; - if (d === ',') return 'comma'; - if (d === '\t') return 'tab'; - if (d === ';') return 'semicolon'; - if (d === '|') return 'pipe'; - return `"${d}"`; -} \ No newline at end of file + if (d === ',') return t('delimiter.comma'); + if (d === '\t') return t('delimiter.tab'); + if (d === ';') return t('delimiter.semicolon'); + if (d === '|') return t('delimiter.pipe'); + return '"' + d + '"'; +} diff --git a/src/ui/views/comparing-view.js b/src/ui/views/comparing-view.js index 5b631e2..b4a3505 100644 --- a/src/ui/views/comparing-view.js +++ b/src/ui/views/comparing-view.js @@ -1,10 +1,9 @@ import { el, replaceChildren, icon, Icons } from '../renderers/dom.js'; export class ComparingView { - /** @param {import('../../app/app-store.js').AppStore} store */ constructor(store) { this.store = store; - this.root = el('main', { class: 'app-main', id: 'main', hidden: '' }); + this.root = el('section', { class: 'app-main', hidden: '' }); store.addEventListener('state', () => this._sync()); store.addEventListener('change', () => this._sync()); this._sync(); @@ -47,7 +46,7 @@ export class ComparingView { }, [ el('div', { class: 'comparing__bar-fill', - style: determinate ? `width:${pct}%` : '', + style: determinate ? 'width:' + pct + '%' : '', dataset: { indeterminate: determinate ? 'false' : 'true' }, }), ]), @@ -68,4 +67,4 @@ export class ComparingView { ]), ]); } -} \ No newline at end of file +} diff --git a/src/ui/views/landing-view.js b/src/ui/views/landing-view.js index 3923393..cfc2759 100644 --- a/src/ui/views/landing-view.js +++ b/src/ui/views/landing-view.js @@ -1,6 +1,7 @@ import { el, icon, Icons } from '../renderers/dom.js'; import { FilePanel } from '../components/file-panel.js'; import { PrivacyNote } from '../components/privacy-note.js'; +import { t } from '../../i18n/index.js'; export class LandingView { /** @param {import('../../app/app-store.js').AppStore} store */ @@ -9,18 +10,18 @@ export class LandingView { this.filePanelA = new FilePanel({ slot: 'A' }); this.filePanelB = new FilePanel({ slot: 'B' }); - this.root = el('main', { class: 'landing', id: 'main' }, [ + this.root = el('section', { class: 'landing' }, [ el('section', { class: 'landing__hero' }, [ el('h1', { class: 'landing__title' }, [ - 'Compare two exports. ', - el('span', { class: 'landing__title-accent', text: "Find what doesn't match." }), + t('landing.titlePrefix'), + el('span', { class: 'landing__title-accent', text: t('landing.titleAccent') }), ]), el('p', { class: 'landing__subtitle', - text: 'Two files in. Differences out. No account, no upload, no cloud database.', + text: t('landing.subtitle'), }), ]), - el('section', { class: 'landing__workflow', 'aria-label': 'File selection' }, [ + el('section', { class: 'landing__workflow', 'aria-label': t('landing.fileSelection') }, [ this.filePanelA.root, el('div', { class: 'landing__connector', 'aria-hidden': 'true' }, [icon(Icons.arrowRight, { size: 18 })]), this.filePanelB.root, @@ -37,4 +38,4 @@ export class LandingView { this.filePanelA.setInspection(this.store.fileA); this.filePanelB.setInspection(this.store.fileB); } -} \ No newline at end of file +} diff --git a/src/ui/views/mapping-view.js b/src/ui/views/mapping-view.js index 4adc89f..8ae1fa0 100644 --- a/src/ui/views/mapping-view.js +++ b/src/ui/views/mapping-view.js @@ -2,15 +2,11 @@ import { el } from '../renderers/dom.js'; import { MappingEditor } from '../components/mapping-editor.js'; export class MappingView { - /** - * @param {import('../../app/app-store.js').AppStore} store - * @param {import('../../services/comparison-service.js').ComparisonService} service - */ constructor(store, service) { this.store = store; this.editor = new MappingEditor(store, service); - this.root = el('main', { class: 'app-main', id: 'main', hidden: '' }, [ + this.root = el('section', { class: 'app-main', hidden: '' }, [ el('div', { class: 'u-container' }, [this.editor.root]), ]); @@ -22,4 +18,4 @@ export class MappingView { const active = ['MAPPING', 'READY_TO_COMPARE'].includes(this.store.state); this.root.hidden = !active; } -} \ No newline at end of file +} diff --git a/src/ui/views/results-view.js b/src/ui/views/results-view.js index 612deef..3d759a7 100644 --- a/src/ui/views/results-view.js +++ b/src/ui/views/results-view.js @@ -15,17 +15,13 @@ const CARDS = [ { status: 'MATCHED', label: 'Matched' }, ]; -/** - * ResultsView — summary cards, filters, table, drawer, export. - */ export class ResultsView { - /** @param {import('../../app/app-store.js').AppStore} store */ constructor(store) { this.store = store; this.table = new ResultsTable(store); this.drawer = new RecordDetailDrawer(store); - this.root = el('main', { class: 'app-main', id: 'main', hidden: '' }); + this.root = el('section', { class: 'app-main', hidden: '' }); store.addEventListener('state', () => this._sync()); store.addEventListener('change', () => this._sync()); this._sync(); @@ -43,7 +39,7 @@ export class ResultsView { _countFor(status) { const s = this.store.result.summary; - if (status === 'ALL') return s.totalA + s.totalB; + if (status === 'ALL') return s.matched + s.onlyA + s.onlyB + s.mismatched + s.duplicates + s.ambiguous; if (status === 'MISMATCH') return s.mismatched; if (status === 'ONLY_A') return s.onlyA; if (status === 'ONLY_B') return s.onlyB; @@ -79,7 +75,7 @@ export class ResultsView { el('h2', { class: 'results-header__title', text: 'Comparison complete' }), el('p', { class: 'results-header__sub u-text-muted', - text: `${formatCount(s.totalA)} rows in File A · ${formatCount(s.totalB)} rows in File B`, + text: formatCount(s.totalA) + ' rows in File A · ' + formatCount(s.totalB) + ' rows in File B', }), ]), el('div', { class: 'results-header__actions' }, [ @@ -95,12 +91,10 @@ export class ResultsView { }, [icon(Icons.table, { size: 14 }), 'Export CSV']), ]), ]), - el('div', { class: 'summary-grid', role: 'tablist', 'aria-label': 'Result filters' }, cards), - this.table.root, ]), this.drawer.root, ]); } -} \ No newline at end of file +} From 97be3c380743cf49b955997011cf114d4167b934 Mon Sep 17 00:00:00 2001 From: QvarcY <118832438+QvarcY@users.noreply.github.com> Date: Sat, 26 Sep 2026 19:04:10 +0300 Subject: [PATCH 3/9] fix: make language switching live and preserve workflow state --- ROADMAP.md | 19 +- src/app/app-controller.js | 161 ++++++++++------ src/app/app-store.js | 66 ++----- src/i18n/index.js | 24 ++- src/main.js | 50 +++-- src/services/mock-comparison-service.js | 5 +- src/ui/components/file-panel.js | 92 +++++---- src/ui/components/language-switcher.js | 27 ++- src/ui/components/mapping-editor.js | 220 ++++++++++++---------- src/ui/components/mapping-row.js | 130 +++++++------ src/ui/components/record-detail-drawer.js | 159 ++++++++++------ src/ui/components/results-table.js | 126 ++++++++----- src/ui/components/skip-link.js | 14 +- src/ui/components/toast-host.js | 41 ++-- src/ui/views/comparing-view.js | 58 ++++-- src/ui/views/landing-view.js | 33 ++-- src/ui/views/results-view.js | 74 +++++--- 17 files changed, 749 insertions(+), 550 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e19594f..acf4b0c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -27,12 +27,19 @@ Status: ✅ complete · 🟡 in progress · ⬜ planned · ⛔ blocked - ✅ Pagination instead of rendering the whole result set. - ✅ English + Latvian i18n registry foundation. - ✅ Language selector driven by locale registry. +- ✅ Live EN/LV switching without page reload. +- ✅ Language switching preserves in-memory files and mapping state. +- ✅ EN/LV coverage through the main workflow. - ✅ English and Latvian README. - ✅ Translation contribution guide. - ✅ Canonical roadmap. -- 🟡 Translate every remaining mapping/results/error string through i18n. -- 🟡 Resolve frontend technical audit findings. -- ⬜ Verify clean production build after the full frontend cleanup. +- ✅ One real main landmark instead of duplicate main IDs. +- ✅ Mock file replacement keeps stable A/B slot IDs. +- ✅ Comparison failure returns to a usable mapping state. +- ✅ Package metadata aligned with the Vite 7 lockfile baseline. +- ✅ Repository line-ending policy added. +- 🟡 Clean production build verification pending. +- 🟡 Source-generated parser warnings will move to code-based localization in Phase 2. **Exit criteria:** complete bilingual UI, clean build, no duplicate IDs, package metadata consistent, no known frontend blocker. @@ -133,9 +140,9 @@ Status: ✅ complete · 🟡 in progress · ⬜ planned · ⛔ blocked | --- | --- | --- | --- | | 2026-09-26 | R0 — Scaffold | ✅ | Vite project, service boundary, initial repository baseline. | | 2026-09-26 | R1 — Frontend prototype | ✅ | File panels, mapping UI, progress view, result dashboard and drawer added. | -| 2026-09-26 | R2 — i18n + documentation foundation | ✅ | EN/LV locale registry, language selector foundation, bilingual README, translation guide, canonical roadmap, line-ending policy and package metadata cleanup. | -| — | R3 — Full UI localization + frontend audit | 🟡 | Next: wire every remaining UI string to i18n and close known frontend audit findings. | +| 2026-09-26 | R2 — i18n + documentation foundation | ✅ | EN/LV locale registry, bilingual README, translation guide, roadmap, line-ending policy and package cleanup. | +| 2026-09-26 | R3 — Live i18n + frontend cleanup | 🟡 | Fixed language-switch reset/crash, wired EN/LV through the workflow, kept files in memory during locale changes, fixed mock slot replacement and comparison error recovery. Build verification pending. | ## Next target / Nākamais mērķis -**R3:** complete EN/LV coverage through the whole workflow, finish frontend technical cleanup, then produce the first clean production build before implementing the real parser. +**R3 exit check:** run a clean production build and switch EN ↔ LV while files are already selected and while the mapping screen is open. After that, start Phase 2 with the real CSV/TSV inspector. diff --git a/src/app/app-controller.js b/src/app/app-controller.js index e059d24..cc8d3eb 100644 --- a/src/app/app-controller.js +++ b/src/app/app-controller.js @@ -1,14 +1,6 @@ import { AppEvents } from './app-events.js'; -/** - * AppController — coordinates user actions and service calls. - * Contains NO CSV parsing and NO matching algorithms. - */ export class AppController { - /** - * @param {import('./app-store.js').AppStore} store - * @param {import('../services/comparison-service.js').ComparisonService} service - */ constructor(store, service) { this.store = store; this.service = service; @@ -16,110 +8,159 @@ export class AppController { } _wireEvents() { - window.addEventListener(AppEvents.FILE_A_SELECTED, (e) => this.handleFileSelected('A', e.detail.file)); - window.addEventListener(AppEvents.FILE_B_SELECTED, (e) => this.handleFileSelected('B', e.detail.file)); + window.addEventListener(AppEvents.FILE_A_SELECTED, (event) => { + this.handleFileSelected('A', event.detail.file); + }); + + window.addEventListener(AppEvents.FILE_B_SELECTED, (event) => { + this.handleFileSelected('B', event.detail.file); + }); + window.addEventListener(AppEvents.FILE_A_REMOVED, () => this.handleFileRemoved('A')); window.addEventListener(AppEvents.FILE_B_REMOVED, () => this.handleFileRemoved('B')); - window.addEventListener(AppEvents.COMPARE_REQUESTED, (e) => this.handleCompare(e.detail.mapping)); - window.addEventListener(AppEvents.EXPORT_REQUESTED, (e) => this.handleExport(e.detail)); + window.addEventListener(AppEvents.COMPARE_REQUESTED, (event) => { + this.handleCompare(event.detail.mapping); + }); + window.addEventListener(AppEvents.EXPORT_REQUESTED, (event) => { + this.handleExport(event.detail); + }); window.addEventListener(AppEvents.RESET_REQUESTED, () => this.handleReset()); } - /** - * @param {'A' | 'B'} slot - * @param {File} file - */ async handleFileSelected(slot, file) { + const loadingKey = slot === 'A' ? 'fileLoadingA' : 'fileLoadingB'; + this.store.patch({ [loadingKey]: true }); + try { - const inspection = await this.service.inspectFile(file); - const patch = slot === 'A' ? { fileA: inspection } : { fileB: inspection }; - this.store.patch(patch); + const inspection = await this.service.inspectFile(file, { slot }); + const filePatch = slot === 'A' + ? { fileA: inspection } + : { fileB: inspection }; + + this.store.patch({ + ...filePatch, + [loadingKey]: false, + }); + this._recomputeState(); - } catch (err) { + } catch (error) { + this.store.patch({ [loadingKey]: false }); + this.store.pushToast({ severity: 'error', - title: "We couldn't read that file", - message: 'Try a different CSV or TSV export.', + titleKey: 'toast.readErrorTitle', + messageKey: 'toast.readErrorMessage', }); - // eslint-disable-next-line no-console - console.error('[AppController] inspectFile failed:', err); + + console.error('[AppController] inspectFile failed:', error); } } - /** - * @param {'A' | 'B'} slot - */ handleFileRemoved(slot) { - const patch = slot === 'A' ? { fileA: null } : { fileB: null }; - this.store.patch(patch); + this.store.patch( + slot === 'A' + ? { fileA: null, fileLoadingA: false } + : { fileB: null, fileLoadingB: false } + ); + this._recomputeState(); } - /** - * @param {import('../models/contracts.js').MappingConfig} mapping - */ async handleCompare(mapping) { const { fileA, fileB } = this.store; if (!fileA || !fileB) return; - this.store.patch({ mapping, progress: null, result: null }); + this.store.patch({ + mapping, + progress: null, + result: null, + }); + this.store.setState('COMPARING'); try { const result = await this.service.compare( - { fileAId: fileA.id, fileBId: fileB.id, mapping }, + { + fileAId: fileA.id, + fileBId: fileB.id, + mapping, + }, { onProgress: (progress) => { this.store.patch({ progress }); - window.dispatchEvent(new CustomEvent(AppEvents.COMPARE_PROGRESS, { detail: progress })); + window.dispatchEvent(new CustomEvent(AppEvents.COMPARE_PROGRESS, { + detail: progress, + })); }, } ); - this.store.patch({ result, progress: null }); + + this.store.patch({ + result, + progress: null, + }); + this.store.setState('RESULTS'); - window.dispatchEvent(new CustomEvent(AppEvents.COMPARE_SUCCEEDED, { detail: result })); - } catch (err) { + + window.dispatchEvent(new CustomEvent(AppEvents.COMPARE_SUCCEEDED, { + detail: result, + })); + } catch (error) { this.store.patch({ progress: null }); - this.store.setState('ERROR'); + this.store.setState(this.store.mappingValid ? 'READY_TO_COMPARE' : 'MAPPING'); + this.store.pushToast({ severity: 'error', - title: 'Comparison failed', - message: 'Try adjusting the mapping or the files.', + titleKey: 'toast.compareFailedTitle', + messageKey: 'toast.compareFailedMessage', }); - window.dispatchEvent(new CustomEvent(AppEvents.COMPARE_FAILED, { detail: err })); - // eslint-disable-next-line no-console - console.error('[AppController] compare failed:', err); + + window.dispatchEvent(new CustomEvent(AppEvents.COMPARE_FAILED, { + detail: error, + })); + + console.error('[AppController] compare failed:', error); } } - /** - * @param {import('../models/contracts.js').ExportOptions} options - */ async handleExport(options = {}) { if (!this.store.result) return; + try { const blob = await this.service.createExport(this.store.result, options); const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `comparison-${this.store.result.runId ?? 'export'}.csv`; - document.body.appendChild(a); - a.click(); - a.remove(); + const anchor = document.createElement('a'); + + anchor.href = url; + anchor.download = 'comparison-' + (this.store.result.runId ?? 'export') + '.csv'; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); URL.revokeObjectURL(url); - this.store.pushToast({ severity: 'info', title: 'Export ready', message: 'Your download has started.' }); - } catch (err) { - this.store.pushToast({ severity: 'error', title: 'Export failed' }); - // eslint-disable-next-line no-console - console.error('[AppController] export failed:', err); + + this.store.pushToast({ + severity: 'info', + titleKey: 'toast.exportReadyTitle', + messageKey: 'toast.exportReadyMessage', + }); + } catch (error) { + this.store.pushToast({ + severity: 'error', + titleKey: 'toast.exportFailedTitle', + }); + + console.error('[AppController] export failed:', error); } } handleReset() { this.service.reset(); + this.store.patch({ fileA: null, fileB: null, + fileLoadingA: false, + fileLoadingB: false, mapping: null, mappingValid: false, progress: null, @@ -127,11 +168,13 @@ export class AppController { resultFilter: 'ALL', openRecordId: null, }); + this.store.setState('EMPTY'); } _recomputeState() { const { fileA, fileB } = this.store; + if (!fileA && !fileB) { this.store.setState('EMPTY'); } else if (fileA && fileB) { @@ -140,4 +183,4 @@ export class AppController { this.store.setState('FILES_PARTIAL'); } } -} \ No newline at end of file +} diff --git a/src/app/app-store.js b/src/app/app-store.js index 058b34b..de5babe 100644 --- a/src/app/app-store.js +++ b/src/app/app-store.js @@ -1,85 +1,51 @@ import { AppEvents } from './app-events.js'; -/** - * @typedef {'EMPTY' | 'FILES_PARTIAL' | 'FILES_READY' | 'MAPPING' - * | 'READY_TO_COMPARE' | 'COMPARING' | 'RESULTS' | 'ERROR'} AppStateName - */ - -/** - * AppStore — single source of truth for UI state. - * Never persists imported business data to browser storage. - */ export class AppStore extends EventTarget { constructor() { super(); - /** @type {AppStateName} */ this.state = 'EMPTY'; - - /** @type {import('../models/contracts.js').FileInspection | null} */ this.fileA = null; - - /** @type {import('../models/contracts.js').FileInspection | null} */ this.fileB = null; - - /** @type {import('../models/contracts.js').MappingConfig | null} */ + this.fileLoadingA = false; + this.fileLoadingB = false; this.mapping = null; - - /** @type {boolean} */ this.mappingValid = false; - - /** @type {import('../models/contracts.js').ProgressEvent | null} */ this.progress = null; - - /** @type {import('../models/contracts.js').ComparisonResult | null} */ this.result = null; - - /** @type {import('../models/contracts.js').ResultStatus | 'ALL'} */ this.resultFilter = 'ALL'; - - /** @type {string | null} */ this.openRecordId = null; - - /** @type {Array<{ id: string, severity: string, title: string, message?: string }>} */ this.toasts = []; } - /** - * Transition to a new state and notify subscribers. - * @param {AppStateName} next - */ setState(next) { if (this.state === next) return; - const prev = this.state; + + const previous = this.state; this.state = next; - this.dispatchEvent(new CustomEvent('state', { detail: { prev, next } })); + this.dispatchEvent(new CustomEvent('state', { + detail: { prev: previous, next }, + })); } - /** - * Merge a partial patch into the store and emit a change event. - * @param {object} patch - */ patch(patch) { Object.assign(this, patch); this.dispatchEvent(new CustomEvent('change', { detail: patch })); } - /** - * Show a transient toast. - * @param {{ severity: 'info'|'warning'|'error', title: string, message?: string }} toast - */ pushToast(toast) { - const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; + const id = 'toast-' + Date.now() + '-' + Math.random().toString(36).slice(2, 7); this.toasts = [...this.toasts, { id, ...toast }]; - this.dispatchEvent(new CustomEvent(AppEvents.TOAST_SHOWN, { detail: { id, ...toast } })); + this.dispatchEvent(new CustomEvent(AppEvents.TOAST_SHOWN, { + detail: { id, ...toast }, + })); return id; } - /** - * @param {string} id - */ dismissToast(id) { - this.toasts = this.toasts.filter((t) => t.id !== id); - this.dispatchEvent(new CustomEvent(AppEvents.TOAST_DISMISSED, { detail: { id } })); + this.toasts = this.toasts.filter((toast) => toast.id !== id); + this.dispatchEvent(new CustomEvent(AppEvents.TOAST_DISMISSED, { + detail: { id }, + })); } -} \ No newline at end of file +} diff --git a/src/i18n/index.js b/src/i18n/index.js index 77c73d2..1d660fe 100644 --- a/src/i18n/index.js +++ b/src/i18n/index.js @@ -1,6 +1,7 @@ import { localeRegistry } from './locales/index.js'; const FALLBACK_LOCALE = 'en'; +export const LOCALE_CHANGE_EVENT = 'tec:localechange'; function normalizeLocale(value) { return String(value || '').trim().toLowerCase().split('-')[0]; @@ -25,8 +26,12 @@ function detectLocale() { let activeLocale = detectLocale(); -document.documentElement.lang = activeLocale; -document.documentElement.dataset.locale = activeLocale; +function applyDocumentLocale() { + document.documentElement.lang = activeLocale; + document.documentElement.dataset.locale = activeLocale; +} + +applyDocumentLocale(); export function getLocale() { return activeLocale; @@ -56,7 +61,20 @@ export function setLocale(locale) { const next = normalizeLocale(locale); if (!localeRegistry[next] || next === activeLocale) return; + const previous = activeLocale; + activeLocale = next; + applyDocumentLocale(); + const url = new URL(window.location.href); url.searchParams.set('lang', next); - window.location.assign(url.toString()); + + try { + window.history.replaceState(window.history.state, '', url.toString()); + } catch { + // The language switch must still work if the URL cannot be rewritten. + } + + window.dispatchEvent(new CustomEvent(LOCALE_CHANGE_EVENT, { + detail: { previous, current: next }, + })); } diff --git a/src/main.js b/src/main.js index 0e45b9d..172d01a 100644 --- a/src/main.js +++ b/src/main.js @@ -14,35 +14,31 @@ import { ToastHost } from './ui/components/toast-host.js'; import { SkipLink } from './ui/components/skip-link.js'; import { LanguageSwitcher } from './ui/components/language-switcher.js'; import { el, replaceChildren } from './ui/renderers/dom.js'; -import { t } from './i18n/index.js'; +import { LOCALE_CHANGE_EVENT, t } from './i18n/index.js'; -/** - * Bootstrap the application. - * @param {{ comparisonService?: import('./services/comparison-service.js').ComparisonService }} [config] - */ export function startApp(config = {}) { const service = config.comparisonService ?? new MockComparisonService(); const store = new AppStore(); const controller = new AppController(store, service); - const root = document.getElementById('app'); - document.title = t('meta.title'); - const description = document.querySelector('meta[name="description"]'); - if (description) description.setAttribute('content', t('meta.description')); + const brandText = el('span'); + const tagline = el('span', { class: 'app-header__tagline' }); + const privacyText = el('span'); + const privacyBadge = el('span', { class: 'privacy-badge' }, [ + el('span', { class: 'privacy-badge__dot', 'aria-hidden': 'true' }), + privacyText, + ]); const header = el('header', { class: 'app-header' }, [ el('div', { class: 'app-header__brand' }, [ el('div', { class: 'app-header__mark', text: '⇄' }), - el('span', { text: t('app.brand') }), + brandText, ]), el('div', { class: 'app-header__meta' }, [ - el('span', { class: 'app-header__tagline', text: t('app.tagline') }), + tagline, LanguageSwitcher(), - el('span', { class: 'privacy-badge', title: t('app.filesNeverLeave') }, [ - el('span', { class: 'privacy-badge__dot', 'aria-hidden': 'true' }), - el('span', { text: t('app.localOnly') }), - ]), + privacyBadge, ]), ]); @@ -66,13 +62,27 @@ export function startApp(config = {}) { toastHost.root, ]); - const sync = () => { + const syncLanguage = () => { + document.title = t('meta.title'); + const description = document.querySelector('meta[name="description"]'); + if (description) description.setAttribute('content', t('meta.description')); + + brandText.textContent = t('app.brand'); + tagline.textContent = t('app.tagline'); + privacyText.textContent = t('app.localOnly'); + privacyBadge.title = t('app.filesNeverLeave'); + }; + + const syncState = () => { root.dataset.state = store.state; - const showLanding = ['EMPTY', 'FILES_PARTIAL', 'FILES_READY'].includes(store.state); - landingView.root.hidden = !showLanding; + landingView.root.hidden = !['EMPTY', 'FILES_PARTIAL', 'FILES_READY'].includes(store.state); }; - store.addEventListener('state', sync); - sync(); + + window.addEventListener(LOCALE_CHANGE_EVENT, syncLanguage); + store.addEventListener('state', syncState); + + syncLanguage(); + syncState(); if (import.meta.env?.DEV) { window.__app = { store, controller, service }; diff --git a/src/services/mock-comparison-service.js b/src/services/mock-comparison-service.js index e50bc4f..1c0192d 100644 --- a/src/services/mock-comparison-service.js +++ b/src/services/mock-comparison-service.js @@ -16,10 +16,11 @@ export class MockComparisonService extends ComparisonService { * @param {object} [options] * @returns {Promise} */ - async inspectFile(file, _options = {}) { + async inspectFile(file, options = {}) { await this._delay(420); - const id = `file-${this._files.size === 0 ? 'a' : 'b'}`; + const slot = options.slot === 'A' || options.slot === 'B' ? options.slot : null; + const id = slot ? 'file-' + slot.toLowerCase() : (this._files.has('file-a') ? 'file-b' : 'file-a'); const isA = id === 'file-a'; /** @type {import('../models/contracts.js').FileInspection} */ diff --git a/src/ui/components/file-panel.js b/src/ui/components/file-panel.js index ec967c9..5676333 100644 --- a/src/ui/components/file-panel.js +++ b/src/ui/components/file-panel.js @@ -1,12 +1,8 @@ import { el, replaceChildren, icon, Icons } from '../renderers/dom.js'; import { AppEvents } from '../../app/app-events.js'; import { formatBytes, formatCount, orDash, delimiterLabel } from '../renderers/format.js'; -import { t } from '../../i18n/index.js'; +import { LOCALE_CHANGE_EVENT, t } from '../../i18n/index.js'; -/** - * FilePanel — renders a single file slot (A or B). - * Does NOT read or parse file contents itself. - */ export class FilePanel { constructor({ slot }) { this.slot = slot; @@ -16,7 +12,6 @@ export class FilePanel { this.root = el('section', { class: 'file-panel', dataset: { slot, state: 'empty' }, - 'aria-label': t('file.label', { slot }), }); this.inspection = null; @@ -27,14 +22,15 @@ export class FilePanel { type: 'file', accept: '.csv,.tsv,text/csv,text/tab-separated-values', class: 'visually-hidden', - 'aria-label': t('file.choose', { slot }), }); + this.input.addEventListener('change', () => { const file = this.input.files?.[0]; if (file) this._emitSelect(file); this.input.value = ''; }); + window.addEventListener(LOCALE_CHANGE_EVENT, () => this._render()); this._render(); } @@ -43,9 +39,9 @@ export class FilePanel { if (!inspection) { this.state = 'empty'; this.errorMessage = null; - } else if (inspection.warnings?.some((w) => w.severity === 'error')) { + } else if (inspection.warnings?.some((warning) => warning.severity === 'error')) { this.state = 'error'; - this.errorMessage = inspection.warnings.find((w) => w.severity === 'error')?.title ?? t('file.genericError'); + this.errorMessage = inspection.warnings.find((warning) => warning.severity === 'error')?.title ?? t('file.genericError'); } else if (inspection.warnings?.length) { this.state = 'warning'; } else { @@ -79,6 +75,9 @@ export class FilePanel { _render() { this.root.dataset.state = this.state; + this.root.setAttribute('aria-label', t('file.label', { slot: this.slot })); + this.input.setAttribute('aria-label', t('file.choose', { slot: this.slot })); + replaceChildren(this.root, [ this.input, this._renderHeader(), @@ -111,17 +110,10 @@ export class FilePanel { } _renderBody() { - switch (this.state) { - case 'loading': - return this._renderLoading(); - case 'ready': - case 'warning': - return this._renderReady(); - case 'error': - return this._renderError(); - default: - return this._renderDropzone(); - } + if (this.state === 'loading') return this._renderLoading(); + if (this.state === 'ready' || this.state === 'warning') return this._renderReady(); + if (this.state === 'error') return this._renderError(); + return this._renderDropzone(); } _renderDropzone() { @@ -138,17 +130,19 @@ export class FilePanel { ]), ]); - zone.addEventListener('dragover', (e) => { - e.preventDefault(); + zone.addEventListener('dragover', (event) => { + event.preventDefault(); zone.dataset.dragover = 'true'; }); + zone.addEventListener('dragleave', () => { delete zone.dataset.dragover; }); - zone.addEventListener('drop', (e) => { - e.preventDefault(); + + zone.addEventListener('drop', (event) => { + event.preventDefault(); delete zone.dataset.dragover; - const file = e.dataTransfer?.files?.[0]; + const file = event.dataTransfer?.files?.[0]; if (file) this._emitSelect(file); }); @@ -165,37 +159,37 @@ export class FilePanel { } _renderReady() { - const i = this.inspection; + const inspection = this.inspection; const children = []; children.push(el('dl', { class: 'file-panel__meta' }, [ - this._metaItem(t('file.rows'), formatCount(i.rowCount)), - this._metaItem(t('file.columns'), formatCount(i.columns.length)), - this._metaItem(t('file.delimiter'), delimiterLabel(i.delimiter)), - this._metaItem(t('file.encoding'), orDash(i.encoding)), - this._metaItem(t('file.size'), formatBytes(i.sizeBytes)), + this._metaItem(t('file.rows'), formatCount(inspection.rowCount)), + this._metaItem(t('file.columns'), formatCount(inspection.columns.length)), + this._metaItem(t('file.delimiter'), delimiterLabel(inspection.delimiter)), + this._metaItem(t('file.encoding'), orDash(inspection.encoding)), + this._metaItem(t('file.size'), formatBytes(inspection.sizeBytes)), ])); - if (i.warnings?.length) { + if (inspection.warnings?.length) { children.push(el('div', { class: 'file-panel__warnings' }, - i.warnings.slice(0, 3).map((w) => + inspection.warnings.slice(0, 3).map((warning) => el('div', { class: 'toast', - dataset: { severity: w.severity }, - role: w.severity === 'error' ? 'alert' : 'status', + dataset: { severity: warning.severity }, + role: warning.severity === 'error' ? 'alert' : 'status', }, [ icon(Icons.alert, { size: 14 }), el('div', {}, [ - el('div', { class: 'u-weight-semi', text: w.title }), - w.message ? el('div', { class: 'u-text-sm u-text-muted', text: w.message }) : null, + el('div', { class: 'u-weight-semi', text: warning.title }), + warning.message ? el('div', { class: 'u-text-sm u-text-muted', text: warning.message }) : null, ]), ]) ) )); } - if (i.previewRows?.length && i.columns?.length) { - children.push(this._renderPreview(i)); + if (inspection.previewRows?.length && inspection.columns?.length) { + children.push(this._renderPreview(inspection)); } return el('div', { class: 'file-panel__body' }, children); @@ -208,18 +202,18 @@ export class FilePanel { ]); } - _renderPreview(i) { - const cols = i.columns.slice(0, 5); + _renderPreview(inspection) { + const columns = inspection.columns.slice(0, 5); const thead = el('thead', {}, [ - el('tr', {}, cols.map((c) => - el('th', { scope: 'col', text: c.label, title: c.label }) + el('tr', {}, columns.map((column) => + el('th', { scope: 'col', text: column.label, title: column.label }) )), ]); - const tbody = el('tbody', {}, i.previewRows.slice(0, 5).map((row) => - el('tr', {}, cols.map((c) => - el('td', { text: orDash(row[c.id]) }) + const tbody = el('tbody', {}, inspection.previewRows.slice(0, 5).map((row) => + el('tr', {}, columns.map((column) => + el('td', { text: orDash(row[column.id]) }) )) )); @@ -228,12 +222,12 @@ export class FilePanel { el('div', { class: 'file-panel__preview-scroll' }, [ el('table', { class: 'file-panel__preview-table' }, [thead, tbody]), ]), - i.rowCount && i.rowCount > i.previewRows.length + inspection.rowCount && inspection.rowCount > inspection.previewRows.length ? el('div', { class: 'file-panel__preview-foot u-text-xs u-text-faint', text: t('file.showingRows', { - shown: Math.min(i.previewRows.length, 5), - total: formatCount(i.rowCount), + shown: Math.min(inspection.previewRows.length, 5), + total: formatCount(inspection.rowCount), }), }) : null, diff --git a/src/ui/components/language-switcher.js b/src/ui/components/language-switcher.js index 84122ef..2cbf924 100644 --- a/src/ui/components/language-switcher.js +++ b/src/ui/components/language-switcher.js @@ -1,25 +1,34 @@ import { el } from '../renderers/dom.js'; -import { getLocale, getSupportedLocales, setLocale, t } from '../../i18n/index.js'; +import { + getLocale, + getSupportedLocales, + LOCALE_CHANGE_EVENT, + setLocale, + t, +} from '../../i18n/index.js'; export function LanguageSwitcher() { - const current = getLocale(); - const select = el('select', { class: 'language-switcher__select', - 'aria-label': t('language.label'), - title: t('language.label'), onchange: (event) => setLocale(event.target.value), }); for (const locale of getSupportedLocales()) { - const option = el('option', { + select.appendChild(el('option', { value: locale.code, text: locale.shortLabel, title: locale.nativeName, - }); - if (locale.code === current) option.selected = true; - select.appendChild(option); + })); } + const sync = () => { + select.value = getLocale(); + select.setAttribute('aria-label', t('language.label')); + select.title = t('language.label'); + }; + + window.addEventListener(LOCALE_CHANGE_EVENT, sync); + sync(); + return el('div', { class: 'language-switcher' }, [select]); } diff --git a/src/ui/components/mapping-editor.js b/src/ui/components/mapping-editor.js index 62e5b3a..81b6905 100644 --- a/src/ui/components/mapping-editor.js +++ b/src/ui/components/mapping-editor.js @@ -1,31 +1,23 @@ import { el, replaceChildren, icon, Icons } from '../renderers/dom.js'; import { MappingRow } from './mapping-row.js'; import { AppEvents } from '../../app/app-events.js'; +import { LOCALE_CHANGE_EVENT, t } from '../../i18n/index.js'; -/** - * MappingEditor — key/compare/display mapping UX and validation. - * Suggests, but never commits mappings without user approval. - */ export class MappingEditor { - /** - * @param {import('../../app/app-store.js').AppStore} store - * @param {import('../../services/comparison-service.js').ComparisonService} service - */ constructor(store, service) { this.store = store; this.service = service; - - /** @type {import('../../models/contracts.js').MappingConfig} */ this.mapping = this._emptyMapping(); this.root = el('section', { class: 'mapping-workspace', - 'aria-label': 'Column mapping', + 'aria-label': t('mapping.aria'), }); this._render(); this.store.addEventListener('change', () => this._onStoreChange()); this.store.addEventListener('state', () => this._onStoreChange()); + window.addEventListener(LOCALE_CHANGE_EVENT, () => this._render()); } _emptyMapping() { @@ -51,23 +43,30 @@ export class MappingEditor { const suggestions = await this.service.suggestMappings(this.store.fileA, this.store.fileB); if (suggestions?.length) { this.mapping.keys = suggestions - .filter((_, i) => i === 0) - .map((s) => ({ columnA: s.columnA, columnB: s.columnB, type: 'text' })); + .filter((_, index) => index === 0) + .map((suggestion) => ({ + columnA: suggestion.columnA, + columnB: suggestion.columnB, + type: 'text', + })); + this.mapping.comparisons = suggestions .slice(1) - .map((s) => ({ - columnA: s.columnA, - columnB: s.columnB, - type: this._guessType(s.columnA, s.columnB), + .map((suggestion) => ({ + columnA: suggestion.columnA, + columnB: suggestion.columnB, + type: this._guessType(suggestion.columnA, suggestion.columnB), tolerance: { mode: 'absolute', value: 0.01 }, })); } } catch { - // Suggestions are optional; never block the UI. + // Suggestions are optional. } + this._render(); this._emitUpdate(); } + if (!this.store.fileA || !this.store.fileB) { this._suggestionsFetched = false; this.mapping = this._emptyMapping(); @@ -75,11 +74,11 @@ export class MappingEditor { } } - _guessType(colA, colB) { - const a = this.store.fileA?.columns.find((c) => c.id === colA)?.inferredType; - const b = this.store.fileB?.columns.find((c) => c.id === colB)?.inferredType; - if (a === 'number' || b === 'number') return 'number'; - if (a === 'date' || b === 'date') return 'date'; + _guessType(columnA, columnB) { + const typeA = this.store.fileA?.columns.find((column) => column.id === columnA)?.inferredType; + const typeB = this.store.fileB?.columns.find((column) => column.id === columnB)?.inferredType; + if (typeA === 'number' || typeB === 'number') return 'number'; + if (typeA === 'date' || typeB === 'date') return 'date'; return 'text'; } @@ -91,13 +90,20 @@ export class MappingEditor { _emitNormalization() { this.store.patch({ mapping: this.mapping }); - window.dispatchEvent(new CustomEvent(AppEvents.NORMALIZATION_UPDATED, { detail: this.mapping.normalization })); + window.dispatchEvent(new CustomEvent(AppEvents.NORMALIZATION_UPDATED, { + detail: this.mapping.normalization, + })); } _addRow(role) { const item = { columnA: null, columnB: null, type: 'text' }; if (role === 'key') this.mapping.keys.push(item); - else if (role === 'compare') this.mapping.comparisons.push({ ...item, tolerance: { mode: 'absolute', value: 0.01 } }); + if (role === 'compare') { + this.mapping.comparisons.push({ + ...item, + tolerance: { mode: 'absolute', value: 0.01 }, + }); + } this._emitUpdate(); this._render(); } @@ -110,10 +116,12 @@ export class MappingEditor { } _validate() { - const validKeys = this.mapping.keys.filter((k) => k.columnA && k.columnB); + const validKeys = this.mapping.keys.filter((key) => key.columnA && key.columnB); const valid = validKeys.length > 0 && - this.mapping.keys.every((k) => k.columnA && k.columnB); + this.mapping.keys.every((key) => key.columnA && key.columnB); + this.store.patch({ mappingValid: valid }); + if (valid && this.store.state === 'MAPPING') { this.store.setState('READY_TO_COMPARE'); } else if (!valid && this.store.state === 'READY_TO_COMPARE') { @@ -124,70 +132,18 @@ export class MappingEditor { _render() { const columnsA = this.store.fileA?.columns ?? []; const columnsB = this.store.fileB?.columns ?? []; + this.root.setAttribute('aria-label', t('mapping.aria')); replaceChildren(this.root, [ el('header', { class: 'mapping-workspace__header' }, [ el('div', {}, [ - el('h2', { class: 'mapping-workspace__title', text: 'Match the columns' }), - el('p', { - class: 'mapping-workspace__hint', - text: 'Define which columns identify a record and which values to compare. Suggestions are proposals — review before running.', - }), + el('h2', { class: 'mapping-workspace__title', text: t('mapping.title') }), + el('p', { class: 'mapping-workspace__hint', text: t('mapping.hint') }), ]), ]), - this._renderColumnHeaders(), - - el('div', { class: 'mapping-workspace__group' }, [ - el('div', { class: 'mapping-workspace__group-head' }, [ - el('h3', { class: 'mapping-workspace__group-title', text: 'Match keys' }), - el('span', { class: 'mapping-workspace__group-hint u-text-xs u-text-muted', text: 'Required · identifies records across both files' }), - el('button', { - type: 'button', - class: 'btn btn--ghost btn--sm', - onclick: () => this._addRow('key'), - }, ['+ Add key']), - ]), - this.mapping.keys.length === 0 - ? el('div', { class: 'mapping-workspace__empty', text: 'No match keys yet. Add at least one.' }) - : el('div', { class: 'mapping-workspace__rows' }, - this.mapping.keys.map((k, i) => - new MappingRow({ - role: 'key', - columnsA, columnsB, - value: k, - onChange: (v) => { this.mapping.keys[i] = v; this._emitUpdate(); }, - onRemove: () => this._removeRow('key', i), - }).root - ) - ), - ]), - - el('div', { class: 'mapping-workspace__group' }, [ - el('div', { class: 'mapping-workspace__group-head' }, [ - el('h3', { class: 'mapping-workspace__group-title', text: 'Compare fields' }), - el('span', { class: 'mapping-workspace__group-hint u-text-xs u-text-muted', text: 'Optional · values that must match' }), - el('button', { - type: 'button', - class: 'btn btn--ghost btn--sm', - onclick: () => this._addRow('compare'), - }, ['+ Add compare']), - ]), - this.mapping.comparisons.length === 0 - ? el('div', { class: 'mapping-workspace__empty', text: 'No compare fields yet.' }) - : el('div', { class: 'mapping-workspace__rows' }, - this.mapping.comparisons.map((c, i) => - new MappingRow({ - role: 'compare', - columnsA, columnsB, - value: c, - onChange: (v) => { this.mapping.comparisons[i] = v; this._emitUpdate(); }, - onRemove: () => this._removeRow('compare', i), - }).root - ) - ), - ]), - + this._renderKeys(columnsA, columnsB), + this._renderComparisons(columnsA, columnsB), this._renderNormalization(), this._renderActions(), ]); @@ -196,26 +152,89 @@ export class MappingEditor { _renderColumnHeaders() { return el('div', { class: 'mapping-workspace__col-heads' }, [ el('div', { class: 'mapping-workspace__col-head' }, [ - el('span', { class: 'mapping-workspace__col-label', text: 'FILE A' }), + el('span', { class: 'mapping-workspace__col-label', text: t('file.label', { slot: 'A' }).toUpperCase() }), el('span', { class: 'mapping-workspace__col-name u-truncate', text: this.store.fileA?.name ?? '—' }), ]), el('div', { class: 'mapping-workspace__col-head mapping-workspace__col-head--spacer', 'aria-hidden': 'true' }), el('div', { class: 'mapping-workspace__col-head' }, [ - el('span', { class: 'mapping-workspace__col-label', text: 'FILE B' }), + el('span', { class: 'mapping-workspace__col-label', text: t('file.label', { slot: 'B' }).toUpperCase() }), el('span', { class: 'mapping-workspace__col-name u-truncate', text: this.store.fileB?.name ?? '—' }), ]), ]); } + _renderKeys(columnsA, columnsB) { + return el('div', { class: 'mapping-workspace__group' }, [ + el('div', { class: 'mapping-workspace__group-head' }, [ + el('h3', { class: 'mapping-workspace__group-title', text: t('mapping.matchKeys') }), + el('span', { class: 'mapping-workspace__group-hint u-text-xs u-text-muted', text: t('mapping.matchKeysHint') }), + el('button', { + type: 'button', + class: 'btn btn--ghost btn--sm', + onclick: () => this._addRow('key'), + }, [t('mapping.addKey')]), + ]), + this.mapping.keys.length === 0 + ? el('div', { class: 'mapping-workspace__empty', text: t('mapping.noKeys') }) + : el('div', { class: 'mapping-workspace__rows' }, + this.mapping.keys.map((key, index) => + new MappingRow({ + role: 'key', + columnsA, + columnsB, + value: key, + onChange: (value) => { + this.mapping.keys[index] = value; + this._emitUpdate(); + }, + onRemove: () => this._removeRow('key', index), + }).root + ) + ), + ]); + } + + _renderComparisons(columnsA, columnsB) { + return el('div', { class: 'mapping-workspace__group' }, [ + el('div', { class: 'mapping-workspace__group-head' }, [ + el('h3', { class: 'mapping-workspace__group-title', text: t('mapping.compareFields') }), + el('span', { class: 'mapping-workspace__group-hint u-text-xs u-text-muted', text: t('mapping.compareHint') }), + el('button', { + type: 'button', + class: 'btn btn--ghost btn--sm', + onclick: () => this._addRow('compare'), + }, [t('mapping.addCompare')]), + ]), + this.mapping.comparisons.length === 0 + ? el('div', { class: 'mapping-workspace__empty', text: t('mapping.noCompare') }) + : el('div', { class: 'mapping-workspace__rows' }, + this.mapping.comparisons.map((comparison, index) => + new MappingRow({ + role: 'compare', + columnsA, + columnsB, + value: comparison, + onChange: (value) => { + this.mapping.comparisons[index] = value; + this._emitUpdate(); + }, + onRemove: () => this._removeRow('compare', index), + }).root + ) + ), + ]); + } + _renderNormalization() { - const n = this.mapping.normalization; + const normalization = this.mapping.normalization; + const toggle = (label, key) => el('label', { class: 'toggle' }, [ el('input', { type: 'checkbox', - checked: n[key] ? 'checked' : null, - onchange: (e) => { - this.mapping.normalization[key] = e.target.checked; + checked: normalization[key] ? 'checked' : null, + onchange: (event) => { + this.mapping.normalization[key] = event.target.checked; this._emitNormalization(); }, }), @@ -225,25 +244,26 @@ export class MappingEditor { return el('details', { class: 'mapping-workspace__group mapping-workspace__group--collapsible' }, [ el('summary', { class: 'mapping-workspace__group-head mapping-workspace__summary' }, [ - el('h3', { class: 'mapping-workspace__group-title', text: 'Normalization' }), - el('span', { class: 'mapping-workspace__group-hint u-text-xs u-text-muted', text: 'How values are treated before matching' }), + el('h3', { class: 'mapping-workspace__group-title', text: t('mapping.normalization') }), + el('span', { class: 'mapping-workspace__group-hint u-text-xs u-text-muted', text: t('mapping.normalizationHint') }), ]), el('div', { class: 'mapping-workspace__normalization' }, [ - toggle('Trim whitespace', 'trimText'), - toggle('Ignore case', 'caseInsensitive'), - toggle('Collapse internal whitespace', 'collapseWhitespace'), + toggle(t('mapping.trim'), 'trimText'), + toggle(t('mapping.ignoreCase'), 'caseInsensitive'), + toggle(t('mapping.collapseWhitespace'), 'collapseWhitespace'), ]), ]); } _renderActions() { const disabled = !this.store.mappingValid; + return el('div', { class: 'mapping-workspace__actions' }, [ el('button', { type: 'button', class: 'btn btn--ghost', onclick: () => window.dispatchEvent(new CustomEvent(AppEvents.RESET_REQUESTED)), - }, [icon(Icons.refresh, { size: 14 }), 'Start over']), + }, [icon(Icons.refresh, { size: 14 }), t('mapping.startOver')]), el('button', { type: 'button', class: 'btn btn--primary btn--lg', @@ -251,9 +271,11 @@ export class MappingEditor { 'aria-disabled': disabled ? 'true' : null, onclick: () => { if (disabled) return; - window.dispatchEvent(new CustomEvent(AppEvents.COMPARE_REQUESTED, { detail: { mapping: this.mapping } })); + window.dispatchEvent(new CustomEvent(AppEvents.COMPARE_REQUESTED, { + detail: { mapping: this.mapping }, + })); }, - }, [icon(Icons.zap, { size: 16 }), 'Compare files']), + }, [icon(Icons.zap, { size: 16 }), t('mapping.compareFiles')]), ]); } -} \ No newline at end of file +} diff --git a/src/ui/components/mapping-row.js b/src/ui/components/mapping-row.js index d8d3f61..cc46756 100644 --- a/src/ui/components/mapping-row.js +++ b/src/ui/components/mapping-row.js @@ -1,21 +1,7 @@ import { el, icon, Icons } from '../renderers/dom.js'; +import { t } from '../../i18n/index.js'; -/** - * MappingRow — one row in the mapping workspace. - * Visual: source column → target column, with role selector. - * Drag & drop is a convenience; the select is the accessible path. - */ export class MappingRow { - /** - * @param {{ - * role: 'key' | 'compare' | 'display' | 'ignore', - * columnsA: import('../../models/contracts.js').ColumnInfo[], - * columnsB: import('../../models/contracts.js').ColumnInfo[], - * value: { columnA: string | null, columnB: string | null, type?: string, tolerance?: { mode: string, value: number } }, - * onChange: (value: object) => void, - * onRemove: () => void, - * }} config - */ constructor(config) { this.config = config; this.value = { ...config.value }; @@ -26,30 +12,35 @@ export class MappingRow { this._render(); } - update(value) { - this.value = { ...this.value, ...value }; - this._render(); - } - _render() { const { role, columnsA, columnsB } = this.config; const roleBadge = el('div', { class: 'mapping-row__role' }, [ el('span', { - class: `mapping-row__role-chip mapping-row__role-chip--${role}`, + class: 'mapping-row__role-chip mapping-row__role-chip--' + role, text: this._roleLabel(role), }), ]); - const selectA = this._buildSelect(columnsA, this.value.columnA, 'File A column', (v) => { - this.value.columnA = v; - this.config.onChange({ ...this.value }); - }); + const selectA = this._buildSelect( + columnsA, + this.value.columnA, + t('mapping.fileAColumn'), + (value) => { + this.value.columnA = value; + this.config.onChange({ ...this.value }); + } + ); - const selectB = this._buildSelect(columnsB, this.value.columnB, 'File B column', (v) => { - this.value.columnB = v; - this.config.onChange({ ...this.value }); - }); + const selectB = this._buildSelect( + columnsB, + this.value.columnB, + t('mapping.fileBColumn'), + (value) => { + this.value.columnB = value; + this.config.onChange({ ...this.value }); + } + ); const arrow = el('div', { class: 'mapping-row__arrow', 'aria-hidden': 'true' }, [ icon(Icons.arrowRight, { size: 14 }), @@ -60,78 +51,85 @@ export class MappingRow { extras.push(this._renderTolerance()); } - const removeBtn = el('button', { + const removeButton = el('button', { type: 'button', class: 'btn btn--ghost btn--icon btn--sm mapping-row__remove', - 'aria-label': 'Remove mapping', - title: 'Remove', + 'aria-label': t('mapping.remove'), + title: t('mapping.remove'), onclick: () => this.config.onRemove(), }, [icon(Icons.x, { size: 12 })]); - const body = el('div', { class: 'mapping-row__body' }, [ - el('div', { class: 'mapping-row__cell mapping-row__cell--a' }, [selectA]), - arrow, - el('div', { class: 'mapping-row__cell mapping-row__cell--b' }, [selectB]), - ...extras, - removeBtn, - ]); - - this.root.replaceChildren(roleBadge, body); + this.root.replaceChildren( + roleBadge, + el('div', { class: 'mapping-row__body' }, [ + el('div', { class: 'mapping-row__cell mapping-row__cell--a' }, [selectA]), + arrow, + el('div', { class: 'mapping-row__cell mapping-row__cell--b' }, [selectB]), + ...extras, + removeButton, + ]) + ); } _roleLabel(role) { - if (role === 'key') return 'MATCH KEY'; - if (role === 'compare') return 'COMPARE'; - if (role === 'display') return 'DISPLAY'; - return 'IGNORE'; + if (role === 'key') return t('mapping.role.key'); + if (role === 'compare') return t('mapping.role.compare'); + if (role === 'display') return t('mapping.role.display'); + return t('mapping.role.ignore'); } _buildSelect(columns, value, label, onChange) { const select = el('select', { class: 'select', 'aria-label': label, - onchange: (e) => onChange(e.target.value || null), + onchange: (event) => onChange(event.target.value || null), }); - select.appendChild(el('option', { value: '', text: '— select —' })); - for (const col of columns) { - const opt = el('option', { value: col.id, text: col.label }); - if (col.id === value) opt.selected = true; - select.appendChild(opt); + + select.appendChild(el('option', { value: '', text: t('mapping.select') })); + + for (const column of columns) { + const option = el('option', { value: column.id, text: column.label }); + if (column.id === value) option.selected = true; + select.appendChild(option); } + return select; } _renderTolerance() { - const tol = this.value.tolerance ?? { mode: 'absolute', value: 0.01 }; + const tolerance = this.value.tolerance ?? { mode: 'absolute', value: 0.01 }; + const modeSelect = el('select', { class: 'select select--sm', - 'aria-label': 'Tolerance mode', - onchange: (e) => { - this.value.tolerance = { ...tol, mode: e.target.value }; + 'aria-label': t('mapping.toleranceMode'), + onchange: (event) => { + this.value.tolerance = { ...tolerance, mode: event.target.value }; this.config.onChange({ ...this.value }); }, }, [ - this._opt('absolute', '±', tol.mode === 'absolute'), - this._opt('percentage', '%', tol.mode === 'percentage'), + this._option('absolute', '±', tolerance.mode === 'absolute'), + this._option('percentage', '%', tolerance.mode === 'percentage'), ]); + const valueInput = el('input', { type: 'number', class: 'input input--sm', - value: String(tol.value), + value: String(tolerance.value), step: '0.01', min: '0', - 'aria-label': 'Tolerance value', - oninput: (e) => { - this.value.tolerance = { ...tol, value: Number(e.target.value) }; + 'aria-label': t('mapping.toleranceValue'), + oninput: (event) => { + this.value.tolerance = { ...tolerance, value: Number(event.target.value) }; this.config.onChange({ ...this.value }); }, }); + return el('div', { class: 'mapping-row__tolerance' }, [modeSelect, valueInput]); } - _opt(value, label, selected) { - const o = el('option', { value, text: label }); - if (selected) o.selected = true; - return o; + _option(value, label, selected) { + const option = el('option', { value, text: label }); + if (selected) option.selected = true; + return option; } -} \ No newline at end of file +} diff --git a/src/ui/components/record-detail-drawer.js b/src/ui/components/record-detail-drawer.js index c5d3036..e45a1f5 100644 --- a/src/ui/components/record-detail-drawer.js +++ b/src/ui/components/record-detail-drawer.js @@ -1,44 +1,36 @@ import { el, replaceChildren, icon, Icons } from '../renderers/dom.js'; import { orDash } from '../renderers/format.js'; import { AppEvents } from '../../app/app-events.js'; +import { LOCALE_CHANGE_EVENT, t } from '../../i18n/index.js'; -const STATUS_LABEL = { - MATCHED: 'Matched', - ONLY_A: 'Only A', - ONLY_B: 'Only B', - MISMATCH: 'Mismatch', - DUPLICATE: 'Duplicate', - AMBIGUOUS: 'Ambiguous', +const STATUS_KEY = { + MATCHED: 'results.matched', + ONLY_A: 'results.onlyA', + ONLY_B: 'results.onlyB', + MISMATCH: 'results.mismatch', + DUPLICATE: 'results.duplicate', + AMBIGUOUS: 'results.ambiguous', }; -const STATUS_EXPLAIN = { - MATCHED: 'Key exists in both files and all compared values pass.', - ONLY_A: 'Record exists only in File A.', - ONLY_B: 'Record exists only in File B.', - MISMATCH: 'Key matched, but one or more compared fields differ.', - DUPLICATE: 'The same normalized key occurs multiple times.', - AMBIGUOUS: 'The engine cannot determine a unique pairing safely. No guess was made.', -}; - -/** - * RecordDetailDrawer — side-by-side inspection. Never mutates source rows. - */ export class RecordDetailDrawer { - /** @param {import('../../app/app-store.js').AppStore} store */ constructor(store) { this.store = store; + this.root = el('aside', { class: 'record-drawer', role: 'dialog', 'aria-modal': 'true', - 'aria-label': 'Record detail', hidden: '', }); + store.addEventListener('change', () => this._sync()); store.addEventListener('state', () => this._sync()); - document.addEventListener('keydown', (e) => { - if (e.key === 'Escape' && this.store.openRecordId) this._close(); + window.addEventListener(LOCALE_CHANGE_EVENT, () => this._sync()); + + document.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && this.store.openRecordId) this._close(); }); + this._sync(); } @@ -48,101 +40,152 @@ export class RecordDetailDrawer { } _sync() { + this.root.setAttribute('aria-label', t('record.detail')); + const id = this.store.openRecordId; if (!id || this.store.state !== 'RESULTS') { this.root.hidden = true; replaceChildren(this.root, []); return; } - const rec = this.store.result?.records.find((r) => r.id === id); - if (!rec) { + + const record = this.store.result?.records.find((item) => item.id === id); + if (!record) { this.root.hidden = true; return; } + this.root.hidden = false; - this._render(rec); + this._render(record); } - _render(rec) { + _render(record) { const backdrop = el('div', { class: 'record-drawer__backdrop', onclick: () => this._close(), }); const panel = el('div', { class: 'record-drawer__panel' }, [ - this._renderHead(rec), - this._renderExplain(rec), - this._renderSideBySide(rec), - rec.differences?.length ? this._renderDifferences(rec) : null, + this._renderHead(record), + this._renderExplain(record), + this._renderSideBySide(record), + record.differences?.length ? this._renderDifferences(record) : null, ]); replaceChildren(this.root, [backdrop, panel]); } - _renderHead(rec) { + _renderHead(record) { + const statusKey = STATUS_KEY[record.status]; + return el('header', { class: 'record-drawer__head' }, [ el('div', { class: 'record-drawer__head-main' }, [ - el('span', { class: 'badge', dataset: { status: rec.status }, text: STATUS_LABEL[rec.status] ?? rec.status }), - el('span', { class: 'record-drawer__key u-text-mono', text: rec.keyLabel }), + el('span', { + class: 'badge', + dataset: { status: record.status }, + text: statusKey ? t(statusKey) : record.status, + }), + el('span', { class: 'record-drawer__key u-text-mono', text: record.keyLabel }), ]), el('button', { type: 'button', class: 'btn btn--ghost btn--icon', - 'aria-label': 'Close', + 'aria-label': t('record.close'), + title: t('record.close'), onclick: () => this._close(), }, [icon(Icons.x, { size: 16 })]), ]); } - _renderExplain(rec) { - return el('p', { class: 'record-drawer__explain', text: STATUS_EXPLAIN[rec.status] ?? '' }); + _renderExplain(record) { + return el('p', { + class: 'record-drawer__explain', + text: t('record.explain.' + record.status), + }); } - _renderSideBySide(rec) { + _renderSideBySide(record) { const fields = new Set([ - ...Object.keys(rec.displayA ?? {}), - ...Object.keys(rec.displayB ?? {}), + ...Object.keys(record.displayA ?? {}), + ...Object.keys(record.displayB ?? {}), ]); + return el('div', { class: 'record-drawer__grid' }, [ - this._renderSide('File A', rec.source?.rowA, rec.displayA ?? {}, fields, rec.differences), - this._renderSide('File B', rec.source?.rowB, rec.displayB ?? {}, fields, rec.differences), + this._renderSide( + t('record.fileA'), + record.source?.rowA, + record.displayA ?? {}, + fields, + record.differences + ), + this._renderSide( + t('record.fileB'), + record.source?.rowB, + record.displayB ?? {}, + fields, + record.differences + ), ]); } - _renderSide(title, rowNum, data, fields, differences) { - const diffFields = new Set((differences ?? []).map((d) => d.field)); + _renderSide(title, rowNumber, data, fields, differences) { + const differenceFields = new Set( + (differences ?? []).map((difference) => String(difference.field).toLowerCase()) + ); + return el('div', { class: 'record-drawer__side' }, [ el('div', { class: 'record-drawer__side-head' }, [ el('span', { class: 'record-drawer__side-title', text: title }), - el('span', { class: 'record-drawer__side-row u-text-mono u-text-muted', text: rowNum != null ? `row ${rowNum}` : '—' }), + el('span', { + class: 'record-drawer__side-row u-text-mono u-text-muted', + text: rowNumber != null ? t('record.row', { row: rowNumber }) : '—', + }), ]), - el('dl', { class: 'record-drawer__fields' }, [...fields].map((f) => { - const isDiff = diffFields.has(f.toLowerCase()); + el('dl', { class: 'record-drawer__fields' }, [...fields].map((field) => { + const isDifferent = differenceFields.has(String(field).toLowerCase()); + return el('div', { class: 'record-drawer__field', - dataset: { diff: isDiff ? 'true' : 'false' }, + dataset: { diff: isDifferent ? 'true' : 'false' }, }, [ - el('dt', { class: 'record-drawer__field-label', text: f }), - el('dd', { class: 'record-drawer__field-value u-text-mono', text: orDash(data[f]) }), + el('dt', { class: 'record-drawer__field-label', text: field }), + el('dd', { + class: 'record-drawer__field-value u-text-mono', + text: orDash(data[field]), + }), ]); })), ]); } - _renderDifferences(rec) { + _renderDifferences(record) { return el('div', { class: 'record-drawer__diffs' }, [ - el('h4', { class: 'record-drawer__diffs-title', text: 'Differences' }), - el('ul', { class: 'record-drawer__diffs-list' }, rec.differences.map((d) => + el('h4', { class: 'record-drawer__diffs-title', text: t('record.differences') }), + el('ul', { class: 'record-drawer__diffs-list' }, record.differences.map((difference) => el('li', { class: 'record-drawer__diff' }, [ - el('span', { class: 'record-drawer__diff-field u-text-mono', text: d.field }), + el('span', { + class: 'record-drawer__diff-field u-text-mono', + text: difference.field, + }), el('span', { class: 'record-drawer__diff-values' }, [ - el('span', { class: 'record-drawer__diff-a', text: String(d.valueA ?? '—') }), + el('span', { + class: 'record-drawer__diff-a', + text: String(difference.valueA ?? '—'), + }), el('span', { class: 'record-drawer__diff-arrow', text: '→' }), - el('span', { class: 'record-drawer__diff-b', text: String(d.valueB ?? '—') }), + el('span', { + class: 'record-drawer__diff-b', + text: String(difference.valueB ?? '—'), + }), ]), - d.delta != null ? el('span', { class: 'record-drawer__diff-delta u-text-mono', text: `Δ ${d.delta}` }) : null, + difference.delta != null + ? el('span', { + class: 'record-drawer__diff-delta u-text-mono', + text: 'Δ ' + difference.delta, + }) + : null, ]) )), ]); } -} \ No newline at end of file +} diff --git a/src/ui/components/results-table.js b/src/ui/components/results-table.js index 772483d..0dca97e 100644 --- a/src/ui/components/results-table.js +++ b/src/ui/components/results-table.js @@ -1,37 +1,38 @@ import { el, replaceChildren, icon, Icons } from '../renderers/dom.js'; import { formatCount } from '../renderers/format.js'; import { AppEvents } from '../../app/app-events.js'; +import { LOCALE_CHANGE_EVENT, t } from '../../i18n/index.js'; -const STATUS_LABEL = { - MATCHED: 'Matched', - ONLY_A: 'Only A', - ONLY_B: 'Only B', - MISMATCH: 'Mismatch', - DUPLICATE: 'Duplicate', - AMBIGUOUS: 'Ambiguous', +const STATUS_KEY = { + MATCHED: 'results.matched', + ONLY_A: 'results.onlyA', + ONLY_B: 'results.onlyB', + MISMATCH: 'results.mismatch', + DUPLICATE: 'results.duplicate', + AMBIGUOUS: 'results.ambiguous', }; const PAGE_SIZE = 50; -/** - * ResultsTable — paginated result presentation. Never renders all rows. - * Holds no authoritative dataset; it reads from AppStore. - */ export class ResultsTable { - /** @param {import('../../app/app-store.js').AppStore} store */ constructor(store) { this.store = store; this.page = 0; this.root = el('div', { class: 'results-table' }); + store.addEventListener('change', () => this._render()); store.addEventListener('state', () => this._render()); + window.addEventListener(LOCALE_CHANGE_EVENT, () => this._render()); + this._render(); } _filtered() { - const all = this.store.result?.records ?? []; - const f = this.store.resultFilter; - return f === 'ALL' ? all : all.filter((r) => r.status === f); + const records = this.store.result?.records ?? []; + const filter = this.store.resultFilter; + return filter === 'ALL' + ? records + : records.filter((record) => record.status === filter); } _render() { @@ -42,14 +43,19 @@ export class ResultsTable { const rows = this._filtered(); const totalPages = Math.max(1, Math.ceil(rows.length / PAGE_SIZE)); + if (this.page >= totalPages) this.page = totalPages - 1; - const slice = rows.slice(this.page * PAGE_SIZE, this.page * PAGE_SIZE + PAGE_SIZE); + + const pageRows = rows.slice( + this.page * PAGE_SIZE, + this.page * PAGE_SIZE + PAGE_SIZE + ); if (rows.length === 0) { replaceChildren(this.root, [ el('div', { class: 'results-table__empty' }, [ icon(Icons.check, { size: 20 }), - el('span', { text: 'Nothing in this category.' }), + el('span', { text: t('results.nothing') }), ]), ]); return; @@ -58,14 +64,14 @@ export class ResultsTable { const table = el('table', { class: 'results-table__table' }, [ el('thead', {}, [ el('tr', {}, [ - el('th', { scope: 'col', text: 'Status' }), - el('th', { scope: 'col', text: 'Key' }), - el('th', { scope: 'col', class: 'u-text-mono', text: 'Row A' }), - el('th', { scope: 'col', class: 'u-text-mono', text: 'Row B' }), + el('th', { scope: 'col', text: t('results.status') }), + el('th', { scope: 'col', text: t('results.key') }), + el('th', { scope: 'col', class: 'u-text-mono', text: t('results.rowA') }), + el('th', { scope: 'col', class: 'u-text-mono', text: t('results.rowB') }), el('th', { scope: 'col', text: '' }), ]), ]), - el('tbody', {}, slice.map((rec) => this._renderRow(rec))), + el('tbody', {}, pageRows.map((record) => this._renderRow(record))), ]); replaceChildren(this.root, [ @@ -74,57 +80,87 @@ export class ResultsTable { ]); } - _renderRow(rec) { + _renderRow(record) { + const statusKey = STATUS_KEY[record.status]; + const statusLabel = statusKey ? t(statusKey) : record.status; + return el('tr', { class: 'results-table__row', tabindex: '0', - dataset: { status: rec.status }, + dataset: { status: record.status }, role: 'button', - 'aria-label': `Open record ${rec.keyLabel}`, - onclick: () => this._open(rec.id), - onkeydown: (e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - this._open(rec.id); + 'aria-label': t('results.openRecord', { key: record.keyLabel }), + onclick: () => this._open(record.id), + onkeydown: (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + this._open(record.id); } }, }, [ el('td', {}, [ - el('span', { class: 'badge', dataset: { status: rec.status }, text: STATUS_LABEL[rec.status] ?? rec.status }), + el('span', { + class: 'badge', + dataset: { status: record.status }, + text: statusLabel, + }), + ]), + el('td', { class: 'u-text-mono results-table__key', text: record.keyLabel }), + el('td', { class: 'u-text-mono u-text-muted', text: record.source?.rowA ?? '—' }), + el('td', { class: 'u-text-mono u-text-muted', text: record.source?.rowB ?? '—' }), + el('td', { class: 'results-table__chevron' }, [ + icon(Icons.arrowRight, { size: 14 }), ]), - el('td', { class: 'u-text-mono results-table__key', text: rec.keyLabel }), - el('td', { class: 'u-text-mono u-text-muted', text: rec.source?.rowA ?? '—' }), - el('td', { class: 'u-text-mono u-text-muted', text: rec.source?.rowB ?? '—' }), - el('td', { class: 'results-table__chevron' }, [icon(Icons.arrowRight, { size: 14 })]), ]); } _open(id) { this.store.patch({ openRecordId: id }); - window.dispatchEvent(new CustomEvent(AppEvents.RESULT_RECORD_OPENED, { detail: { id } })); + window.dispatchEvent(new CustomEvent(AppEvents.RESULT_RECORD_OPENED, { + detail: { id }, + })); } _renderPager(total, totalPages) { if (totalPages <= 1) { return el('div', { class: 'results-table__pager' }, [ - el('span', { class: 'u-text-sm u-text-muted', text: `${formatCount(total)} records` }), + el('span', { + class: 'u-text-sm u-text-muted', + text: t('results.records', { count: formatCount(total) }), + }), ]); } - const prev = el('button', { + + const previous = el('button', { type: 'button', class: 'btn btn--ghost btn--sm', disabled: this.page === 0 ? 'true' : null, - onclick: () => { this.page = Math.max(0, this.page - 1); this._render(); }, - }, ['← Prev']); + onclick: () => { + this.page = Math.max(0, this.page - 1); + this._render(); + }, + }, [t('results.prev')]); + const next = el('button', { type: 'button', class: 'btn btn--ghost btn--sm', disabled: this.page >= totalPages - 1 ? 'true' : null, - onclick: () => { this.page = Math.min(totalPages - 1, this.page + 1); this._render(); }, - }, ['Next →']); + onclick: () => { + this.page = Math.min(totalPages - 1, this.page + 1); + this._render(); + }, + }, [t('results.next')]); + return el('div', { class: 'results-table__pager' }, [ - el('span', { class: 'u-text-sm u-text-muted', text: `${formatCount(total)} records · page ${this.page + 1} of ${totalPages}` }), - el('div', { class: 'u-flex u-gap-2' }, [prev, next]), + el('span', { + class: 'u-text-sm u-text-muted', + text: t('results.page', { + count: formatCount(total), + page: this.page + 1, + pages: totalPages, + }), + }), + el('div', { class: 'u-flex u-gap-2' }, [previous, next]), ]); } -} \ No newline at end of file +} diff --git a/src/ui/components/skip-link.js b/src/ui/components/skip-link.js index 2347435..4d5a80d 100644 --- a/src/ui/components/skip-link.js +++ b/src/ui/components/skip-link.js @@ -1,10 +1,18 @@ import { el } from '../renderers/dom.js'; -import { t } from '../../i18n/index.js'; +import { LOCALE_CHANGE_EVENT, t } from '../../i18n/index.js'; export function SkipLink() { - return el('a', { + const link = el('a', { href: '#main', class: 'skip-link', - text: t('skip.content'), }); + + const sync = () => { + link.textContent = t('skip.content'); + }; + + window.addEventListener(LOCALE_CHANGE_EVENT, sync); + sync(); + + return link; } diff --git a/src/ui/components/toast-host.js b/src/ui/components/toast-host.js index 632ff37..1a682ab 100644 --- a/src/ui/components/toast-host.js +++ b/src/ui/components/toast-host.js @@ -1,49 +1,54 @@ import { el, replaceChildren, icon, Icons } from '../renderers/dom.js'; import { AppEvents } from '../../app/app-events.js'; +import { LOCALE_CHANGE_EVENT, t } from '../../i18n/index.js'; -/** - * ToastHost — renders transient messages from AppStore.toasts. - */ export class ToastHost { - /** @param {import('../../app/app-store.js').AppStore} store */ constructor(store) { this.store = store; + this.root = el('div', { class: 'toast-host', role: 'region', - 'aria-label': 'Notifications', }); store.addEventListener('change', () => this._render()); store.addEventListener(AppEvents.TOAST_SHOWN, () => this._render()); store.addEventListener(AppEvents.TOAST_DISMISSED, () => this._render()); + window.addEventListener(LOCALE_CHANGE_EVENT, () => this._render()); this._render(); } - /** @private */ _render() { + this.root.setAttribute('aria-label', t('toast.notifications')); + replaceChildren( this.root, - this.store.toasts.map((t) => - el('div', { + this.store.toasts.map((toast) => { + const title = toast.titleKey ? t(toast.titleKey) : toast.title; + const message = toast.messageKey ? t(toast.messageKey) : toast.message; + + return el('div', { class: 'toast toast--floating', - dataset: { severity: t.severity }, - role: t.severity === 'error' ? 'alert' : 'status', + dataset: { severity: toast.severity }, + role: toast.severity === 'error' ? 'alert' : 'status', }, [ - icon(t.severity === 'error' ? Icons.alert : Icons.check, { size: 14 }), + icon(toast.severity === 'error' ? Icons.alert : Icons.check, { size: 14 }), el('div', { class: 'toast__body' }, [ - el('div', { class: 'u-weight-semi', text: t.title }), - t.message ? el('div', { class: 'u-text-sm u-text-muted', text: t.message }) : null, + el('div', { class: 'u-weight-semi', text: title }), + message + ? el('div', { class: 'u-text-sm u-text-muted', text: message }) + : null, ]), el('button', { type: 'button', class: 'btn btn--ghost btn--icon btn--sm', - 'aria-label': 'Dismiss', - onclick: () => this.store.dismissToast(t.id), + 'aria-label': t('toast.dismiss'), + title: t('toast.dismiss'), + onclick: () => this.store.dismissToast(toast.id), }, [icon(Icons.x, { size: 12 })]), - ]) - ) + ]); + }) ); } -} \ No newline at end of file +} diff --git a/src/ui/views/comparing-view.js b/src/ui/views/comparing-view.js index b4a3505..666d1ab 100644 --- a/src/ui/views/comparing-view.js +++ b/src/ui/views/comparing-view.js @@ -1,4 +1,5 @@ import { el, replaceChildren, icon, Icons } from '../renderers/dom.js'; +import { LOCALE_CHANGE_EVENT, t } from '../../i18n/index.js'; export class ComparingView { constructor(store) { @@ -6,61 +7,78 @@ export class ComparingView { this.root = el('section', { class: 'app-main', hidden: '' }); store.addEventListener('state', () => this._sync()); store.addEventListener('change', () => this._sync()); + window.addEventListener(LOCALE_CHANGE_EVENT, () => this._sync()); this._sync(); } _sync() { const active = this.store.state === 'COMPARING'; this.root.hidden = !active; - if (!active) return; - this._render(); + if (active) this._render(); } _render() { - const p = this.store.progress; - const determinate = p && Number.isFinite(p.completed) && Number.isFinite(p.total) && p.total > 0; - const pct = determinate ? Math.min(100, Math.round((p.completed / p.total) * 100)) : null; + const progress = this.store.progress; + const determinate = progress && + Number.isFinite(progress.completed) && + Number.isFinite(progress.total) && + progress.total > 0; + + const percent = determinate + ? Math.min(100, Math.round((progress.completed / progress.total) * 100)) + : null; const phases = [ - { id: 'parse-a', label: 'Parse File A' }, - { id: 'parse-b', label: 'Parse File B' }, - { id: 'index', label: 'Index records' }, - { id: 'compare', label: 'Compare' }, - { id: 'finalize', label: 'Finalize' }, + { id: 'parse-a', label: t('comparing.phase.parse-a') }, + { id: 'parse-b', label: t('comparing.phase.parse-b') }, + { id: 'index', label: t('comparing.phase.index') }, + { id: 'compare', label: t('comparing.phase.compare') }, + { id: 'finalize', label: t('comparing.phase.finalize') }, ]; - const currentIdx = p ? phases.findIndex((x) => x.id === p.phase) : -1; + + const currentIndex = progress + ? phases.findIndex((phase) => phase.id === progress.phase) + : -1; + + const title = currentIndex >= 0 + ? phases[currentIndex].label + : t('comparing.preparing'); replaceChildren(this.root, [ el('div', { class: 'u-container' }, [ el('section', { class: 'comparing' }, [ el('div', { class: 'comparing__spinner', 'aria-hidden': 'true' }), - el('h2', { class: 'comparing__title', text: p?.message ?? 'Preparing…' }), - el('p', { class: 'comparing__subtitle u-text-muted', text: 'Working locally. Nothing leaves this browser.' }), + el('h2', { class: 'comparing__title', text: title }), + el('p', { class: 'comparing__subtitle u-text-muted', text: t('comparing.local') }), el('div', { class: 'comparing__bar', role: 'progressbar', - 'aria-label': 'Comparison progress', + 'aria-label': t('comparing.progress'), 'aria-valuemin': '0', 'aria-valuemax': '100', - ...(determinate ? { 'aria-valuenow': String(pct) } : {}), + ...(determinate ? { 'aria-valuenow': String(percent) } : {}), }, [ el('div', { class: 'comparing__bar-fill', - style: determinate ? 'width:' + pct + '%' : '', + style: determinate ? 'width:' + percent + '%' : '', dataset: { indeterminate: determinate ? 'false' : 'true' }, }), ]), - el('ol', { class: 'comparing__phases' }, phases.map((ph, i) => + el('ol', { class: 'comparing__phases' }, phases.map((phase, index) => el('li', { class: 'comparing__phase', dataset: { - state: i < currentIdx ? 'done' : i === currentIdx ? 'active' : 'pending', + state: index < currentIndex + ? 'done' + : index === currentIndex + ? 'active' + : 'pending', }, }, [ el('span', { class: 'comparing__phase-dot' }, [ - i < currentIdx ? icon(Icons.check, { size: 12 }) : null, + index < currentIndex ? icon(Icons.check, { size: 12 }) : null, ]), - el('span', { class: 'comparing__phase-label', text: ph.label }), + el('span', { class: 'comparing__phase-label', text: phase.label }), ]) )), ]), diff --git a/src/ui/views/landing-view.js b/src/ui/views/landing-view.js index cfc2759..3945018 100644 --- a/src/ui/views/landing-view.js +++ b/src/ui/views/landing-view.js @@ -1,25 +1,31 @@ -import { el, icon, Icons } from '../renderers/dom.js'; +import { el, replaceChildren, icon, Icons } from '../renderers/dom.js'; import { FilePanel } from '../components/file-panel.js'; import { PrivacyNote } from '../components/privacy-note.js'; -import { t } from '../../i18n/index.js'; +import { LOCALE_CHANGE_EVENT, t } from '../../i18n/index.js'; export class LandingView { - /** @param {import('../../app/app-store.js').AppStore} store */ constructor(store) { this.store = store; this.filePanelA = new FilePanel({ slot: 'A' }); this.filePanelB = new FilePanel({ slot: 'B' }); + this.root = el('section', { class: 'landing' }); - this.root = el('section', { class: 'landing' }, [ + this._render(); + this._syncFromStore(); + + store.addEventListener('change', () => this._syncFromStore()); + store.addEventListener('state', () => this._syncFromStore()); + window.addEventListener(LOCALE_CHANGE_EVENT, () => this._render()); + } + + _render() { + replaceChildren(this.root, [ el('section', { class: 'landing__hero' }, [ el('h1', { class: 'landing__title' }, [ t('landing.titlePrefix'), el('span', { class: 'landing__title-accent', text: t('landing.titleAccent') }), ]), - el('p', { - class: 'landing__subtitle', - text: t('landing.subtitle'), - }), + el('p', { class: 'landing__subtitle', text: t('landing.subtitle') }), ]), el('section', { class: 'landing__workflow', 'aria-label': t('landing.fileSelection') }, [ this.filePanelA.root, @@ -28,14 +34,13 @@ export class LandingView { ]), el('div', { class: 'landing__footer' }, [PrivacyNote()]), ]); - - this._syncFromStore(); - store.addEventListener('change', () => this._syncFromStore()); - store.addEventListener('state', () => this._syncFromStore()); } _syncFromStore() { - this.filePanelA.setInspection(this.store.fileA); - this.filePanelB.setInspection(this.store.fileB); + if (this.store.fileLoadingA) this.filePanelA.setLoading(); + else this.filePanelA.setInspection(this.store.fileA); + + if (this.store.fileLoadingB) this.filePanelB.setLoading(); + else this.filePanelB.setInspection(this.store.fileB); } } diff --git a/src/ui/views/results-view.js b/src/ui/views/results-view.js index 3d759a7..c6a0b4d 100644 --- a/src/ui/views/results-view.js +++ b/src/ui/views/results-view.js @@ -4,15 +4,16 @@ import { SummaryCard } from '../components/summary-card.js'; import { ResultsTable } from '../components/results-table.js'; import { RecordDetailDrawer } from '../components/record-detail-drawer.js'; import { AppEvents } from '../../app/app-events.js'; +import { LOCALE_CHANGE_EVENT, t } from '../../i18n/index.js'; const CARDS = [ - { status: 'ALL', label: 'All' }, - { status: 'MISMATCH', label: 'Mismatch' }, - { status: 'ONLY_A', label: 'Only A' }, - { status: 'ONLY_B', label: 'Only B' }, - { status: 'DUPLICATE', label: 'Duplicate' }, - { status: 'AMBIGUOUS', label: 'Ambiguous' }, - { status: 'MATCHED', label: 'Matched' }, + { status: 'ALL', labelKey: 'results.all' }, + { status: 'MISMATCH', labelKey: 'results.mismatch' }, + { status: 'ONLY_A', labelKey: 'results.onlyA' }, + { status: 'ONLY_B', labelKey: 'results.onlyB' }, + { status: 'DUPLICATE', labelKey: 'results.duplicate' }, + { status: 'AMBIGUOUS', labelKey: 'results.ambiguous' }, + { status: 'MATCHED', labelKey: 'results.matched' }, ]; export class ResultsView { @@ -24,47 +25,53 @@ export class ResultsView { this.root = el('section', { class: 'app-main', hidden: '' }); store.addEventListener('state', () => this._sync()); store.addEventListener('change', () => this._sync()); + window.addEventListener(LOCALE_CHANGE_EVENT, () => this._sync()); this._sync(); } _sync() { const active = this.store.state === 'RESULTS' && this.store.result; this.root.hidden = !active; + if (!active) { replaceChildren(this.root, []); return; } + this._render(); } _countFor(status) { - const s = this.store.result.summary; - if (status === 'ALL') return s.matched + s.onlyA + s.onlyB + s.mismatched + s.duplicates + s.ambiguous; - if (status === 'MISMATCH') return s.mismatched; - if (status === 'ONLY_A') return s.onlyA; - if (status === 'ONLY_B') return s.onlyB; - if (status === 'DUPLICATE') return s.duplicates; - if (status === 'AMBIGUOUS') return s.ambiguous; - if (status === 'MATCHED') return s.matched; + const summary = this.store.result.summary; + + if (status === 'ALL') return this.store.result.records.length; + if (status === 'MISMATCH') return summary.mismatched; + if (status === 'ONLY_A') return summary.onlyA; + if (status === 'ONLY_B') return summary.onlyB; + if (status === 'DUPLICATE') return summary.duplicates; + if (status === 'AMBIGUOUS') return summary.ambiguous; + if (status === 'MATCHED') return summary.matched; return 0; } _setFilter(status) { this.store.patch({ resultFilter: status }); - window.dispatchEvent(new CustomEvent(AppEvents.RESULT_FILTER_CHANGED, { detail: { status } })); + window.dispatchEvent(new CustomEvent(AppEvents.RESULT_FILTER_CHANGED, { + detail: { status }, + })); this._render(); } _render() { - const s = this.store.result.summary; + const summary = this.store.result.summary; - const cards = CARDS.map((c) => + const cards = CARDS.map((card) => new SummaryCard({ - status: c.status, - label: c.label, - count: this._countFor(c.status), - active: this.store.resultFilter === c.status, - onClick: () => this._setFilter(c.status), + status: card.status, + label: t(card.labelKey), + count: this._countFor(card.status), + active: this.store.resultFilter === card.status, + onClick: () => this._setFilter(card.status), }).root ); @@ -72,10 +79,13 @@ export class ResultsView { el('div', { class: 'u-container' }, [ el('header', { class: 'results-header' }, [ el('div', {}, [ - el('h2', { class: 'results-header__title', text: 'Comparison complete' }), + el('h2', { class: 'results-header__title', text: t('results.complete') }), el('p', { class: 'results-header__sub u-text-muted', - text: formatCount(s.totalA) + ' rows in File A · ' + formatCount(s.totalB) + ' rows in File B', + text: t('results.rowsSummary', { + a: formatCount(summary.totalA), + b: formatCount(summary.totalB), + }), }), ]), el('div', { class: 'results-header__actions' }, [ @@ -83,15 +93,21 @@ export class ResultsView { type: 'button', class: 'btn btn--ghost', onclick: () => window.dispatchEvent(new CustomEvent(AppEvents.RESET_REQUESTED)), - }, [icon(Icons.refresh, { size: 14 }), 'Start over']), + }, [icon(Icons.refresh, { size: 14 }), t('results.startOver')]), el('button', { type: 'button', class: 'btn btn--primary', - onclick: () => window.dispatchEvent(new CustomEvent(AppEvents.EXPORT_REQUESTED, { detail: { format: 'csv' } })), - }, [icon(Icons.table, { size: 14 }), 'Export CSV']), + onclick: () => window.dispatchEvent(new CustomEvent(AppEvents.EXPORT_REQUESTED, { + detail: { format: 'csv' }, + })), + }, [icon(Icons.table, { size: 14 }), t('results.exportCsv')]), ]), ]), - el('div', { class: 'summary-grid', role: 'tablist', 'aria-label': 'Result filters' }, cards), + el('div', { + class: 'summary-grid', + role: 'tablist', + 'aria-label': t('results.filters'), + }, cards), this.table.root, ]), this.drawer.root, From 356a6c3bbeee08d1495e3888bc632167ef7c72a2 Mon Sep 17 00:00:00 2001 From: QvarcY <118832438+QvarcY@users.noreply.github.com> Date: Sat, 26 Sep 2026 19:24:53 +0300 Subject: [PATCH 4/9] fix: simplify mapping UX and harden suggested selections --- ROADMAP.md | 2 +- src/i18n/locales/en.js | 34 ++++++++++++------------- src/i18n/locales/lv.js | 34 ++++++++++++------------- src/services/mock-comparison-service.js | 1 - src/ui/components/mapping-row.js | 2 +- 5 files changed, 36 insertions(+), 37 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index acf4b0c..4cca0e9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -141,7 +141,7 @@ Status: ✅ complete · 🟡 in progress · ⬜ planned · ⛔ blocked | 2026-09-26 | R0 — Scaffold | ✅ | Vite project, service boundary, initial repository baseline. | | 2026-09-26 | R1 — Frontend prototype | ✅ | File panels, mapping UI, progress view, result dashboard and drawer added. | | 2026-09-26 | R2 — i18n + documentation foundation | ✅ | EN/LV locale registry, bilingual README, translation guide, roadmap, line-ending policy and package cleanup. | -| 2026-09-26 | R3 — Live i18n + frontend cleanup | 🟡 | Fixed language-switch reset/crash, wired EN/LV through the workflow, kept files in memory during locale changes, fixed mock slot replacement and comparison error recovery. Build verification pending. | +| 2026-09-26 | R3 — Live i18n + frontend cleanup | 🟡 | Fixed language-switch reset/crash, wired EN/LV through the workflow, kept files in memory during locale changes, simplified the mapping UX, hardened select value rendering, fixed mock slot replacement and comparison error recovery. Build verification pending. | ## Next target / Nākamais mērķis diff --git a/src/i18n/locales/en.js b/src/i18n/locales/en.js index 23c2ecc..ea1f173 100644 --- a/src/i18n/locales/en.js +++ b/src/i18n/locales/en.js @@ -44,21 +44,21 @@ export default { 'delimiter.pipe': 'pipe', 'mapping.aria': 'Column mapping', - 'mapping.title': 'Match the columns', - 'mapping.hint': 'Define which columns identify a record and which values to compare. Suggestions are proposals — review before running.', - 'mapping.matchKeys': 'Match keys', - 'mapping.matchKeysHint': 'Required · identifies records across both files', - 'mapping.addKey': '+ Add key', - 'mapping.noKeys': 'No match keys yet. Add at least one.', - 'mapping.compareFields': 'Compare fields', - 'mapping.compareHint': 'Optional · values that must match', - 'mapping.addCompare': '+ Add compare', - 'mapping.noCompare': 'No compare fields yet.', - 'mapping.normalization': 'Normalization', - 'mapping.normalizationHint': 'How values are treated before matching', - 'mapping.trim': 'Trim whitespace', - 'mapping.ignoreCase': 'Ignore case', - 'mapping.collapseWhitespace': 'Collapse internal whitespace', + 'mapping.title': 'What do you want to compare?', + 'mapping.hint': 'First choose how the same record is identified in both files. Then choose which values should be checked.', + 'mapping.matchKeys': '1. How do we identify the same record?', + 'mapping.matchKeysHint': 'For example: invoice number, order ID or payment reference.', + 'mapping.addKey': '+ Add another identifier', + 'mapping.noKeys': 'Choose at least one pair of columns that identifies the same record in both files.', + 'mapping.compareFields': '2. What should be checked for those records?', + 'mapping.compareHint': 'For example: amount, date or status. This step is optional.', + 'mapping.addCompare': '+ Add another check', + 'mapping.noCompare': 'No additional fields will be compared.', + 'mapping.normalization': 'Advanced comparison rules', + 'mapping.normalizationHint': 'You normally do not need to change these settings.', + 'mapping.trim': 'Ignore spaces at the beginning and end', + 'mapping.ignoreCase': 'Ignore upper/lower case', + 'mapping.collapseWhitespace': 'Treat repeated spaces as one', 'mapping.startOver': 'Start over', 'mapping.compareFiles': 'Compare files', 'mapping.fileAColumn': 'File A column', @@ -67,8 +67,8 @@ export default { 'mapping.select': '— select —', 'mapping.toleranceMode': 'Tolerance mode', 'mapping.toleranceValue': 'Tolerance value', - 'mapping.role.key': 'MATCH KEY', - 'mapping.role.compare': 'COMPARE', + 'mapping.role.key': 'IDENTIFIER', + 'mapping.role.compare': 'CHECK', 'mapping.role.display': 'DISPLAY', 'mapping.role.ignore': 'IGNORE', diff --git a/src/i18n/locales/lv.js b/src/i18n/locales/lv.js index eccbb4a..8b41c18 100644 --- a/src/i18n/locales/lv.js +++ b/src/i18n/locales/lv.js @@ -44,21 +44,21 @@ export default { 'delimiter.pipe': 'vertikālā svītra', 'mapping.aria': 'Kolonnu savienošana', - 'mapping.title': 'Savieno kolonnas', - 'mapping.hint': 'Norādi, kuras kolonnas identificē ierakstu un kuras vērtības jāsalīdzina. Ieteikumi ir tikai priekšlikumi — pārbaudi tos pirms palaišanas.', - 'mapping.matchKeys': 'Atbilstības atslēgas', - 'mapping.matchKeysHint': 'Obligāti · identificē ierakstus abos failos', - 'mapping.addKey': '+ Pievienot atslēgu', - 'mapping.noKeys': 'Atbilstības atslēgu vēl nav. Pievieno vismaz vienu.', - 'mapping.compareFields': 'Salīdzināmie lauki', - 'mapping.compareHint': 'Neobligāti · vērtības, kurām jāsakrīt', - 'mapping.addCompare': '+ Pievienot lauku', - 'mapping.noCompare': 'Salīdzināmo lauku vēl nav.', - 'mapping.normalization': 'Normalizācija', - 'mapping.normalizationHint': 'Kā vērtības tiek apstrādātas pirms salīdzināšanas', - 'mapping.trim': 'Noņemt liekās malas atstarpes', - 'mapping.ignoreCase': 'Ignorēt burtu reģistru', - 'mapping.collapseWhitespace': 'Apvienot atkārtotas atstarpes', + 'mapping.title': 'Ko vēlies salīdzināt?', + 'mapping.hint': 'Vispirms norādi, pēc kuras kolonnas abos failos atpazīt vienu un to pašu ierakstu. Pēc tam izvēlies, kuras vērtības pārbaudīt.', + 'mapping.matchKeys': '1. Kā atpazīt vienu un to pašu ierakstu?', + 'mapping.matchKeysHint': 'Piemēram: rēķina numurs, pasūtījuma ID vai maksājuma atsauce.', + 'mapping.addKey': '+ Pievienot vēl vienu identifikatoru', + 'mapping.noKeys': 'Izvēlies vismaz vienu kolonnu pāri, kas abos failos apzīmē vienu un to pašu ierakstu.', + 'mapping.compareFields': '2. Ko pārbaudīt šajos ierakstos?', + 'mapping.compareHint': 'Piemēram: summa, datums vai statuss. Šis solis nav obligāts.', + 'mapping.addCompare': '+ Pievienot vēl vienu pārbaudi', + 'mapping.noCompare': 'Papildu lauki netiks salīdzināti.', + 'mapping.normalization': 'Papildu salīdzināšanas noteikumi', + 'mapping.normalizationHint': 'Parasti šos iestatījumus nav jāmaina.', + 'mapping.trim': 'Ignorēt atstarpes sākumā un beigās', + 'mapping.ignoreCase': 'Nešķirot lielos un mazos burtus', + 'mapping.collapseWhitespace': 'Vairākas atstarpes uzskatīt par vienu', 'mapping.startOver': 'Sākt no jauna', 'mapping.compareFiles': 'Salīdzināt failus', 'mapping.fileAColumn': 'Faila A kolonna', @@ -67,8 +67,8 @@ export default { 'mapping.select': '— izvēlies —', 'mapping.toleranceMode': 'Pielaides režīms', 'mapping.toleranceValue': 'Pielaides vērtība', - 'mapping.role.key': 'ATSLĒGA', - 'mapping.role.compare': 'SALĪDZINĀT', + 'mapping.role.key': 'IDENTIFIKATORS', + 'mapping.role.compare': 'PĀRBAUDĪT', 'mapping.role.display': 'RĀDĪT', 'mapping.role.ignore': 'IGNORĒT', diff --git a/src/services/mock-comparison-service.js b/src/services/mock-comparison-service.js index 1c0192d..3fd2b16 100644 --- a/src/services/mock-comparison-service.js +++ b/src/services/mock-comparison-service.js @@ -74,7 +74,6 @@ export class MockComparisonService extends ComparisonService { ['Reference', 'Payment Ref'], ['Amount', 'Total'], ['Date', 'Paid Date'], - ['Description', 'Customer'], ]; for (const [a, b] of pairs) { diff --git a/src/ui/components/mapping-row.js b/src/ui/components/mapping-row.js index cc46756..2a74f66 100644 --- a/src/ui/components/mapping-row.js +++ b/src/ui/components/mapping-row.js @@ -89,10 +89,10 @@ export class MappingRow { for (const column of columns) { const option = el('option', { value: column.id, text: column.label }); - if (column.id === value) option.selected = true; select.appendChild(option); } + select.value = value ?? ''; return select; } From 9799e94bc2d2d224bb7d722643910d30fe525d98 Mon Sep 17 00:00:00 2001 From: QvarcY <118832438+QvarcY@users.noreply.github.com> Date: Sat, 26 Sep 2026 19:35:46 +0300 Subject: [PATCH 5/9] feat: pivot primary workflow to automatic visual diff --- README.lv.md | 12 +- README.md | 12 +- ROADMAP.md | 9 +- src/app/app-controller.js | 79 +++++++++- src/app/app-events.js | 1 + src/i18n/locales/en.js | 14 ++ src/i18n/locales/lv.js | 14 ++ src/services/mock-comparison-service.js | 2 +- src/styles/components.css | 200 ++++++++++++++++++++++++ src/ui/components/visual-diff.js | 116 ++++++++++++++ src/ui/views/results-view.js | 119 ++++++-------- 11 files changed, 486 insertions(+), 92 deletions(-) create mode 100644 src/ui/components/visual-diff.js diff --git a/README.lv.md b/README.lv.md index 27a11a5..6053b3c 100644 --- a/README.lv.md +++ b/README.lv.md @@ -48,17 +48,13 @@ Plānotie produkcijas noteikumi: ~~~text FAILS A + FAILS B ↓ -PĀRBAUDE / PRIEKŠSKATS +AUTOMĀTISKA LOKĀLA ANALĪZE ↓ -KOLONNU SAVIENOŠANA +VIZUĀLS SALĪDZINĀJUMS BLAKUS ↓ -NORMALIZĀCIJAS IESTATĪŠANA +SARKANS = ATŠĶIRĪBA / NEITRĀLS = SAKRĪT ↓ -LOKĀLA SALĪDZINĀŠANA - ↓ -SAKRĪT / TIKAI A / TIKAI B / NESAKRĪT / DUBLIKĀTS / NESKAIDRS - ↓ -DETALIZĒTA PĀRBAUDE +PĒC VAJADZĪBAS — EKSPERTA IESTATĪJUMI ↓ ATSKAITES EKSPORTS ~~~ diff --git a/README.md b/README.md index 47617da..c71e1c1 100644 --- a/README.md +++ b/README.md @@ -48,17 +48,13 @@ This architecture reduces unnecessary data transfer, but the project does **not* ~~~text DROP FILE A + FILE B ↓ -INSPECT / PREVIEW +AUTOMATIC LOCAL ANALYSIS ↓ -MAP COLUMNS +SIDE-BY-SIDE VISUAL DIFF ↓ -CONFIGURE NORMALIZATION +RED = DIFFERENCE / NEUTRAL = MATCH ↓ -COMPARE LOCALLY - ↓ -MATCHED / ONLY A / ONLY B / MISMATCH / DUPLICATE / AMBIGUOUS - ↓ -INSPECT DETAILS +OPTIONAL EXPERT SETTINGS ↓ EXPORT REPORT ~~~ diff --git a/ROADMAP.md b/ROADMAP.md index 4cca0e9..97138fa 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -20,7 +20,9 @@ Status: ✅ complete · 🟡 in progress · ⬜ planned · ⛔ blocked - ✅ Modern dark-first UI prototype. - ✅ File A / File B panels. -- ✅ Mapping workspace. +- ✅ Mapping workspace retained as Expert Mode. +- ✅ Automatic compare path after two files are selected. +- ✅ Side-by-side Visual Diff becomes the primary result experience. - ✅ Compare progress screen. - ✅ Results cards, table and record drawer. - ✅ Safe DOM renderer using textContent for imported values. @@ -141,8 +143,9 @@ Status: ✅ complete · 🟡 in progress · ⬜ planned · ⛔ blocked | 2026-09-26 | R0 — Scaffold | ✅ | Vite project, service boundary, initial repository baseline. | | 2026-09-26 | R1 — Frontend prototype | ✅ | File panels, mapping UI, progress view, result dashboard and drawer added. | | 2026-09-26 | R2 — i18n + documentation foundation | ✅ | EN/LV locale registry, bilingual README, translation guide, roadmap, line-ending policy and package cleanup. | -| 2026-09-26 | R3 — Live i18n + frontend cleanup | 🟡 | Fixed language-switch reset/crash, wired EN/LV through the workflow, kept files in memory during locale changes, simplified the mapping UX, hardened select value rendering, fixed mock slot replacement and comparison error recovery. Build verification pending. | +| 2026-09-26 | R3 — Live i18n + frontend cleanup | ✅ | Fixed language-switch reset/crash, wired EN/LV through the workflow, kept files in memory during locale changes, simplified mapping and fixed mock slot replacement/error recovery. | +| 2026-09-26 | R4 — Visual Diff pivot | 🟡 | Product flow changed to Upload → automatic comparison → side-by-side Visual Diff. Manual mapping moved behind Expert settings. Build and UX verification pending. | ## Next target / Nākamais mērķis -**R3 exit check:** run a clean production build and switch EN ↔ LV while files are already selected and while the mapping screen is open. After that, start Phase 2 with the real CSV/TSV inspector. +**R4 exit check:** pull the Visual Diff prototype, verify that two selected files lead directly to the visual result, confirm red difference highlighting and neutral matches, then continue with the real CSV/TSV inspector. diff --git a/src/app/app-controller.js b/src/app/app-controller.js index cc8d3eb..86cb463 100644 --- a/src/app/app-controller.js +++ b/src/app/app-controller.js @@ -25,6 +25,7 @@ export class AppController { this.handleExport(event.detail); }); window.addEventListener(AppEvents.RESET_REQUESTED, () => this.handleReset()); + window.addEventListener(AppEvents.EXPERT_MAPPING_REQUESTED, () => this.handleExpertMapping()); } async handleFileSelected(slot, file) { @@ -42,7 +43,10 @@ export class AppController { [loadingKey]: false, }); - this._recomputeState(); + const ready = this._recomputeState(); + if (ready) { + await this.handleAutoCompare(); + } } catch (error) { this.store.patch({ [loadingKey]: false }); @@ -153,6 +157,66 @@ export class AppController { } } + async handleAutoCompare() { + const { fileA, fileB } = this.store; + if (!fileA || !fileB) return; + + try { + const suggestions = await this.service.suggestMappings(fileA, fileB); + + if (!suggestions?.length) { + this.store.patch({ mapping: null, mappingValid: false }); + this.store.setState('MAPPING'); + return; + } + + const [keySuggestion, ...comparisonSuggestions] = suggestions; + const mapping = { + keys: [{ + columnA: keySuggestion.columnA, + columnB: keySuggestion.columnB, + type: 'text', + }], + comparisons: comparisonSuggestions.map((suggestion) => ({ + columnA: suggestion.columnA, + columnB: suggestion.columnB, + type: this._guessMappingType(fileA, fileB, suggestion.columnA, suggestion.columnB), + tolerance: { mode: 'absolute', value: 0.01 }, + })), + displayOnly: { fileA: [], fileB: [] }, + normalization: { + trimText: true, + caseInsensitive: true, + collapseWhitespace: true, + numberLocale: 'auto', + dateFormatA: null, + dateFormatB: null, + }, + }; + + this.store.patch({ mapping, mappingValid: true }); + await this.handleCompare(mapping); + } catch (error) { + this.store.patch({ mapping: null, mappingValid: false }); + this.store.setState('MAPPING'); + console.error('[AppController] automatic comparison setup failed:', error); + } + } + + _guessMappingType(fileA, fileB, columnA, columnB) { + const typeA = fileA.columns.find((column) => column.id === columnA)?.inferredType; + const typeB = fileB.columns.find((column) => column.id === columnB)?.inferredType; + + if (typeA === 'number' || typeB === 'number') return 'number'; + if (typeA === 'date' || typeB === 'date') return 'date'; + return 'text'; + } + + handleExpertMapping() { + if (!this.store.fileA || !this.store.fileB) return; + this.store.setState(this.store.mappingValid ? 'READY_TO_COMPARE' : 'MAPPING'); + } + handleReset() { this.service.reset(); @@ -177,10 +241,15 @@ export class AppController { if (!fileA && !fileB) { this.store.setState('EMPTY'); - } else if (fileA && fileB) { - this.store.setState('MAPPING'); - } else { - this.store.setState('FILES_PARTIAL'); + return false; } + + if (fileA && fileB) { + this.store.setState('FILES_READY'); + return true; + } + + this.store.setState('FILES_PARTIAL'); + return false; } } diff --git a/src/app/app-events.js b/src/app/app-events.js index 568f149..f2be6c5 100644 --- a/src/app/app-events.js +++ b/src/app/app-events.js @@ -12,6 +12,7 @@ export const AppEvents = Object.freeze({ // Mapping MAPPING_UPDATED: 'MAPPING_UPDATED', NORMALIZATION_UPDATED: 'NORMALIZATION_UPDATED', + EXPERT_MAPPING_REQUESTED: 'EXPERT_MAPPING_REQUESTED', // Comparison COMPARE_REQUESTED: 'COMPARE_REQUESTED', diff --git a/src/i18n/locales/en.js b/src/i18n/locales/en.js index ea1f173..9b3134a 100644 --- a/src/i18n/locales/en.js +++ b/src/i18n/locales/en.js @@ -104,6 +104,20 @@ export default { 'results.prev': '← Prev', 'results.next': 'Next →', + 'visual.autoCompared': 'Compared automatically', + 'visual.differencesFound': 'Differences found', + 'visual.filesMatch': 'Files match', + 'visual.diffSubtitle': '{count} records need attention. Matching rows stay visually quiet.', + 'visual.matchSubtitle': 'No differences were found in the compared data.', + 'visual.fileA': 'File A', + 'visual.fileB': 'File B', + 'visual.expert': 'Expert settings', + 'visual.onlyHere': 'Only in this file', + 'visual.changed': 'Different values', + 'visual.matched': 'Matches', + 'visual.ambiguous': 'Needs review', + 'visual.row': 'row {row}', + 'record.detail': 'Record detail', 'record.close': 'Close', 'record.fileA': 'File A', diff --git a/src/i18n/locales/lv.js b/src/i18n/locales/lv.js index 8b41c18..75ba1d5 100644 --- a/src/i18n/locales/lv.js +++ b/src/i18n/locales/lv.js @@ -104,6 +104,20 @@ export default { 'results.prev': '← Iepriekšējā', 'results.next': 'Nākamā →', + 'visual.autoCompared': 'Salīdzināts automātiski', + 'visual.differencesFound': 'Atrastas atšķirības', + 'visual.filesMatch': 'Faili sakrīt', + 'visual.diffSubtitle': '{count} ierakstiem jāpievērš uzmanība. Sakritības paliek vizuāli neitrālas.', + 'visual.matchSubtitle': 'Salīdzinātajos datos atšķirības netika atrastas.', + 'visual.fileA': 'Fails A', + 'visual.fileB': 'Fails B', + 'visual.expert': 'Eksperta iestatījumi', + 'visual.onlyHere': 'Tikai šajā failā', + 'visual.changed': 'Atšķirīgas vērtības', + 'visual.matched': 'Sakrīt', + 'visual.ambiguous': 'Jāpārbauda', + 'visual.row': 'rinda {row}', + 'record.detail': 'Ieraksta detaļas', 'record.close': 'Aizvērt', 'record.fileA': 'Fails A', diff --git a/src/services/mock-comparison-service.js b/src/services/mock-comparison-service.js index 3fd2b16..db585a1 100644 --- a/src/services/mock-comparison-service.js +++ b/src/services/mock-comparison-service.js @@ -150,7 +150,7 @@ export class MockComparisonService extends ComparisonService { displayA: { Date: '2026-09-18', Amount: '125.00' }, displayB: { 'Paid Date': '2026-09-18', Total: '152.00' }, differences: [ - { field: 'amount', valueA: 125.0, valueB: 152.0, delta: 27.0 }, + { field: 'amount', fieldA: 'Amount', fieldB: 'Total', valueA: 125.0, valueB: 152.0, delta: 27.0 }, ], }, { diff --git a/src/styles/components.css b/src/styles/components.css index dc8dbb8..6f93480 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -1720,3 +1720,203 @@ outline: 2px solid var(--color-accent); outline-offset: 2px; } + + +/* ========================================================================== + Visual diff — primary product experience + ========================================================================== */ +.visual-results { + display: flex; + flex-direction: column; + gap: var(--space-6); +} + +.visual-results__header { + display: flex; + justify-content: space-between; + align-items: flex-end; + gap: var(--space-6); + flex-wrap: wrap; +} + +.visual-results__heading { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.visual-results__auto { + width: fit-content; + padding: 3px var(--space-2); + border-radius: var(--radius-pill); + background: var(--tint-info); + color: var(--color-info); + font-size: var(--text-xs); + font-weight: var(--weight-semi); +} + +.visual-results__title { + font-size: var(--text-3xl); +} + +.visual-results__subtitle { + color: var(--color-text-2); + max-width: 720px; +} + +.visual-results__actions { + display: flex; + gap: var(--space-2); + flex-wrap: wrap; +} + +.visual-diff { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.visual-diff__pair { + display: grid; + grid-template-columns: minmax(0, 1fr) 120px minmax(0, 1fr); + gap: var(--space-3); + align-items: stretch; +} + +.visual-diff__side { + min-width: 0; + padding: var(--space-4); + border: 1px solid var(--color-border-0); + border-radius: var(--radius-lg); + background: var(--color-surface-0); +} + +.visual-diff__pair[data-tone='matched'] .visual-diff__side { + border-color: rgba(96, 165, 250, 0.18); + background: rgba(96, 165, 250, 0.035); +} + +.visual-diff__pair[data-tone='attention'] .visual-diff__side { + border-color: rgba(248, 113, 113, 0.36); + background: rgba(248, 113, 113, 0.055); +} + +.visual-diff__side--missing { + display: grid; + place-items: center; + min-height: 130px; + border-style: dashed; + color: var(--color-mismatch); + background: rgba(248, 113, 113, 0.04); +} + +.visual-diff__missing-label { + font-size: var(--text-sm); + font-weight: var(--weight-semi); +} + +.visual-diff__side-head { + display: flex; + justify-content: space-between; + gap: var(--space-3); + color: var(--color-text-3); + font-size: var(--text-xs); + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.visual-diff__record-key { + margin-top: var(--space-2); + color: var(--color-text-0); + font-family: var(--font-mono); + font-weight: var(--weight-semi); +} + +.visual-diff__fields { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: var(--space-2); + margin-top: var(--space-4); +} + +.visual-diff__field { + display: flex; + flex-direction: column; + gap: 2px; + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-sm); + background: var(--color-surface-1); +} + +.visual-diff__field[data-changed='true'] { + background: rgba(248, 113, 113, 0.14); + box-shadow: inset 0 0 0 1px rgba(248, 113, 113, 0.38); +} + +.visual-diff__field-name { + color: var(--color-text-3); + font-size: var(--text-xs); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.visual-diff__field-value { + color: var(--color-text-0); + font-family: var(--font-mono); + word-break: break-word; +} + +.visual-diff__status { + align-self: center; + justify-self: stretch; + padding: var(--space-2); + border-radius: var(--radius-pill); + text-align: center; + font-size: var(--text-xs); + font-weight: var(--weight-semi); + color: var(--color-text-2); + background: var(--color-surface-1); +} + +.visual-diff__status[data-status='MATCHED'] { + color: var(--color-info); + background: var(--tint-info); +} + +.visual-diff__status[data-status='MISMATCH'], +.visual-diff__status[data-status='ONLY_A'], +.visual-diff__status[data-status='ONLY_B'] { + color: var(--color-mismatch); + background: var(--tint-mismatch); +} + +.visual-diff__status[data-status='DUPLICATE'], +.visual-diff__status[data-status='AMBIGUOUS'] { + color: var(--color-ambig); + background: var(--tint-ambig); +} + +@media (max-width: 860px) { + .visual-diff__pair { + grid-template-columns: 1fr; + } + + .visual-diff__status { + width: fit-content; + justify-self: center; + } + + .visual-results__actions { + width: 100%; + } +} + +@media (max-width: 560px) { + .visual-results__actions .btn { + flex: 1 1 100%; + } + + .visual-diff__fields { + grid-template-columns: 1fr; + } +} diff --git a/src/ui/components/visual-diff.js b/src/ui/components/visual-diff.js new file mode 100644 index 0000000..6d57c03 --- /dev/null +++ b/src/ui/components/visual-diff.js @@ -0,0 +1,116 @@ +import { el } from '../renderers/dom.js'; +import { t } from '../../i18n/index.js'; + +const ATTENTION_STATUSES = new Set([ + 'MISMATCH', + 'ONLY_A', + 'ONLY_B', + 'DUPLICATE', + 'AMBIGUOUS', +]); + +export class VisualDiff { + constructor(store) { + this.store = store; + this.root = el('div', { class: 'visual-diff' }); + this.render(); + } + + render() { + const records = this.store.result?.records ?? []; + this.root.replaceChildren( + ...records.map((record) => this._renderPair(record)) + ); + } + + _renderPair(record) { + const attention = ATTENTION_STATUSES.has(record.status); + const statusClass = attention ? 'attention' : 'matched'; + + return el('article', { + class: 'visual-diff__pair', + dataset: { status: record.status, tone: statusClass }, + }, [ + this._renderSide('A', record), + this._renderStatus(record), + this._renderSide('B', record), + ]); + } + + _renderSide(side, record) { + const isA = side === 'A'; + const data = isA ? record.displayA : record.displayB; + const rowNumber = isA ? record.source?.rowA : record.source?.rowB; + const missing = + (record.status === 'ONLY_A' && !isA) || + (record.status === 'ONLY_B' && isA); + + if (missing) { + return el('div', { + class: 'visual-diff__side visual-diff__side--missing', + }, [ + el('div', { + class: 'visual-diff__missing-label', + text: t('visual.onlyHere'), + }), + ]); + } + + const entries = Object.entries(data ?? {}); + + return el('div', { class: 'visual-diff__side' }, [ + el('div', { class: 'visual-diff__side-head' }, [ + el('span', { + class: 'visual-diff__file-label', + text: isA ? t('visual.fileA') : t('visual.fileB'), + }), + el('span', { + class: 'visual-diff__row', + text: rowNumber != null ? t('visual.row', { row: rowNumber }) : '—', + }), + ]), + el('div', { class: 'visual-diff__record-key', text: record.keyLabel }), + el('div', { class: 'visual-diff__fields' }, + entries.map(([field, value]) => + el('div', { + class: 'visual-diff__field', + dataset: { + changed: this._fieldChanged(record, side, field) ? 'true' : 'false', + }, + }, [ + el('span', { class: 'visual-diff__field-name', text: field }), + el('span', { class: 'visual-diff__field-value', text: String(value ?? '—') }), + ]) + ) + ), + ]); + } + + _fieldChanged(record, side, field) { + if (record.status !== 'MISMATCH') return false; + + const target = String(field).toLowerCase(); + + return (record.differences ?? []).some((difference) => { + const explicit = side === 'A' ? difference.fieldA : difference.fieldB; + if (explicit) return String(explicit).toLowerCase() === target; + + const generic = String(difference.field ?? '').toLowerCase(); + return generic === target; + }); + } + + _renderStatus(record) { + let label = t('visual.matched'); + + if (record.status === 'MISMATCH') label = t('visual.changed'); + else if (record.status === 'ONLY_A' || record.status === 'ONLY_B') label = t('visual.onlyHere'); + else if (record.status === 'DUPLICATE' || record.status === 'AMBIGUOUS') label = t('visual.ambiguous'); + + return el('div', { + class: 'visual-diff__status', + dataset: { status: record.status }, + text: label, + }); + } +} diff --git a/src/ui/views/results-view.js b/src/ui/views/results-view.js index c6a0b4d..087ee62 100644 --- a/src/ui/views/results-view.js +++ b/src/ui/views/results-view.js @@ -1,31 +1,25 @@ import { el, replaceChildren, icon, Icons } from '../renderers/dom.js'; -import { formatCount } from '../renderers/format.js'; -import { SummaryCard } from '../components/summary-card.js'; -import { ResultsTable } from '../components/results-table.js'; -import { RecordDetailDrawer } from '../components/record-detail-drawer.js'; +import { VisualDiff } from '../components/visual-diff.js'; import { AppEvents } from '../../app/app-events.js'; import { LOCALE_CHANGE_EVENT, t } from '../../i18n/index.js'; -const CARDS = [ - { status: 'ALL', labelKey: 'results.all' }, - { status: 'MISMATCH', labelKey: 'results.mismatch' }, - { status: 'ONLY_A', labelKey: 'results.onlyA' }, - { status: 'ONLY_B', labelKey: 'results.onlyB' }, - { status: 'DUPLICATE', labelKey: 'results.duplicate' }, - { status: 'AMBIGUOUS', labelKey: 'results.ambiguous' }, - { status: 'MATCHED', labelKey: 'results.matched' }, -]; +const ATTENTION_STATUSES = new Set([ + 'MISMATCH', + 'ONLY_A', + 'ONLY_B', + 'DUPLICATE', + 'AMBIGUOUS', +]); export class ResultsView { constructor(store) { this.store = store; - this.table = new ResultsTable(store); - this.drawer = new RecordDetailDrawer(store); - this.root = el('section', { class: 'app-main', hidden: '' }); + store.addEventListener('state', () => this._sync()); store.addEventListener('change', () => this._sync()); window.addEventListener(LOCALE_CHANGE_EVENT, () => this._sync()); + this._sync(); } @@ -41,76 +35,67 @@ export class ResultsView { this._render(); } - _countFor(status) { - const summary = this.store.result.summary; - - if (status === 'ALL') return this.store.result.records.length; - if (status === 'MISMATCH') return summary.mismatched; - if (status === 'ONLY_A') return summary.onlyA; - if (status === 'ONLY_B') return summary.onlyB; - if (status === 'DUPLICATE') return summary.duplicates; - if (status === 'AMBIGUOUS') return summary.ambiguous; - if (status === 'MATCHED') return summary.matched; - return 0; - } + _render() { + const records = this.store.result.records ?? []; + const attentionCount = records.filter((record) => + ATTENTION_STATUSES.has(record.status) + ).length; - _setFilter(status) { - this.store.patch({ resultFilter: status }); - window.dispatchEvent(new CustomEvent(AppEvents.RESULT_FILTER_CHANGED, { - detail: { status }, - })); - this._render(); - } + const title = attentionCount > 0 + ? t('visual.differencesFound') + : t('visual.filesMatch'); - _render() { - const summary = this.store.result.summary; + const subtitle = attentionCount > 0 + ? t('visual.diffSubtitle', { count: attentionCount }) + : t('visual.matchSubtitle'); - const cards = CARDS.map((card) => - new SummaryCard({ - status: card.status, - label: t(card.labelKey), - count: this._countFor(card.status), - active: this.store.resultFilter === card.status, - onClick: () => this._setFilter(card.status), - }).root - ); + const visualDiff = new VisualDiff(this.store); replaceChildren(this.root, [ - el('div', { class: 'u-container' }, [ - el('header', { class: 'results-header' }, [ - el('div', {}, [ - el('h2', { class: 'results-header__title', text: t('results.complete') }), + el('div', { class: 'u-container visual-results' }, [ + el('header', { class: 'visual-results__header' }, [ + el('div', { class: 'visual-results__heading' }, [ + el('span', { + class: 'visual-results__auto', + text: t('visual.autoCompared'), + }), + el('h2', { + class: 'visual-results__title', + text: title, + }), el('p', { - class: 'results-header__sub u-text-muted', - text: t('results.rowsSummary', { - a: formatCount(summary.totalA), - b: formatCount(summary.totalB), - }), + class: 'visual-results__subtitle', + text: subtitle, }), ]), - el('div', { class: 'results-header__actions' }, [ + el('div', { class: 'visual-results__actions' }, [ + el('button', { + type: 'button', + class: 'btn btn--ghost', + onclick: () => window.dispatchEvent( + new CustomEvent(AppEvents.EXPERT_MAPPING_REQUESTED) + ), + }, [t('visual.expert')]), el('button', { type: 'button', class: 'btn btn--ghost', - onclick: () => window.dispatchEvent(new CustomEvent(AppEvents.RESET_REQUESTED)), + onclick: () => window.dispatchEvent( + new CustomEvent(AppEvents.RESET_REQUESTED) + ), }, [icon(Icons.refresh, { size: 14 }), t('results.startOver')]), el('button', { type: 'button', class: 'btn btn--primary', - onclick: () => window.dispatchEvent(new CustomEvent(AppEvents.EXPORT_REQUESTED, { - detail: { format: 'csv' }, - })), + onclick: () => window.dispatchEvent( + new CustomEvent(AppEvents.EXPORT_REQUESTED, { + detail: { format: 'csv' }, + }) + ), }, [icon(Icons.table, { size: 14 }), t('results.exportCsv')]), ]), ]), - el('div', { - class: 'summary-grid', - role: 'tablist', - 'aria-label': t('results.filters'), - }, cards), - this.table.root, + visualDiff.root, ]), - this.drawer.root, ]); } } From b4e57c4a51607e56f3fc1d4150c02af2a7c84aca Mon Sep 17 00:00:00 2001 From: QvarcY <118832438+QvarcY@users.noreply.github.com> Date: Sat, 26 Sep 2026 19:41:42 +0300 Subject: [PATCH 6/9] feat: add real local CSV comparison engine MVP --- ROADMAP.md | 29 +- package.json | 3 +- src/engine/delimited-parser.js | 208 ++++++++++++ src/engine/mapping-suggester.js | 155 +++++++++ src/engine/normalization.js | 64 ++++ src/main.js | 4 +- src/services/browser-comparison-service.js | 350 ++++++++++++++++++++- tests/browser-comparison-service.test.js | 79 +++++ tests/delimited-parser.test.js | 29 ++ tests/fixtures/file-a.csv | 13 + tests/fixtures/file-b.csv | 13 + 11 files changed, 915 insertions(+), 32 deletions(-) create mode 100644 src/engine/delimited-parser.js create mode 100644 src/engine/mapping-suggester.js create mode 100644 src/engine/normalization.js create mode 100644 tests/browser-comparison-service.test.js create mode 100644 tests/delimited-parser.test.js create mode 100644 tests/fixtures/file-a.csv create mode 100644 tests/fixtures/file-b.csv diff --git a/ROADMAP.md b/ROADMAP.md index 97138fa..d57ff3f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -45,18 +45,18 @@ Status: ✅ complete · 🟡 in progress · ⬜ planned · ⛔ blocked **Exit criteria:** complete bilingual UI, clean build, no duplicate IDs, package metadata consistent, no known frontend blocker. -## Phase 2 — Real file inspection and parsing / Īsta failu pārbaude un parsēšana ⬜ - -- ⬜ Replace mock file inspection in BrowserComparisonService. -- ⬜ CSV parser with quoted fields and multiline quoted values. -- ⬜ TSV support. -- ⬜ Comma / semicolon / tab delimiter detection. -- ⬜ UTF-8 and UTF-8 BOM handling. -- ⬜ Clear unsupported-encoding error. -- ⬜ Header discovery and safe preview. -- ⬜ Row and column metadata. -- ⬜ File-size and malformed-input safeguards. -- ⬜ Parser fixtures for real edge cases. +## Phase 2 — Real file inspection and parsing / Īsta failu pārbaude un parsēšana 🟡 + +- ✅ Replace mock file inspection in BrowserComparisonService. +- ✅ CSV parser with quoted fields and multiline quoted values. +- ✅ TSV support. +- ✅ Comma / semicolon / tab delimiter detection. +- ✅ UTF-8 and UTF-8 BOM handling. +- ✅ Detect replacement characters and fail instead of silently showing corrupted UTF-8. +- ✅ Header discovery and safe preview. +- ✅ Row and column metadata with basic type inference. +- ✅ 50 MiB file-size guard and malformed quoted-field error. +- ✅ Parser fixtures for BOM, delimiter detection, quoted commas and multiline values. **Exit criteria:** real CSV/TSV files can be inspected locally without upload and parser fixtures prove edge-case behavior. @@ -144,8 +144,9 @@ Status: ✅ complete · 🟡 in progress · ⬜ planned · ⛔ blocked | 2026-09-26 | R1 — Frontend prototype | ✅ | File panels, mapping UI, progress view, result dashboard and drawer added. | | 2026-09-26 | R2 — i18n + documentation foundation | ✅ | EN/LV locale registry, bilingual README, translation guide, roadmap, line-ending policy and package cleanup. | | 2026-09-26 | R3 — Live i18n + frontend cleanup | ✅ | Fixed language-switch reset/crash, wired EN/LV through the workflow, kept files in memory during locale changes, simplified mapping and fixed mock slot replacement/error recovery. | -| 2026-09-26 | R4 — Visual Diff pivot | 🟡 | Product flow changed to Upload → automatic comparison → side-by-side Visual Diff. Manual mapping moved behind Expert settings. Build and UX verification pending. | +| 2026-09-26 | R4 — Visual Diff pivot | ✅ | Product flow changed to Upload → automatic comparison → side-by-side Visual Diff. Manual mapping moved behind Expert settings. | +| 2026-09-26 | R5 — Real browser engine MVP | 🟡 | Replaced mock default with real local CSV/TSV parsing, automatic mapping suggestions and deterministic comparison sufficient for the Visual Diff flow. Added regression fixtures/tests. Local build/test verification pending. | ## Next target / Nākamais mērķis -**R4 exit check:** pull the Visual Diff prototype, verify that two selected files lead directly to the visual result, confirm red difference highlighting and neutral matches, then continue with the real CSV/TSV inspector. +**R5 exit check:** run npm test and npm run build, then load the supplied File A/File B fixtures and verify that the Visual Diff is produced from their real contents rather than mock data. diff --git a/package.json b/package.json index 0a7c3ff..93d8668 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "scripts": { "dev": "vite", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "node --test" }, "devDependencies": { "vite": "^7.0.0" diff --git a/src/engine/delimited-parser.js b/src/engine/delimited-parser.js new file mode 100644 index 0000000..788f5fd --- /dev/null +++ b/src/engine/delimited-parser.js @@ -0,0 +1,208 @@ +const CANDIDATE_DELIMITERS = [',', ';', '\t']; + +export function detectDelimiter(text) { + const sample = String(text ?? '').slice(0, 65536); + let best = { delimiter: ',', score: -Infinity }; + + for (const delimiter of CANDIDATE_DELIMITERS) { + const rows = parseDelimitedText(sample, delimiter, { tolerateUnclosedQuote: true }) + .filter((row) => row.some((cell) => String(cell).trim() !== '')) + .slice(0, 20); + + if (rows.length < 2) continue; + + const widths = rows.map((row) => row.length); + const modeWidth = mode(widths); + const consistent = widths.filter((width) => width === modeWidth).length; + const score = modeWidth > 1 + ? consistent * 10 + modeWidth - Math.max(...widths) + Math.min(...widths) + : -100; + + if (score > best.score) best = { delimiter, score }; + } + + return best.delimiter; +} + +export function parseDelimitedText(text, delimiter, options = {}) { + const input = String(text ?? '').replace(/^\uFEFF/, ''); + const rows = []; + let row = []; + let field = ''; + let quoted = false; + + for (let i = 0; i < input.length; i += 1) { + const char = input[i]; + + if (quoted) { + if (char === '"') { + if (input[i + 1] === '"') { + field += '"'; + i += 1; + } else { + quoted = false; + } + } else { + field += char; + } + continue; + } + + if (char === '"' && field.length === 0) { + quoted = true; + continue; + } + + if (char === delimiter) { + row.push(field); + field = ''; + continue; + } + + if (char === '\n' || char === '\r') { + if (char === '\r' && input[i + 1] === '\n') i += 1; + row.push(field); + field = ''; + rows.push(row); + row = []; + continue; + } + + field += char; + } + + if (quoted && !options.tolerateUnclosedQuote) { + throw new Error('CSV_PARSE_UNCLOSED_QUOTE'); + } + + if (field.length > 0 || row.length > 0) { + row.push(field); + rows.push(row); + } + + while ( + rows.length && + rows[rows.length - 1].every((cell) => String(cell).trim() === '') + ) { + rows.pop(); + } + + return rows; +} + +export function tableFromText(text) { + const clean = String(text ?? '').replace(/^\uFEFF/, ''); + + if (clean.includes('\uFFFD')) { + throw new Error('UNSUPPORTED_ENCODING'); + } + + const delimiter = detectDelimiter(clean); + const matrix = parseDelimitedText(clean, delimiter); + + if (matrix.length === 0) { + throw new Error('EMPTY_FILE'); + } + + const maxWidth = Math.max(...matrix.map((row) => row.length)); + const rawHeader = [...matrix[0]]; + + while (rawHeader.length < maxWidth) rawHeader.push(''); + + const columns = dedupeHeaders(rawHeader); + const rows = matrix + .slice(1) + .filter((row) => row.some((cell) => String(cell).trim() !== '')) + .map((row) => { + const record = {}; + for (let i = 0; i < columns.length; i += 1) { + record[columns[i].id] = String(row[i] ?? ''); + } + return record; + }); + + const typedColumns = columns.map((column) => ({ + ...column, + inferredType: inferType(rows.map((row) => row[column.id])), + })); + + return { + delimiter, + columns: typedColumns, + rows, + }; +} + +function dedupeHeaders(rawHeaders) { + const used = new Map(); + + return rawHeaders.map((raw, index) => { + const base = String(raw ?? '').trim() || 'Column ' + (index + 1); + const count = (used.get(base) ?? 0) + 1; + used.set(base, count); + + const id = count === 1 ? base : base + ' (' + count + ')'; + return { + id, + label: id, + inferredType: 'unknown', + }; + }); +} + +function inferType(values) { + const sample = values + .map((value) => String(value ?? '').trim()) + .filter(Boolean) + .slice(0, 200); + + if (sample.length === 0) return 'unknown'; + + const numberHits = sample.filter((value) => parseFlexibleNumber(value) !== null).length; + if (numberHits / sample.length >= 0.9) return 'number'; + + const dateHits = sample.filter((value) => isDateLike(value)).length; + if (dateHits / sample.length >= 0.9) return 'date'; + + return 'text'; +} + +export function parseFlexibleNumber(value) { + const raw = String(value ?? '').trim().replace(/\s+/g, ''); + if (!raw) return null; + + const normalized = raw.includes(',') && !raw.includes('.') + ? raw.replace(',', '.') + : raw.replace(/,/g, ''); + + if (!/^[+-]?(?:\d+|\d*\.\d+)$/.test(normalized)) return null; + + const number = Number(normalized); + return Number.isFinite(number) ? number : null; +} + +function isDateLike(value) { + const raw = String(value ?? '').trim(); + return ( + /^\d{4}-\d{2}-\d{2}(?:[T\s].*)?$/.test(raw) || + /^\d{1,2}[./-]\d{1,2}[./-]\d{4}$/.test(raw) + ); +} + +function mode(values) { + const counts = new Map(); + let bestValue = values[0]; + let bestCount = 0; + + for (const value of values) { + const count = (counts.get(value) ?? 0) + 1; + counts.set(value, count); + + if (count > bestCount) { + bestCount = count; + bestValue = value; + } + } + + return bestValue; +} diff --git a/src/engine/mapping-suggester.js b/src/engine/mapping-suggester.js new file mode 100644 index 0000000..48dee29 --- /dev/null +++ b/src/engine/mapping-suggester.js @@ -0,0 +1,155 @@ +export function suggestColumnMappings(fileA, fileB, dataA, dataB) { + const columnsA = fileA.columns ?? []; + const columnsB = fileB.columns ?? []; + + if (!columnsA.length || !columnsB.length) return []; + + const keyCandidate = bestKeyPair(columnsA, columnsB, dataA.rows, dataB.rows); + if (!keyCandidate) return []; + + const suggestions = [{ + columnA: keyCandidate.a.id, + columnB: keyCandidate.b.id, + confidence: round(keyCandidate.score), + }]; + + const usedB = new Set([keyCandidate.b.id]); + + for (const columnA of columnsA) { + if (columnA.id === keyCandidate.a.id) continue; + + let best = null; + + for (const columnB of columnsB) { + if (usedB.has(columnB.id)) continue; + if (!compatibleTypes(columnA.inferredType, columnB.inferredType)) continue; + + const score = comparePairScore(columnA, columnB, dataA.rows, dataB.rows); + if (!best || score > best.score) best = { a: columnA, b: columnB, score }; + } + + if (best && best.score >= 0.48) { + suggestions.push({ + columnA: best.a.id, + columnB: best.b.id, + confidence: round(best.score), + }); + usedB.add(best.b.id); + } + } + + return suggestions; +} + +function bestKeyPair(columnsA, columnsB, rowsA, rowsB) { + let best = null; + + for (const a of columnsA) { + for (const b of columnsB) { + if (!compatibleTypes(a.inferredType, b.inferredType)) continue; + + const valuesA = nonEmpty(rowsA.map((row) => row[a.id])); + const valuesB = nonEmpty(rowsB.map((row) => row[b.id])); + if (!valuesA.length || !valuesB.length) continue; + + const overlap = overlapRatio(valuesA, valuesB); + const uniqueness = Math.min(uniqueRatio(valuesA), uniqueRatio(valuesB)); + const semantic = semanticSimilarity(a.label, b.label); + + const score = overlap * 0.62 + uniqueness * 0.25 + semantic * 0.13; + + if (!best || score > best.score) best = { a, b, score }; + } + } + + return best && best.score >= 0.35 ? best : null; +} + +function comparePairScore(a, b, rowsA, rowsB) { + const valuesA = nonEmpty(rowsA.map((row) => row[a.id])); + const valuesB = nonEmpty(rowsB.map((row) => row[b.id])); + + const semantic = semanticSimilarity(a.label, b.label); + const overlap = overlapRatio(valuesA, valuesB); + const type = compatibleTypes(a.inferredType, b.inferredType) ? 1 : 0; + + return semantic * 0.5 + overlap * 0.35 + type * 0.15; +} + +function compatibleTypes(a, b) { + if (a === 'unknown' || b === 'unknown') return true; + return a === b; +} + +function semanticSimilarity(a, b) { + const groupA = semanticGroup(a); + const groupB = semanticGroup(b); + + if (groupA && groupA === groupB) return 1; + + const tokensA = tokenSet(a); + const tokensB = tokenSet(b); + if (!tokensA.size || !tokensB.size) return 0; + + let intersection = 0; + for (const token of tokensA) if (tokensB.has(token)) intersection += 1; + + return intersection / Math.max(tokensA.size, tokensB.size); +} + +function semanticGroup(label) { + const value = String(label ?? '').toLowerCase(); + + if (/\b(ref|reference|invoice|inv|order|payment.?ref|transaction.?id|id|number|no)\b/.test(value)) { + return 'identifier'; + } + if (/\b(amount|total|sum|price|value|balance)\b/.test(value)) return 'amount'; + if (/\b(date|time|paid.?date|created|updated)\b/.test(value)) return 'date'; + if (/\b(description|desc|memo|note|details)\b/.test(value)) return 'description'; + if (/\b(customer|client|buyer|name)\b/.test(value)) return 'party'; + + return null; +} + +function tokenSet(value) { + return new Set( + String(value ?? '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, ' ') + .trim() + .split(/\s+/) + .filter(Boolean) + ); +} + +function overlapRatio(valuesA, valuesB) { + const setA = new Set(valuesA.map(normalize)); + const setB = new Set(valuesB.map(normalize)); + + if (!setA.size || !setB.size) return 0; + + let intersection = 0; + for (const value of setA) if (setB.has(value)) intersection += 1; + + return intersection / Math.min(setA.size, setB.size); +} + +function uniqueRatio(values) { + if (!values.length) return 0; + return new Set(values.map(normalize)).size / values.length; +} + +function nonEmpty(values) { + return values + .map((value) => String(value ?? '').trim()) + .filter(Boolean) + .slice(0, 1000); +} + +function normalize(value) { + return String(value ?? '').trim().toLocaleLowerCase().replace(/\s+/g, ' '); +} + +function round(value) { + return Math.round(value * 100) / 100; +} diff --git a/src/engine/normalization.js b/src/engine/normalization.js new file mode 100644 index 0000000..72f7810 --- /dev/null +++ b/src/engine/normalization.js @@ -0,0 +1,64 @@ +import { parseFlexibleNumber } from './delimited-parser.js'; + +export function normalizeText(value, config = {}) { + let result = String(value ?? ''); + + if (config.trimText !== false) result = result.trim(); + if (config.collapseWhitespace !== false) result = result.replace(/\s+/g, ' '); + if (config.caseInsensitive !== false) result = result.toLocaleLowerCase(); + + return result; +} + +export function normalizeKey(row, mappings, config = {}) { + return mappings + .map((mapping) => { + const raw = row[mapping.columnA ?? mapping.columnB] ?? ''; + return normalizeText(raw, config); + }) + .join('\u001F'); +} + +export function normalizeKeyForSide(row, mappings, side, config = {}) { + return mappings + .map((mapping) => { + const column = side === 'A' ? mapping.columnA : mapping.columnB; + return normalizeText(row[column] ?? '', config); + }) + .join('\u001F'); +} + +export function compareValues(valueA, valueB, mapping, config = {}) { + if (mapping.type === 'number') { + const a = parseFlexibleNumber(valueA); + const b = parseFlexibleNumber(valueB); + + if (a === null || b === null) { + return { + equal: normalizeText(valueA, config) === normalizeText(valueB, config), + delta: null, + }; + } + + const delta = Math.abs(a - b); + const tolerance = mapping.tolerance?.value ?? 0; + + if (mapping.tolerance?.mode === 'percentage') { + const base = Math.max(Math.abs(a), Math.abs(b), Number.EPSILON); + return { + equal: (delta / base) * 100 <= tolerance, + delta, + }; + } + + return { + equal: delta <= tolerance, + delta, + }; + } + + return { + equal: normalizeText(valueA, config) === normalizeText(valueB, config), + delta: null, + }; +} diff --git a/src/main.js b/src/main.js index 172d01a..4f4d60a 100644 --- a/src/main.js +++ b/src/main.js @@ -5,7 +5,7 @@ import './styles/components.css'; import { AppStore } from './app/app-store.js'; import { AppController } from './app/app-controller.js'; -import { MockComparisonService } from './services/mock-comparison-service.js'; +import { BrowserComparisonService } from './services/browser-comparison-service.js'; import { LandingView } from './ui/views/landing-view.js'; import { MappingView } from './ui/views/mapping-view.js'; import { ComparingView } from './ui/views/comparing-view.js'; @@ -17,7 +17,7 @@ import { el, replaceChildren } from './ui/renderers/dom.js'; import { LOCALE_CHANGE_EVENT, t } from './i18n/index.js'; export function startApp(config = {}) { - const service = config.comparisonService ?? new MockComparisonService(); + const service = config.comparisonService ?? new BrowserComparisonService(); const store = new AppStore(); const controller = new AppController(store, service); const root = document.getElementById('app'); diff --git a/src/services/browser-comparison-service.js b/src/services/browser-comparison-service.js index cdcd02d..2e67a68 100644 --- a/src/services/browser-comparison-service.js +++ b/src/services/browser-comparison-service.js @@ -1,24 +1,344 @@ import { ComparisonService } from './comparison-service.js'; +import { tableFromText } from '../engine/delimited-parser.js'; +import { compareValues, normalizeKeyForSide } from '../engine/normalization.js'; +import { suggestColumnMappings } from '../engine/mapping-suggester.js'; + +const MAX_FILE_BYTES = 50 * 1024 * 1024; +const PREVIEW_ROWS = 5; -/** - * Placeholder for the real browser-side engine. - * Will be implemented during the technical phase — do not fill in yet. - * @extends ComparisonService - */ export class BrowserComparisonService extends ComparisonService { - async inspectFile() { - throw new Error('BrowserComparisonService is not implemented in the design phase.'); + constructor() { + super(); + this._files = new Map(); + } + + async inspectFile(file, options = {}) { + if (!file || typeof file.text !== 'function') { + throw new Error('INVALID_FILE'); + } + + if (file.size > MAX_FILE_BYTES) { + throw new Error('FILE_TOO_LARGE'); + } + + const text = await file.text(); + const parsed = tableFromText(text); + + const slot = options.slot === 'A' || options.slot === 'B' + ? options.slot + : (this._files.has('file-a') ? 'B' : 'A'); + + const id = 'file-' + slot.toLowerCase(); + + const inspection = { + id, + name: file.name || id + '.csv', + sizeBytes: file.size ?? text.length, + rowCount: parsed.rows.length, + delimiter: parsed.delimiter, + encoding: 'UTF-8', + columns: parsed.columns, + previewRows: parsed.rows.slice(0, PREVIEW_ROWS), + warnings: [], + }; + + this._files.set(id, { + file, + inspection, + parsed, + }); + + return inspection; } - async suggestMappings() { - throw new Error('BrowserComparisonService is not implemented in the design phase.'); + + async suggestMappings(fileA, fileB) { + const dataA = this._files.get(fileA.id); + const dataB = this._files.get(fileB.id); + + if (!dataA || !dataB) return []; + + return suggestColumnMappings( + fileA, + fileB, + dataA.parsed, + dataB.parsed + ); } - async compare() { - throw new Error('BrowserComparisonService is not implemented in the design phase.'); + + async compare(request, { onProgress } = {}) { + const dataA = this._files.get(request.fileAId); + const dataB = this._files.get(request.fileBId); + + if (!dataA || !dataB) { + throw new Error('FILES_NOT_AVAILABLE'); + } + + const mapping = request.mapping; + if (!mapping?.keys?.length) { + throw new Error('MISSING_KEY_MAPPING'); + } + + onProgress?.({ + phase: 'index', + message: 'Indexing records…', + completed: 20, + total: 100, + }); + + const indexA = buildIndex(dataA.parsed.rows, mapping.keys, 'A', mapping.normalization); + const indexB = buildIndex(dataB.parsed.rows, mapping.keys, 'B', mapping.normalization); + + onProgress?.({ + phase: 'compare', + message: 'Comparing records…', + completed: 55, + total: 100, + }); + + const records = []; + const seenKeys = new Set(); + + for (const [key, rowsA] of indexA.entries()) { + seenKeys.add(key); + const rowsB = indexB.get(key) ?? []; + + if (rowsB.length === 0) { + for (const itemA of rowsA) { + records.push(makeSingleSideRecord( + 'ONLY_A', + key, + itemA, + null, + dataA, + dataB + )); + } + continue; + } + + if (rowsA.length > 1 || rowsB.length > 1) { + records.push(makeDuplicateRecord( + rowsA.length > 1 && rowsB.length > 1 ? 'AMBIGUOUS' : 'DUPLICATE', + key, + rowsA, + rowsB, + dataA, + dataB + )); + continue; + } + + records.push(comparePair( + key, + rowsA[0], + rowsB[0], + mapping, + dataA, + dataB + )); + } + + for (const [key, rowsB] of indexB.entries()) { + if (seenKeys.has(key)) continue; + + for (const itemB of rowsB) { + records.push(makeSingleSideRecord( + 'ONLY_B', + key, + null, + itemB, + dataA, + dataB + )); + } + } + + onProgress?.({ + phase: 'finalize', + message: 'Finalizing…', + completed: 100, + total: 100, + }); + + const summary = { + totalA: dataA.parsed.rows.length, + totalB: dataB.parsed.rows.length, + matched: countStatus(records, 'MATCHED'), + onlyA: countStatus(records, 'ONLY_A'), + onlyB: countStatus(records, 'ONLY_B'), + mismatched: countStatus(records, 'MISMATCH'), + duplicates: countStatus(records, 'DUPLICATE'), + ambiguous: countStatus(records, 'AMBIGUOUS'), + }; + + return { + runId: 'cmp-' + Date.now(), + createdAt: new Date().toISOString(), + summary, + records, + warnings: [], + }; } - async createExport() { - throw new Error('BrowserComparisonService is not implemented in the design phase.'); + + async createExport(result, options = {}) { + const allowed = options.includeStatuses?.length + ? new Set(options.includeStatuses) + : null; + + const rows = [ + ['status', 'key', 'rowA', 'rowB', 'differences'], + ]; + + for (const record of result.records) { + if (allowed && !allowed.has(record.status)) continue; + + rows.push([ + record.status, + record.keyLabel, + record.source?.rowA ?? '', + record.source?.rowB ?? '', + (record.differences ?? []) + .map((difference) => { + return difference.fieldA + ' ↔ ' + difference.fieldB + + ': ' + String(difference.valueA ?? '') + + ' → ' + String(difference.valueB ?? ''); + }) + .join(' | '), + ]); + } + + const csv = rows + .map((row) => row.map(csvCell).join(',')) + .join('\r\n'); + + return new Blob([csv], { type: 'text/csv;charset=utf-8' }); } + reset() { - throw new Error('BrowserComparisonService is not implemented in the design phase.'); + this._files.clear(); + } +} + +function buildIndex(rows, keys, side, normalization) { + const index = new Map(); + + rows.forEach((row, indexNumber) => { + const key = normalizeKeyForSide(row, keys, side, normalization); + if (!index.has(key)) index.set(key, []); + index.get(key).push({ + row, + rowNumber: indexNumber + 2, + }); + }); + + return index; +} + +function comparePair(key, itemA, itemB, mapping, dataA, dataB) { + const differences = []; + + for (const comparison of mapping.comparisons ?? []) { + const valueA = itemA.row[comparison.columnA] ?? ''; + const valueB = itemB.row[comparison.columnB] ?? ''; + const result = compareValues( + valueA, + valueB, + comparison, + mapping.normalization + ); + + if (!result.equal) { + differences.push({ + field: comparison.columnA, + fieldA: comparison.columnA, + fieldB: comparison.columnB, + valueA, + valueB, + ...(result.delta !== null ? { delta: result.delta } : {}), + }); + } + } + + return { + id: nextResultId(), + status: differences.length ? 'MISMATCH' : 'MATCHED', + keyLabel: readableKey(key), + source: { + rowA: itemA.rowNumber, + rowB: itemB.rowNumber, + }, + displayA: displayRow(itemA.row, dataA.inspection.columns), + displayB: displayRow(itemB.row, dataB.inspection.columns), + differences, + }; +} + +function makeSingleSideRecord(status, key, itemA, itemB, dataA, dataB) { + return { + id: nextResultId(), + status, + keyLabel: readableKey(key), + source: { + rowA: itemA?.rowNumber ?? null, + rowB: itemB?.rowNumber ?? null, + }, + ...(itemA + ? { displayA: displayRow(itemA.row, dataA.inspection.columns) } + : {}), + ...(itemB + ? { displayB: displayRow(itemB.row, dataB.inspection.columns) } + : {}), + }; +} + +function makeDuplicateRecord(status, key, rowsA, rowsB, dataA, dataB) { + return { + id: nextResultId(), + status, + keyLabel: readableKey(key), + source: { + rowA: rowsA[0]?.rowNumber ?? null, + rowB: rowsB[0]?.rowNumber ?? null, + }, + ...(rowsA[0] + ? { displayA: displayRow(rowsA[0].row, dataA.inspection.columns) } + : {}), + ...(rowsB[0] + ? { displayB: displayRow(rowsB[0].row, dataB.inspection.columns) } + : {}), + }; +} + +function displayRow(row, columns) { + const result = {}; + for (const column of columns) result[column.label] = row[column.id] ?? ''; + return result; +} + +function countStatus(records, status) { + return records.filter((record) => record.status === status).length; +} + +function readableKey(key) { + return String(key ?? '').split('\u001F').join(' · '); +} + +let resultCounter = 0; +function nextResultId() { + resultCounter += 1; + return 'result-' + resultCounter; +} + +function csvCell(value) { + let text = String(value ?? ''); + + if (/^[=+\-@]/.test(text)) { + text = "'" + text; } -} \ No newline at end of file + + if (/[",\r\n]/.test(text)) { + text = '"' + text.replace(/"/g, '""') + '"'; + } + + return text; +} diff --git a/tests/browser-comparison-service.test.js b/tests/browser-comparison-service.test.js new file mode 100644 index 0000000..d697ac5 --- /dev/null +++ b/tests/browser-comparison-service.test.js @@ -0,0 +1,79 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +import { BrowserComparisonService } from '../src/services/browser-comparison-service.js'; + +const here = dirname(fileURLToPath(import.meta.url)); + +async function fixture(name) { + return readFile(resolve(here, 'fixtures', name), 'utf8'); +} + +function fakeFile(name, text) { + return { + name, + size: Buffer.byteLength(text), + text: async () => text, + }; +} + +test('real browser service inspects files, suggests mappings and compares actual rows', async () => { + const service = new BrowserComparisonService(); + const textA = await fixture('file-a.csv'); + const textB = await fixture('file-b.csv'); + + const fileA = await service.inspectFile(fakeFile('file-a.csv', textA), { slot: 'A' }); + const fileB = await service.inspectFile(fakeFile('file-b.csv', textB), { slot: 'B' }); + + assert.equal(fileA.rowCount, 12); + assert.equal(fileB.rowCount, 12); + + const suggestions = await service.suggestMappings(fileA, fileB); + + assert.equal(suggestions[0].columnA, 'Reference'); + assert.equal(suggestions[0].columnB, 'Payment Ref'); + assert.ok(suggestions.some((item) => item.columnA === 'Amount' && item.columnB === 'Total')); + assert.ok(suggestions.some((item) => item.columnA === 'Date' && item.columnB === 'Paid Date')); + + const key = suggestions[0]; + const comparisons = suggestions.slice(1).map((item) => ({ + columnA: item.columnA, + columnB: item.columnB, + type: fileA.columns.find((column) => column.id === item.columnA)?.inferredType ?? 'text', + tolerance: { mode: 'absolute', value: 0.01 }, + })); + + const result = await service.compare({ + fileAId: fileA.id, + fileBId: fileB.id, + mapping: { + keys: [{ columnA: key.columnA, columnB: key.columnB, type: 'text' }], + comparisons, + displayOnly: { fileA: [], fileB: [] }, + normalization: { + trimText: true, + caseInsensitive: true, + collapseWhitespace: true, + numberLocale: 'auto', + dateFormatA: null, + dateFormatB: null, + }, + }, + }); + + assert.ok(result.records.some((record) => record.status === 'MISMATCH' && record.keyLabel === 'inv-1002')); + assert.ok(result.records.some((record) => record.status === 'ONLY_A' && record.keyLabel === 'inv-1003')); + assert.ok(result.records.some((record) => record.status === 'ONLY_B' && record.keyLabel === 'inv-1004')); + assert.ok(result.records.some((record) => record.status === 'DUPLICATE' && record.keyLabel === 'inv-1005')); + assert.ok(result.records.some((record) => record.status === 'AMBIGUOUS' && record.keyLabel === 'inv-1006')); + assert.ok(result.records.some((record) => record.status === 'MATCHED' && record.keyLabel === 'inv-1007')); + + const inv1010 = result.records.find((record) => record.keyLabel === 'inv-1010'); + assert.equal(inv1010.status, 'MATCHED'); + + const inv1011 = result.records.find((record) => record.keyLabel === 'inv-1011'); + assert.equal(inv1011.status, 'MISMATCH'); +}); diff --git a/tests/delimited-parser.test.js b/tests/delimited-parser.test.js new file mode 100644 index 0000000..270e082 --- /dev/null +++ b/tests/delimited-parser.test.js @@ -0,0 +1,29 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + detectDelimiter, + parseDelimitedText, + tableFromText, +} from '../src/engine/delimited-parser.js'; + +test('detects semicolon delimiter', () => { + const text = 'a;b;c\n1;2;3\n4;5;6\n'; + assert.equal(detectDelimiter(text), ';'); +}); + +test('parses quoted commas and multiline values', () => { + const text = 'id,note\n1,"hello, world"\n2,"line one\nline two"\n'; + const rows = parseDelimitedText(text, ','); + + assert.deepEqual(rows[1], ['1', 'hello, world']); + assert.deepEqual(rows[2], ['2', 'line one\nline two']); +}); + +test('strips UTF-8 BOM and infers basic types', () => { + const table = tableFromText('\uFEFFid,date,amount\nA1,2026-09-26,12.50\n'); + + assert.equal(table.columns[1].inferredType, 'date'); + assert.equal(table.columns[2].inferredType, 'number'); + assert.equal(table.rows[0].id, 'A1'); +}); diff --git a/tests/fixtures/file-a.csv b/tests/fixtures/file-a.csv new file mode 100644 index 0000000..aa6e64d --- /dev/null +++ b/tests/fixtures/file-a.csv @@ -0,0 +1,13 @@ +Reference,Date,Amount,Description +INV-1001,2026-09-15,125.00,Consulting +INV-1002,2026-09-18,125.00,Design +INV-1003,2026-09-20,89.50,Hosting +INV-1005,2026-09-21,50.00,Duplicate A1 +INV-1005,2026-09-21,50.00,Duplicate A2 +INV-1006,2026-09-22,75.00,Ambiguous A1 +INV-1006,2026-09-22,75.00,Ambiguous A2 + inv-1007 ,2026-09-23,199.99,Whitespace +INV-1008,2026-09-24,49.90,Date mismatch +INV-1009,2026-09-25,300.00,Exact match +INV-1010,2026-09-25,100.00,Tolerance +INV-1011,2026-09-25,100.00,Outside tolerance diff --git a/tests/fixtures/file-b.csv b/tests/fixtures/file-b.csv new file mode 100644 index 0000000..36f5776 --- /dev/null +++ b/tests/fixtures/file-b.csv @@ -0,0 +1,13 @@ +Payment Ref,Paid Date,Total,Customer +INV-1001,2026-09-15,125.00,Acme +INV-1002,2026-09-18,152.00,Acme +INV-1004,2026-09-22,45.00,Beta +INV-1005,2026-09-21,50.00,Duplicate B +INV-1006,2026-09-22,75.00,Ambiguous B1 +INV-1006,2026-09-22,75.00,Ambiguous B2 +INV-1007,2026-09-23,199.99,Whitespace +INV-1008,2026-09-25,49.90,Date mismatch +INV-1009,2026-09-25,300.00,Exact match +INV-1010,2026-09-25,100.005,Tolerance +INV-1011,2026-09-25,100.05,Outside tolerance +INV-1013,2026-09-26,18.40,Only B From 40ba8f0b5ce0a1940b765de992600f086f2e3341 Mon Sep 17 00:00:00 2001 From: QvarcY <118832438+QvarcY@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:17:21 +0300 Subject: [PATCH 7/9] docs: polish README and repository contribution flow --- .github/ISSUE_TEMPLATE/bug_report.yml | 46 ++++ .github/ISSUE_TEMPLATE/config.yml | 2 + .github/ISSUE_TEMPLATE/feature_request.yml | 41 ++++ .github/PULL_REQUEST_TEMPLATE.md | 25 ++ CONTRIBUTING.md | 93 +++++--- README.lv.md | 261 +++++++++++++-------- README.md | 261 +++++++++++++-------- ROADMAP.md | 2 +- SECURITY.md | 35 +++ docs/PROJECT_SCOPE.md | 68 ++++++ 10 files changed, 616 insertions(+), 218 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 SECURITY.md create mode 100644 docs/PROJECT_SCOPE.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..3e92aca --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,46 @@ +name: Bug report +description: Report reproducible incorrect behavior +title: "[Bug]: " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for reporting a bug. Please use synthetic data only — never attach confidential exports. + - type: textarea + id: summary + attributes: + label: What happened? + description: Describe the incorrect behavior. + validations: + required: true + - type: textarea + id: reproduce + attributes: + label: Steps to reproduce + placeholder: | + 1. Open... + 2. Load synthetic File A... + 3. Load synthetic File B... + 4. Observe... + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + validations: + required: true + - type: input + id: browser + attributes: + label: Browser / version + - type: input + id: os + attributes: + label: Operating system + - type: textarea + id: fixture + attributes: + label: Minimal synthetic fixture + description: Paste the smallest non-sensitive example that reproduces the problem. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..8005e32 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,2 @@ +blank_issues_enabled: false +contact_links: [] diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..a3259e2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,41 @@ +name: Feature request +description: Suggest a focused improvement +title: "[Feature]: " +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Core promise: two files in → visual differences out. + Please explain how the proposal improves that workflow. + - type: textarea + id: problem + attributes: + label: Problem + description: What comparison problem is difficult today? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed improvement + validations: + required: true + - type: textarea + id: scope + attributes: + label: Why does this belong in the core? + description: How does it help users understand differences faster without making the normal workflow harder? + validations: + required: true + - type: checkboxes + id: principles + attributes: + label: Scope check + options: + - label: This directly helps compare two files. + required: true + - label: This can preserve local-first/privacy-first behavior. + required: true + - label: This does not require turning the project into a general spreadsheet/workspace product. + required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..fc9d380 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,25 @@ +## Summary + +What changed and why? + +## Scope + +- [ ] This change directly supports comparing two files or the quality of that workflow. +- [ ] The default UX remains understandable for non-expert users. +- [ ] Local-first/privacy-first behavior is preserved. + +## Verification + +- [ ] `npm test` +- [ ] `npm run build` +- [ ] I tested the affected UI/behavior manually where relevant. +- [ ] New or changed behavior has meaningful regression coverage where practical. + +## Data safety + +- [ ] No real confidential business/customer export is included in this PR. + +## Documentation + +- [ ] README/docs updated if user-visible behavior changed. +- [ ] ROADMAP updated if this completes or changes a planned development round. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 90e982c..d5ed992 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,25 +1,57 @@ -# Contributing +# Contributing to Two-Export Comparator -Thank you for considering a contribution to Two-Export Comparator. +Thanks for helping improve the project. -The project intentionally stays focused: compare two datasets locally and explain what does not match. +The project intentionally stays focused: -## Before opening code changes +> **Take two files and visually show what is different.** -- Keep the privacy-first architecture. -- Do not introduce accounts, cloud file storage, analytics, telemetry or unrelated SaaS features. -- Do not put parser or comparison logic inside UI components. -- Imported values must be rendered safely. -- Avoid silent guessing for ambiguous data. -- Keep dependencies minimal. +Please read [PROJECT_SCOPE.md](docs/PROJECT_SCOPE.md) before proposing a large feature. -## Development +## Development setup -~~~bash +```bash npm install +npm test npm run dev +``` + +Before opening a PR: + +```bash +npm test npm run build -~~~ +``` + +## Pull request expectations + +A good PR should: + +- solve one clear problem; +- keep parser/comparison logic outside UI components; +- preserve local-first behavior; +- avoid unnecessary dependencies; +- include or update tests for behavioral changes; +- update documentation when behavior changes; +- update `ROADMAP.md` when completing a planned development round. + +## Tests + +Tests should prove behavior, not merely execute code. + +For a bug fix, the preferred regression test should: + +1. fail on the broken behavior; +2. pass with the fix; +3. fail again if the protected behavior is removed. + +Realistic synthetic fixtures are preferred over tests that only mock everything away. + +## Privacy and test data + +Never commit real customer, accounting, banking, payment, or other confidential exports. + +Use synthetic fixture data only. ## Translations @@ -29,31 +61,30 @@ See [docs/TRANSLATIONS.md](docs/TRANSLATIONS.md). A translation PR should normally: -1. add a locale file under src/i18n/locales/; -2. register it in src/i18n/locales/index.js; -3. keep the same message keys as English; -4. translate meaning, not code identifiers or source-data column names; -5. run the project and inspect the main workflow; -6. optionally add a README.xx.md translation. - -## Tests - -As the real engine is introduced, tests must protect behavior rather than merely execute code. +1. copy an existing locale under `src/i18n/locales/`; +2. keep the same message keys; +3. translate message values naturally; +4. register the locale in `src/i18n/locales/index.js`; +5. test the full primary workflow; +6. optionally add a `README.xx.md` translation. -A regression test should fail when the protected behavior is removed or restored to the previous broken behavior. +Do not translate imported file values or source column names. They belong to the user data. -## Scope +## Good contribution areas -Good contribution areas include: - -- parsers and file-format support; -- normalization; +- CSV/TSV edge cases; - deterministic matching; +- normalization; +- duplicate/ambiguity handling; - security hardening; - accessibility; - performance; - translations; - documentation; -- test fixtures. +- regression fixtures. + +## Large features + +Please discuss large scope changes before implementing them. -Large scope expansions should be discussed before implementation. +A feature that is technically impressive but makes the default workflow harder to understand is unlikely to belong in the core. diff --git a/README.lv.md b/README.lv.md index 6053b3c..aa9cbf6 100644 --- a/README.lv.md +++ b/README.lv.md @@ -2,148 +2,223 @@ [English](README.md) · [Latviešu](README.lv.md) -**Divi faili iekšā. Atšķirības ārā. Bez konta. Bez augšupielādes. Bez mākoņdatubāzes.** +> **Divi faili iekšā. Atšķirības ārā.** +> Privātumam draudzīgs pārlūka rīks, kas lokāli salīdzina divus CSV/TSV eksportus un vizuāli parāda atšķirības blakus. -Two-Export Comparator ir privātumam draudzīgs pārlūka rīks divu eksportētu datu kopu salīdzināšanai, lai atrastu trūkstošus, dublētus, neskaidri savietojamus vai atšķirīgus ierakstus. +**Statuss:** pirms publiskā laidiena / aktīvā izstrādē -Projekta mērķis apzināti ir šaurs: **ieliec divus eksportus, savieno vajadzīgās kolonnas, salīdzini lokāli, pārbaudi atšķirības un eksportē rezultātu.** +## Kāpēc šis projekts pastāv -> **Pašreizējais statuss:** frontend prototips un arhitektūras pamats. Produkcijas CSV/TSV parseris un īstais salīdzināšanas dzinējs vēl nav ieviests. Pašreizējais interfeiss izmanto determinētus mock datus, kamēr īstais dzinējs tiek veidots aiz stabila servisa kontrakta. +Divu eksportu salīdzināšanai nevajadzētu prasīt Excel formulu labirintu, failu augšupielādi mākonī vai datu inženiera zināšanas. -## Kāpēc šis projekts pastāv +Two-Export Comparator tiek veidots ap vienu ļoti vienkāršu plūsmu: -Mazie uzņēmumi regulāri eksportē datus no bankām, internetveikaliem, maksājumu sistēmām, noliktavas rīkiem, CRM, grāmatvedības sistēmām un izklājlapām. Sarežģītā daļa bieži nav eksporta iegūšana, bet atbilde uz vienkāršu jautājumu: +```text +Ieliec Failu A + Failu B + ↓ +Automātiska lokāla analīze + ↓ +Vizuāls salīdzinājums blakus + ↓ +Sarkans = atšķiras +Neitrāls / zils = sakrīt + ↓ +Eksportē rezultātu +``` -**Kas šajos divos failos nesakrīt?** +Parastam lietotājam nav jāzina salīdzināšanas algoritmi, normalizācijas noteikumi vai kolonnu sasaistes terminoloģija. Rīks cenšas šo darbu izdarīt automātiski. Manuālā sasaistīšana paliek tikai zem **Eksperta iestatījumiem**. -Tipiski piemēri: +## Ko rīks meklē -- bankas eksports pret rēķinu eksportu; -- veikala pasūtījumi pret maksājumu sistēmas eksportu; -- noliktavas eksports pret internetveikala eksportu; -- vecais piegādātāja cenu saraksts pret jauno; -- divas atskaites, kurās būtu jābūt vienādām atsaucēm un summām. +Salīdzināšanas dzinējs ierakstus var klasificēt šādi: -Šis projekts nav paredzēts kā ERP, CRM, grāmatvedības sistēma vai mākoņa datu platforma. +| Statuss | Nozīme | +| --- | --- | +| `MATCHED` | Atrasts viens drošs ierakstu pāris, un salīdzinātās vērtības sakrīt. | +| `MISMATCH` | Tas pats ieraksts atrasts abos failos, bet viena vai vairākas vērtības atšķiras. | +| `ONLY_A` | Ieraksts atrodas tikai Failā A. | +| `ONLY_B` | Ieraksts atrodas tikai Failā B. | +| `DUPLICATE` | Viena normalizēta atslēga sastopama vairākas reizes. | +| `AMBIGUOUS` | Vienu drošu pāri nav iespējams noteikt. Dzinējs nemin. | + +## Pašreizējās iespējas + +- CSV un TSV faili +- komata, semikola un tabulācijas atdalītāju noteikšana +- CSV lauki pēdiņās +- vairāku rindu vērtības pēdiņās +- UTF-8 un UTF-8 BOM +- teksta, skaitļu un datumu pamata tipu noteikšana +- automātiski kolonnu pāru ieteikumi +- determinēta lokāla salīdzināšana +- skaitliska pielaide +- dublikātu un neskaidru sasaistes gadījumu noteikšana +- avota rindu izsekojamība +- vizuāls salīdzinājums blakus +- CSV rezultāta eksports +- angļu un latviešu interfeiss +- valodu reģistrs, kuru viegli papildināt contributoriem + +## Privātums pēc arhitektūras + +Biznesa eksportos var būt sensitīva informācija. Tāpēc aplikācijas parastajai salīdzināšanas plūsmai nav nepieciešams sūtīt failus uz serveri. + +- faili tiek nolasīti pārlūkā; +- aplikācija failu saturu neaugšupielādē; +- nav nepieciešams lietotāja konts; +- importētie biznesa dati netiek glabāti aplikācijas datubāzē; +- salīdzināšanai nav nepieciešama analītika vai telemetrija; +- importētās vērtības tiek attēlotas ar drošām DOM metodēm; +- pārlādējot vai aizverot lapu, atmiņā ielādētie dati pazūd. -## Privātuma modelis +Tas apraksta arhitektūru, nevis ir vispārīgs juridisks apgalvojums, piemēram, “100% atbilst GDPR”. -Biznesa failos var būt sensitīva komerciāla vai personas informācija, tāpēc aplikācija tiek veidota ap lokālu apstrādi. +## Ātra palaišana -Plānotie produkcijas noteikumi: +Nepieciešams: -- failu saturs tiek apstrādāts pārlūkā; -- aplikācija failus neaugšupielādē; -- lietotāja konts nav nepieciešams; -- aplikācijas datubāzē importētie faili netiek glabāti; -- salīdzināšanas darbplūsmai nav nepieciešama analītika vai telemetrija; -- ārējs JavaScript CDN nav nepieciešams; -- importētās vērtības tiek attēlotas ar drošām DOM metodēm; -- aizverot vai pārlādējot lapu, atmiņā ielādētie biznesa dati pazūd. +- aktuāls Node.js +- npm -Šī arhitektūra samazina nevajadzīgu datu pārsūtīšanu, taču projekts neizsaka vispārīgus juridiskus apgalvojumus, piemēram, “100% atbilst GDPR”. +```bash +git clone https://github.com/QvarcY/two-export-comparator.git +cd two-export-comparator +npm install +npm test +npm run dev +``` -## Darbplūsma +Produkcijas build: -~~~text -FAILS A + FAILS B - ↓ -AUTOMĀTISKA LOKĀLA ANALĪZE - ↓ -VIZUĀLS SALĪDZINĀJUMS BLAKUS - ↓ -SARKANS = ATŠĶIRĪBA / NEITRĀLS = SAKRĪT - ↓ -PĒC VAJADZĪBAS — EKSPERTA IESTATĪJUMI - ↓ -ATSKAITES EKSPORTS -~~~ +```bash +npm run build +npm run preview +``` -## Rezultātu kategorijas +## Kā darbojas automātiskā sasaistīšana -| Statuss | Nozīme | -| --- | --- | -| MATCHED | Atrasts viens drošs ierakstu pāris un salīdzinātās vērtības atbilst. | -| ONLY_A | Ieraksts atrodas tikai Failā A. | -| ONLY_B | Ieraksts atrodas tikai Failā B. | -| MISMATCH | Atslēga sakrīt, bet viena vai vairākas salīdzinātās vērtības atšķiras. | -| DUPLICATE | Viena normalizēta atslēga sastopama vairākas reizes. | -| AMBIGUOUS | Nav iespējams droši noteikt vienu pāri; dzinējs nedrīkst minēt. | +Aplikācija analizē kolonnu nosaukumus, noteiktos datu tipus, vērtību pārklāšanos un unikalitāti, lai piedāvātu, kuras kolonnas abos failos nozīmē vienu un to pašu. + +Piemēram: + +```text +Reference ↔ Payment Ref +Amount ↔ Total +Date ↔ Paid Date +``` + +Spēcīgākais identifikatora kandidāts tiek izmantots ierakstu sasaistīšanai. Papildu saderīgie pāri tiek izmantoti vērtību salīdzināšanai. + +Automātiskie ieteikumi apzināti ir konservatīvi. Ja drošu sasaisti nevar noteikt, lietotājs var atvērt **Eksperta iestatījumus**, nevis dzinējs klusām izdomā rezultātu. + +## Atbalstītie ievades dati + +Šobrīd: + +- `.csv` +- `.tsv` +- UTF-8 / UTF-8 BOM +- līdz 50 MiB vienam failam pašreizējā MVP + +Tādus formātus kā XLSX ir jēga pievienot tikai tad, kad CSV/TSV kodols ir stabils. + +## Projekta robežas + +Šis projekts apzināti paliek mazs. + +**Kodola solījums:** + +> Paņem divus failus un vizuāli parādi, kas tajos atšķiras. + +Tam nav jākļūst par ERP, CRM, grāmatvedības sistēmu, mākoņa darba vidi, BI platformu vai failu glabātuvi. + +Pirms pievienot jaunu funkciju, jāuzdod viens jautājums: + +> Vai tas palīdz lietotājam ātrāk saprast atšķirības starp diviem failiem? + +Ja nē, tad šī funkcija, visticamāk, nepieder projekta kodolam. + +Skati [PROJECT_SCOPE.md](docs/PROJECT_SCOPE.md). ## Arhitektūra -~~~text +```text src/ -├── app/ aplikācijas stāvoklis un kontrolieris +├── app/ stāvoklis + aplikācijas kontrolieris +├── engine/ parsēšana, normalizācija, sasaistīšana un salīdzināšana ├── i18n/ valodu reģistrs un tulkojumi -├── models/ stabili DTO / servisa kontrakti -├── services/ mock un nākotnes pārlūka salīdzināšanas serviss +├── models/ DTO / servisa kontrakti +├── services/ pārlūka salīdzināšanas serviss ├── ui/ │ ├── components/ │ ├── renderers/ │ └── views/ └── styles/ -~~~ - -UI sazinās tikai ar ComparisonService robežu. Produkcijas parseris, normalizētāji un salīdzināšanas dzinējs atradīsies aiz BrowserComparisonService, tāpēc, nomainot mock servisu pret īsto dzinēju, interfeiss nav jāpārbūvē. +``` -## Valodas +UI ir atkarīgs no `ComparisonService` robežas, nevis no parsera iekšējās uzbūves. Tas neļauj salīdzināšanas loģikai sajaukties ar UI komponentiem. -Pirmās oficiālās UI valodas: +## Izstrādes principi -- angļu (EN); -- latviešu (LV). +- viena skaidra problēma, atrisināta labi; +- local-first, kur tas ir praktiski; +- nekādas klusās minēšanas neskaidru datu gadījumā; +- minimālas dependencies; +- droša importēto vērtību attēlošana; +- pieejamas vadīklas; +- determinēta loģika pirms AI; +- regression testam jānokrīt, ja aizsargātā uzvedība tiek sabojāta. -Valodu pārslēdzējs tiek veidots no valodu reģistra. Contributors var pievienot jaunu valodu, neaiztiekot salīdzināšanas loģiku. +## Valodas -Skati [Tulkošanas rokasgrāmatu](docs/TRANSLATIONS.md). +Oficiālās UI valodas: -README tulkojumus var pievienot kā README.xx.md failus un pievienot valodu rindai README augšpusē. +- English (`en`) +- Latviešu (`lv`) -## Izstrāde +Jaunas valodas pievienošana neprasa mainīt salīdzināšanas loģiku. -~~~bash -npm install -npm run dev -npm run build -npm run preview -~~~ +Skati [Tulkošanas rokasgrāmatu](docs/TRANSLATIONS.md). -Node/Vite tiek izmantots tikai izstrādei un statiskā build izveidei. Produkcijas aplikācija paliek statiska pārlūka aplikācija. +README tulkojumus var pievienot kā `README.xx.md`. ## Attīstības plāns -Detalizētais un nepārtraukti atjauninātais plāns atrodas [ROADMAP.md](ROADMAP.md). +Projekta kanoniskais attīstības žurnāls ir [ROADMAP.md](ROADMAP.md). -Augsta līmeņa ceļš: +Pašreizējais virziens: -1. ✅ Projekta ideja un arhitektūras pamats -2. 🟡 Frontend prototips, divvalodu UI pamats un dokumentācija -3. ⬜ Īsts CSV/TSV failu pārbaudes un parsēšanas slānis -4. ⬜ Normalizācija un kolonnu savienošanas noteikumi -5. ⬜ Determinēts salīdzināšanas dzinējs -6. ⬜ Eksports, privātums un drošības nostiprināšana -7. ⬜ Testu fixtures, veiktspēja un pieejamība -8. ⬜ Offline build un publiska izplatīšana -9. ⬜ Publisks release un contributor ekosistēma +1. ✅ projekta arhitektūra un frontend pamats +2. ✅ Visual Diff kā galvenais UX +3. 🟡 īsts lokāls CSV/TSV dzinējs +4. ⬜ sasaistīšanas un normalizācijas nostiprināšana +5. ⬜ eksporta, privātuma un drošības audits +6. ⬜ pieejamības, veiktspējas un regression kvalitātes vārti +7. ⬜ statiska/offline izplatīšana +8. ⬜ publisks laidiens -**Roadmap noteikums:** pēc katras nozīmīgas izstrādes kārtas jāatjaunina ROADMAP.md, lai repo skaidri redzams, kas mainīts, kas pārbaudīts un kas seko tālāk. +Pēc katras nozīmīgas izstrādes kārtas tiek atjaunināts `ROADMAP.md`. ## Contribution -Contribution būs gaidīti, tiklīdz attiecīgā projekta daļa būs pietiekami stabila drošam darbam. Tulkojumu contribution apzināti veidots vienkāršs. +Īpaši noderīgi contribution virzieni: + +- parsera edge cases; +- determinēta sasaistīšana; +- testa fixtures; +- pieejamība; +- veiktspēja; +- drošības nostiprināšana; +- tulkojumi; +- dokumentācija. + +Pirms PR izveides izlasi [CONTRIBUTING.md](CONTRIBUTING.md). -Skati [CONTRIBUTING.md](CONTRIBUTING.md). +## Drošība -## Dizaina principi +Lūdzu, nepublicē sensitīvus biznesa eksportus issues, pull requestos, screenshots vai testa fixtures. -- Viena skaidra problēma, atrisināta labi. -- Local-first, kur tas ir praktiski. -- Nekādas klusas minēšanas neskaidru datu gadījumā. -- Reāla uzvedība svarīgāka par funkciju skaitu. -- Minimālas dependencies. -- Pieejamas vadīklas, ne tikai drag-and-drop. -- Testiem jāpierāda uzvedība un jānokrīt, ja aizsargātā uzvedība tiek sabojāta. +Skati [SECURITY.md](SECURITY.md). ## Licence diff --git a/README.md b/README.md index c71e1c1..c89f33e 100644 --- a/README.md +++ b/README.md @@ -2,148 +2,223 @@ [English](README.md) · [Latviešu](README.lv.md) -**Two files in. Differences out. No account. No upload. No cloud database.** +> **Two files in. Differences out.** +> A privacy-first browser tool that compares two CSV/TSV exports locally and shows the differences side by side. -Two-Export Comparator is a privacy-first browser utility for comparing two exported datasets and finding records that are missing, duplicated, ambiguous, or different. +**Status:** pre-release / active development -The long-term goal is deliberately narrow: **drop two exports, map the relevant columns, compare them locally, inspect the differences, export the result.** +## Why this exists -> **Current status:** frontend prototype and architecture foundation. The production CSV/TSV parser and real comparison engine are not implemented yet. The current interface uses deterministic mock data while the engine is built behind a stable service contract. +Comparing exports should not require a spreadsheet formula maze, a cloud upload, or a data-engineering background. -## Why this project exists +Two-Export Comparator is built around one simple workflow: -Small businesses regularly export data from banks, shops, payment systems, inventory tools, CRMs, accounting tools and spreadsheets. The difficult part is often not producing the export — it is answering a simple question: +```text +Drop File A + File B + ↓ +Automatic local analysis + ↓ +Side-by-side Visual Diff + ↓ +Red = different +Neutral / blue = matching + ↓ +Export the result +``` -**What does not match between these two files?** +The normal user should not need to understand matching algorithms, normalization rules, or column-mapping terminology. The app attempts to do that work automatically. Manual mapping remains available only as **Expert settings**. -Typical examples: +## What it looks for -- bank export vs invoice export; -- shop orders vs payment provider export; -- warehouse export vs store export; -- old supplier list vs new supplier list; -- two reports that should contain the same references and amounts. +The comparison engine can classify records as: -This project is not intended to become an ERP, CRM, accounting suite or cloud data platform. +| Status | Meaning | +| --- | --- | +| `MATCHED` | A unique pair was found and the compared values match. | +| `MISMATCH` | The same record was found in both files, but one or more values differ. | +| `ONLY_A` | The record exists only in File A. | +| `ONLY_B` | The record exists only in File B. | +| `DUPLICATE` | A normalized key appears multiple times. | +| `AMBIGUOUS` | A unique safe pairing cannot be determined. The engine does not guess. | + +## Current capabilities + +- CSV and TSV input +- comma, semicolon and tab delimiter detection +- quoted CSV fields +- multiline quoted values +- UTF-8 and UTF-8 BOM handling +- basic type inference for text, numbers and dates +- automatic column-pair suggestions +- deterministic local comparison +- numeric tolerance +- duplicate and ambiguity detection +- source-row traceability +- side-by-side Visual Diff +- CSV result export +- English and Latvian UI +- contributor-friendly locale registry + +## Privacy by architecture + +Business exports can contain sensitive information. The application is designed so the normal comparison flow does not require sending those files anywhere. + +- files are read in the browser; +- file contents are not uploaded by the application; +- there is no account requirement; +- imported business data is not stored in an application database; +- no analytics or telemetry is required for comparison; +- imported values are rendered with safe DOM APIs; +- refreshing or closing the page clears in-memory file data. -## Privacy model +This describes the architecture. It is **not** a blanket legal claim such as “100% GDPR compliant”. -Business files may contain sensitive commercial or personal data, so the application is designed around local processing. +## Quick start -Planned production rules: +Requirements: -- file contents are processed in the browser; -- files are not uploaded by the application; -- no account is required; -- no application database stores imported files; -- no analytics or telemetry is required for the comparison workflow; -- no external JavaScript CDN is required; -- imported values are rendered with safe DOM APIs; -- closing or refreshing the page clears in-memory business data. +- a current Node.js version +- npm -This architecture reduces unnecessary data transfer, but the project does **not** make blanket legal claims such as “100% GDPR compliant”. +```bash +git clone https://github.com/QvarcY/two-export-comparator.git +cd two-export-comparator +npm install +npm test +npm run dev +``` -## Workflow +Production build: -~~~text -DROP FILE A + FILE B - ↓ -AUTOMATIC LOCAL ANALYSIS - ↓ -SIDE-BY-SIDE VISUAL DIFF - ↓ -RED = DIFFERENCE / NEUTRAL = MATCH - ↓ -OPTIONAL EXPERT SETTINGS - ↓ -EXPORT REPORT -~~~ +```bash +npm run build +npm run preview +``` -## Result categories +## How automatic matching works -| Status | Meaning | -| --- | --- | -| MATCHED | A unique record pair was found and compared values pass. | -| ONLY_A | A record exists only in File A. | -| ONLY_B | A record exists only in File B. | -| MISMATCH | The key matched, but one or more compared values differ. | -| DUPLICATE | The same normalized key occurs multiple times. | -| AMBIGUOUS | A unique safe pairing cannot be determined; the engine must not guess. | +The app analyzes column names, inferred data types, value overlap and uniqueness to suggest which columns represent the same concept across both files. + +Examples: + +```text +Reference ↔ Payment Ref +Amount ↔ Total +Date ↔ Paid Date +``` + +The first strong identifier candidate is used to pair records. Additional compatible pairs are used for comparison. + +Automatic suggestions are intentionally conservative. If the app cannot determine a safe mapping, the user can open **Expert settings** instead of the engine silently guessing. + +## Supported input + +Current target: + +- `.csv` +- `.tsv` +- UTF-8 / UTF-8 BOM +- up to 50 MiB per file in the current MVP + +Planned formats such as XLSX should only be added after the core CSV/TSV workflow is stable. + +## Project scope + +This project intentionally stays small. + +**Core promise:** + +> Take two files and visually show what is different. + +The project should not become an ERP, CRM, accounting suite, cloud workspace, BI platform, or file-storage service. + +Before adding a feature, ask: + +> Does this help the user understand the differences between two files faster? + +If the answer is no, it probably does not belong in the core product. + +See [PROJECT_SCOPE.md](docs/PROJECT_SCOPE.md). ## Architecture -~~~text +```text src/ -├── app/ application state and controller +├── app/ state + application controller +├── engine/ parsing, normalization, mapping and comparison logic ├── i18n/ locale registry and translations -├── models/ stable DTO / service contracts -├── services/ mock and future browser comparison service +├── models/ DTO / service contracts +├── services/ browser comparison service ├── ui/ │ ├── components/ │ ├── renderers/ │ └── views/ └── styles/ -~~~ +``` -The UI talks only to the ComparisonService boundary. The production parser, normalizers and comparison engine will live behind BrowserComparisonService so the interface does not need to be redesigned when the real engine replaces the mock. +The UI depends on the `ComparisonService` boundary rather than parser internals. This keeps technical comparison logic out of UI components. -## Languages - -The first official UI languages are: +## Development principles -- English (EN) -- Latvian / Latviešu (LV) +- one focused job, done well; +- local-first where practical; +- no silent guessing with ambiguous data; +- minimal dependencies; +- safe rendering of imported values; +- accessible controls; +- deterministic comparison before AI; +- regression tests must fail when the protected behavior is broken. -The language selector is generated from a locale registry. Contributors can add another language without changing comparison logic. +## Languages -See [Translation guide](docs/TRANSLATIONS.md). +Official UI languages: -README translations may be added as README.xx.md files and linked from the language line at the top. +- English (`en`) +- Latviešu (`lv`) -## Development +Adding another language does not require changing comparison logic. -~~~bash -npm install -npm run dev -npm run build -npm run preview -~~~ +See [Translation guide](docs/TRANSLATIONS.md). -Node/Vite is used only for development and static bundling. The production application remains a browser-side static application. +README translations may be added as `README.xx.md`. ## Roadmap -The detailed, continuously maintained roadmap is in [ROADMAP.md](ROADMAP.md). +The canonical development ledger is [ROADMAP.md](ROADMAP.md). -High-level path: +Current direction: -1. ✅ Project concept and architecture baseline -2. 🟡 Frontend prototype, bilingual UI foundation and documentation -3. ⬜ Real CSV/TSV inspection and parsing -4. ⬜ Normalization and column mapping rules -5. ⬜ Deterministic comparison engine -6. ⬜ Export, privacy and security hardening -7. ⬜ Test fixtures, performance and accessibility -8. ⬜ Offline build and public distribution -9. ⬜ Public release and contributor ecosystem +1. ✅ project architecture and frontend foundation +2. ✅ Visual Diff as the primary UX +3. 🟡 real local CSV/TSV engine +4. ⬜ harden matching and normalization +5. ⬜ export/privacy/security review +6. ⬜ accessibility, performance and regression quality gate +7. ⬜ static/offline distribution +8. ⬜ public release -**Roadmap rule:** every meaningful development round must update ROADMAP.md so the repository shows what changed, what is verified and what comes next. +Every meaningful development round updates `ROADMAP.md`. ## Contributing -Contributions are welcome once the relevant area is stable enough to work on safely. Translation contributions are intentionally low-friction. +Contributions are welcome, especially for: + +- parser edge cases; +- deterministic matching; +- test fixtures; +- accessibility; +- performance; +- security hardening; +- translations; +- documentation. + +Please read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a PR. -See [CONTRIBUTING.md](CONTRIBUTING.md). +## Security -## Design principles +Please do not publish sensitive business exports in issues, pull requests, screenshots, or fixtures. -- One focused job, done well. -- Local-first where practical. -- No silent guessing when data is ambiguous. -- Behavior over decorative feature count. -- Minimal dependencies. -- Accessible controls, not drag-and-drop only. -- Tests must prove behavior and must fail when the protected behavior is broken. +See [SECURITY.md](SECURITY.md). ## License diff --git a/ROADMAP.md b/ROADMAP.md index d57ff3f..cd8b7ee 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -145,7 +145,7 @@ Status: ✅ complete · 🟡 in progress · ⬜ planned · ⛔ blocked | 2026-09-26 | R2 — i18n + documentation foundation | ✅ | EN/LV locale registry, bilingual README, translation guide, roadmap, line-ending policy and package cleanup. | | 2026-09-26 | R3 — Live i18n + frontend cleanup | ✅ | Fixed language-switch reset/crash, wired EN/LV through the workflow, kept files in memory during locale changes, simplified mapping and fixed mock slot replacement/error recovery. | | 2026-09-26 | R4 — Visual Diff pivot | ✅ | Product flow changed to Upload → automatic comparison → side-by-side Visual Diff. Manual mapping moved behind Expert settings. | -| 2026-09-26 | R5 — Real browser engine MVP | 🟡 | Replaced mock default with real local CSV/TSV parsing, automatic mapping suggestions and deterministic comparison sufficient for the Visual Diff flow. Added regression fixtures/tests. Local build/test verification pending. | +| 2026-09-26 | R5 — Real browser engine MVP | 🟡 | Replaced mock default with real local CSV/TSV parsing, automatic mapping suggestions and deterministic comparison sufficient for the Visual Diff flow. Added regression fixtures/tests. Local build/test verification pending. |\n| 2026-09-26 | R6 — Repository polish | 🟡 | Reworked EN/LV README around the Visual Diff product promise, documented strict project scope, strengthened contribution/security guidance and added GitHub issue/PR templates. | ## Next target / Nākamais mērķis diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..0ce9485 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,35 @@ +# Security policy + +## Data-safety principle + +Two-Export Comparator is designed around local browser processing. File contents should not be sent to a backend as part of the normal comparison workflow. + +## Reporting a vulnerability + +Please avoid publishing security-sensitive details in a public issue before a fix is available. + +When reporting a problem, include: + +- affected version/commit; +- browser and operating system; +- minimal reproduction steps; +- whether the issue can expose imported file contents; +- whether export generation is involved. + +Do **not** attach real confidential business exports. Use a minimal synthetic fixture instead. + +## Sensitive areas + +Security review should pay particular attention to: + +- imported cell values rendered in the DOM; +- CSV formula injection in generated exports; +- malformed CSV/TSV input; +- resource exhaustion from large files; +- accidental network requests; +- persistence of imported data; +- future third-party dependencies. + +## Supported versions + +The project is currently pre-release. Security fixes target the active development branch until the first public release is tagged. diff --git a/docs/PROJECT_SCOPE.md b/docs/PROJECT_SCOPE.md new file mode 100644 index 0000000..df03679 --- /dev/null +++ b/docs/PROJECT_SCOPE.md @@ -0,0 +1,68 @@ +# Project scope + +Two-Export Comparator should remain intentionally small. + +## Core promise + +> Take two files and visually show what is different. + +The default workflow should remain: + +```text +2 files → automatic local comparison → visual differences → optional export +``` + +## In scope + +- CSV / TSV parsing +- deterministic matching +- visual difference highlighting +- duplicate / ambiguity handling +- safe local processing +- result export +- accessibility +- performance +- translation support +- narrowly-scoped Expert settings + +## Not core scope + +The project should not grow into: + +- ERP +- CRM +- accounting software +- cloud file storage +- collaboration workspace +- project management +- BI/dashboard suite +- user-account platform +- scheduled data synchronization +- generic spreadsheet editor +- AI chat interface + +A future feature may still be useful, but usefulness alone is not enough to put it in the core. + +## Feature test + +Before adding a feature, ask: + +1. Does it directly help compare two files? +2. Does it reduce the time to understand a difference? +3. Can it stay optional if most users do not need it? +4. Does it preserve local-first/privacy-first behavior? +5. Can we test it deterministically? + +If most answers are no, the feature should stay out. + +## UX rule + +The normal user should not need to understand: + +- keys; +- normalization; +- tolerance terminology; +- schema mapping; +- parser internals. + +Those concepts may exist internally or under Expert settings, but the primary flow must remain understandable without documentation. From 99cd40c7b2b916667f0952a7c80ccee51bfe70ea Mon Sep 17 00:00:00 2001 From: QvarcY <118832438+QvarcY@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:25:25 +0300 Subject: [PATCH 8/9] docs: add README hero and CI polish --- .github/workflows/ci.yml | 34 +++++++++++++++++++++++++++++++++ README.lv.md | 2 ++ README.md | 2 ++ ROADMAP.md | 10 +++++----- docs/assets/hero.svg | 41 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 84 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 docs/assets/hero.svg diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..297753c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + push: + branches: + - main + - ui/frontend-prototype + pull_request: + +permissions: + contents: read + +jobs: + test-and-build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm test + + - name: Build + run: npm run build diff --git a/README.lv.md b/README.lv.md index aa9cbf6..d6844df 100644 --- a/README.lv.md +++ b/README.lv.md @@ -2,6 +2,8 @@ [English](README.md) · [Latviešu](README.lv.md) +![Two-Export Comparator — vizuāls salīdzinājums blakus](docs/assets/hero.svg) + > **Divi faili iekšā. Atšķirības ārā.** > Privātumam draudzīgs pārlūka rīks, kas lokāli salīdzina divus CSV/TSV eksportus un vizuāli parāda atšķirības blakus. diff --git a/README.md b/README.md index c89f33e..48f5f57 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ [English](README.md) · [Latviešu](README.lv.md) +![Two-Export Comparator — side-by-side visual diff](docs/assets/hero.svg) + > **Two files in. Differences out.** > A privacy-first browser tool that compares two CSV/TSV exports locally and shows the differences side by side. diff --git a/ROADMAP.md b/ROADMAP.md index cd8b7ee..912f718 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -16,7 +16,7 @@ Status: ✅ complete · 🟡 in progress · ⬜ planned · ⛔ blocked - ✅ MockComparisonService for deterministic UI development. - ✅ Baseline Git repository and frontend branch strategy. -## Phase 1 — Frontend prototype and project foundation / Frontend prototips un projekta pamats 🟡 +## Phase 1 — Frontend prototype and project foundation / Frontend prototips un projekta pamats ✅ - ✅ Modern dark-first UI prototype. - ✅ File A / File B panels. @@ -40,12 +40,12 @@ Status: ✅ complete · 🟡 in progress · ⬜ planned · ⛔ blocked - ✅ Comparison failure returns to a usable mapping state. - ✅ Package metadata aligned with the Vite 7 lockfile baseline. - ✅ Repository line-ending policy added. -- 🟡 Clean production build verification pending. +- ✅ Clean production build verified locally with Vite 7.3.6. - 🟡 Source-generated parser warnings will move to code-based localization in Phase 2. **Exit criteria:** complete bilingual UI, clean build, no duplicate IDs, package metadata consistent, no known frontend blocker. -## Phase 2 — Real file inspection and parsing / Īsta failu pārbaude un parsēšana 🟡 +## Phase 2 — Real file inspection and parsing / Īsta failu pārbaude un parsēšana ✅ - ✅ Replace mock file inspection in BrowserComparisonService. - ✅ CSV parser with quoted fields and multiline quoted values. @@ -145,8 +145,8 @@ Status: ✅ complete · 🟡 in progress · ⬜ planned · ⛔ blocked | 2026-09-26 | R2 — i18n + documentation foundation | ✅ | EN/LV locale registry, bilingual README, translation guide, roadmap, line-ending policy and package cleanup. | | 2026-09-26 | R3 — Live i18n + frontend cleanup | ✅ | Fixed language-switch reset/crash, wired EN/LV through the workflow, kept files in memory during locale changes, simplified mapping and fixed mock slot replacement/error recovery. | | 2026-09-26 | R4 — Visual Diff pivot | ✅ | Product flow changed to Upload → automatic comparison → side-by-side Visual Diff. Manual mapping moved behind Expert settings. | -| 2026-09-26 | R5 — Real browser engine MVP | 🟡 | Replaced mock default with real local CSV/TSV parsing, automatic mapping suggestions and deterministic comparison sufficient for the Visual Diff flow. Added regression fixtures/tests. Local build/test verification pending. |\n| 2026-09-26 | R6 — Repository polish | 🟡 | Reworked EN/LV README around the Visual Diff product promise, documented strict project scope, strengthened contribution/security guidance and added GitHub issue/PR templates. | +| 2026-09-26 | R5 — Real browser engine MVP | ✅ | Replaced mock default with real local CSV/TSV parsing, automatic mapping suggestions and deterministic comparison sufficient for the Visual Diff flow. Regression fixtures pass (4/4) and production build verified locally. |\n| 2026-09-26 | R6 — Repository polish | ✅ | Reworked EN/LV README around the Visual Diff product promise, documented strict project scope, strengthened contribution/security guidance, added GitHub issue/PR templates, README hero artwork and CI workflow. | ## Next target / Nākamais mērķis -**R5 exit check:** run npm test and npm run build, then load the supplied File A/File B fixtures and verify that the Visual Diff is produced from their real contents rather than mock data. +**Next:** harden automatic matching/normalization without expanding the core workflow, then prepare the first public release candidate. diff --git a/docs/assets/hero.svg b/docs/assets/hero.svg new file mode 100644 index 0000000..d0086c2 --- /dev/null +++ b/docs/assets/hero.svg @@ -0,0 +1,41 @@ + + Two-Export Comparator + Privacy-first local CSV and TSV visual diff tool + + + Two-Export Comparator + Two files in. Differences out. Local only. + + + + FILE A + FILE B + + + + INV-1001 + INV-1001 + 125.00 + 125.00 + + + + INV-1002 + INV-1002 + + + 125.00 + 152.00 + + + DIFFERENT + + + + matching rows stay visually quiet + matching rows stay visually quiet + + + No upload · No account + CSV · TSV · Visual Diff + From 84050dea452e66216f7a572820c9ad8c142870a7 Mon Sep 17 00:00:00 2001 From: QvarcY <118832438+QvarcY@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:34:11 +0300 Subject: [PATCH 9/9] fix: clean release docs and visual diff labels --- README.lv.md | 4 +-- README.md | 4 +-- README_FRONTEND.md | 21 ++++++++--- ROADMAP.md | 61 ++++++++++++++++---------------- src/i18n/locales/en.js | 5 ++- src/i18n/locales/lv.js | 5 ++- src/ui/components/visual-diff.js | 5 +-- 7 files changed, 63 insertions(+), 42 deletions(-) diff --git a/README.lv.md b/README.lv.md index d6844df..c1e4c1a 100644 --- a/README.lv.md +++ b/README.lv.md @@ -192,8 +192,8 @@ Pašreizējais virziens: 1. ✅ projekta arhitektūra un frontend pamats 2. ✅ Visual Diff kā galvenais UX -3. 🟡 īsts lokāls CSV/TSV dzinējs -4. ⬜ sasaistīšanas un normalizācijas nostiprināšana +3. ✅ īsta lokāla CSV/TSV dzinēja MVP versija +4. 🟡 sasaistīšanas un normalizācijas nostiprināšana 5. ⬜ eksporta, privātuma un drošības audits 6. ⬜ pieejamības, veiktspējas un regression kvalitātes vārti 7. ⬜ statiska/offline izplatīšana diff --git a/README.md b/README.md index 48f5f57..93798c2 100644 --- a/README.md +++ b/README.md @@ -192,8 +192,8 @@ Current direction: 1. ✅ project architecture and frontend foundation 2. ✅ Visual Diff as the primary UX -3. 🟡 real local CSV/TSV engine -4. ⬜ harden matching and normalization +3. ✅ real local CSV/TSV engine MVP +4. 🟡 harden matching and normalization 5. ⬜ export/privacy/security review 6. ⬜ accessibility, performance and regression quality gate 7. ⬜ static/offline distribution diff --git a/README_FRONTEND.md b/README_FRONTEND.md index a821e43..e0b2bac 100644 --- a/README_FRONTEND.md +++ b/README_FRONTEND.md @@ -2,7 +2,7 @@ The public project description lives in [README.md](README.md) and [README.lv.md](README.lv.md). -The frontend is intentionally separated from the future comparison engine: +The application is intentionally split so UI code does not own parser or comparison logic: ~~~text UI / views / components @@ -11,14 +11,19 @@ AppController + AppStore ↓ ComparisonService ↓ -MockComparisonService (current prototype) -BrowserComparisonService (future production engine) +BrowserComparisonService + ↓ +engine/ + parsing · normalization · mapping suggestions · comparison ~~~ +`MockComparisonService` remains available for deterministic UI development, but the application now uses `BrowserComparisonService` by default. + ## Commands ~~~bash npm install +npm test npm run dev npm run build npm run preview @@ -28,4 +33,12 @@ npm run preview Do not implement parser, normalization or comparison algorithms inside UI components. -Language files live under src/i18n/locales/. See [docs/TRANSLATIONS.md](docs/TRANSLATIONS.md). +Keep the default workflow simple: + +~~~text +2 files → automatic local comparison → Visual Diff +~~~ + +Manual mapping belongs under Expert settings. + +Language files live under `src/i18n/locales/`. See [docs/TRANSLATIONS.md](docs/TRANSLATIONS.md). diff --git a/ROADMAP.md b/ROADMAP.md index 912f718..bb9e01b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -41,7 +41,7 @@ Status: ✅ complete · 🟡 in progress · ⬜ planned · ⛔ blocked - ✅ Package metadata aligned with the Vite 7 lockfile baseline. - ✅ Repository line-ending policy added. - ✅ Clean production build verified locally with Vite 7.3.6. -- 🟡 Source-generated parser warnings will move to code-based localization in Phase 2. +- ⬜ Source-generated parser warnings still need code-based localization. **Exit criteria:** complete bilingual UI, clean build, no duplicate IDs, package metadata consistent, no known frontend blocker. @@ -60,51 +60,51 @@ Status: ✅ complete · 🟡 in progress · ⬜ planned · ⛔ blocked **Exit criteria:** real CSV/TSV files can be inspected locally without upload and parser fixtures prove edge-case behavior. -## Phase 3 — Mapping and normalization / Kolonnu savienošana un normalizācija ⬜ +## Phase 3 — Mapping and normalization / Kolonnu savienošana un normalizācija 🟡 -- ⬜ Exact text keys. -- ⬜ Composite keys. -- ⬜ Trim / case / repeated-whitespace normalization. -- ⬜ Explicit numeric parsing for dot/comma decimals. +- ✅ Exact text keys. +- ✅ Composite keys are supported by the mapping/indexing model. +- ✅ Trim / case / repeated-whitespace normalization. +- ✅ Explicit numeric parsing for dot/comma decimals. - ⬜ Conservative date handling. - ⬜ User-selected ambiguous date format. -- ⬜ Numeric tolerance: absolute and percentage. -- ⬜ Deterministic column mapping suggestions without AI. -- ⬜ Mapping validation and readable error states. +- ✅ Numeric tolerance: absolute and percentage. +- ✅ Deterministic column mapping suggestions without AI. +- 🟡 Basic mapping validation exists; error UX still needs hardening. **Exit criteria:** mappings are explicit, auditable and never silently guess ambiguous formats. -## Phase 4 — Comparison engine / Salīdzināšanas dzinējs ⬜ - -- ⬜ Hash-map indexing; avoid O(n²) pair scans. -- ⬜ MATCHED. -- ⬜ ONLY_A. -- ⬜ ONLY_B. -- ⬜ MISMATCH. -- ⬜ DUPLICATE. -- ⬜ AMBIGUOUS. -- ⬜ Source row traceability. -- ⬜ Deterministic pairing rules. -- ⬜ No silent first-match behavior for duplicate/ambiguous records. +## Phase 4 — Comparison engine / Salīdzināšanas dzinējs 🟡 + +- ✅ Hash-map indexing; avoids O(n²) pair scans. +- ✅ MATCHED. +- ✅ ONLY_A. +- ✅ ONLY_B. +- ✅ MISMATCH. +- ✅ DUPLICATE. +- ✅ AMBIGUOUS. +- ✅ Source row traceability. +- ✅ Deterministic pairing rules for unique keys. +- ✅ Duplicate/ambiguous keys are surfaced instead of silently accepted as matches. - ⬜ Large-file performance tests. **Exit criteria:** removing or breaking any comparison rule causes the relevant regression fixture to fail. -## Phase 5 — Export, privacy and security hardening / Eksports, privātums un drošība ⬜ +## Phase 5 — Export, privacy and security hardening / Eksports, privātums un drošība 🟡 -- ⬜ Result CSV export. +- ✅ Result CSV export. - ⬜ Filtered exports. -- ⬜ CSV formula-injection protection. +- ✅ CSV formula-injection protection. - ⬜ CSP suitable for static deployment, including connect-src 'none' where deployment permits it. -- ⬜ No analytics, telemetry or external data APIs. -- ⬜ No business-file content in localStorage, IndexedDB or cookies. +- ✅ No analytics, telemetry or external data APIs in the comparison flow. +- ✅ No business-file content stored in localStorage, IndexedDB or cookies. - ⬜ Security review of imported values and generated downloads. - ⬜ Privacy explanation verified against actual behavior. -## Phase 6 — Quality gate / Kvalitātes vārti ⬜ +## Phase 6 — Quality gate / Kvalitātes vārti 🟡 -- ⬜ Unit tests. -- ⬜ Behavior-driven fixture tests. +- ✅ Node test coverage for parser and browser comparison service. +- ✅ Synthetic behavior fixtures for real comparison outcomes. - ⬜ Integration tests. - ⬜ Regression tests that fail on the pre-fix behavior. - ⬜ Accessibility audit: keyboard, labels, focus, screen reader semantics. @@ -145,7 +145,8 @@ Status: ✅ complete · 🟡 in progress · ⬜ planned · ⛔ blocked | 2026-09-26 | R2 — i18n + documentation foundation | ✅ | EN/LV locale registry, bilingual README, translation guide, roadmap, line-ending policy and package cleanup. | | 2026-09-26 | R3 — Live i18n + frontend cleanup | ✅ | Fixed language-switch reset/crash, wired EN/LV through the workflow, kept files in memory during locale changes, simplified mapping and fixed mock slot replacement/error recovery. | | 2026-09-26 | R4 — Visual Diff pivot | ✅ | Product flow changed to Upload → automatic comparison → side-by-side Visual Diff. Manual mapping moved behind Expert settings. | -| 2026-09-26 | R5 — Real browser engine MVP | ✅ | Replaced mock default with real local CSV/TSV parsing, automatic mapping suggestions and deterministic comparison sufficient for the Visual Diff flow. Regression fixtures pass (4/4) and production build verified locally. |\n| 2026-09-26 | R6 — Repository polish | ✅ | Reworked EN/LV README around the Visual Diff product promise, documented strict project scope, strengthened contribution/security guidance, added GitHub issue/PR templates, README hero artwork and CI workflow. | +| 2026-09-26 | R5 — Real browser engine MVP | ✅ | Replaced mock default with real local CSV/TSV parsing, automatic mapping suggestions and deterministic comparison sufficient for the Visual Diff flow. Regression fixtures pass (4/4) and production build verified locally. | +| 2026-09-26 | R6 — Repository polish | ✅ | Reworked EN/LV README around the Visual Diff product promise, documented strict project scope, strengthened contribution/security guidance, added GitHub issue/PR templates, README hero artwork and CI workflow. | ## Next target / Nākamais mērķis diff --git a/src/i18n/locales/en.js b/src/i18n/locales/en.js index 9b3134a..93c48c3 100644 --- a/src/i18n/locales/en.js +++ b/src/i18n/locales/en.js @@ -112,7 +112,10 @@ export default { 'visual.fileA': 'File A', 'visual.fileB': 'File B', 'visual.expert': 'Expert settings', - 'visual.onlyHere': 'Only in this file', + 'visual.onlyHere': 'Only in one file', + 'visual.onlyA': 'Only in File A', + 'visual.onlyB': 'Only in File B', + 'visual.notInFile': 'Not in this file', 'visual.changed': 'Different values', 'visual.matched': 'Matches', 'visual.ambiguous': 'Needs review', diff --git a/src/i18n/locales/lv.js b/src/i18n/locales/lv.js index 75ba1d5..dc35569 100644 --- a/src/i18n/locales/lv.js +++ b/src/i18n/locales/lv.js @@ -112,7 +112,10 @@ export default { 'visual.fileA': 'Fails A', 'visual.fileB': 'Fails B', 'visual.expert': 'Eksperta iestatījumi', - 'visual.onlyHere': 'Tikai šajā failā', + 'visual.onlyHere': 'Tikai vienā failā', + 'visual.onlyA': 'Tikai Failā A', + 'visual.onlyB': 'Tikai Failā B', + 'visual.notInFile': 'Nav šajā failā', 'visual.changed': 'Atšķirīgas vērtības', 'visual.matched': 'Sakrīt', 'visual.ambiguous': 'Jāpārbauda', diff --git a/src/ui/components/visual-diff.js b/src/ui/components/visual-diff.js index 6d57c03..b8ec593 100644 --- a/src/ui/components/visual-diff.js +++ b/src/ui/components/visual-diff.js @@ -51,7 +51,7 @@ export class VisualDiff { }, [ el('div', { class: 'visual-diff__missing-label', - text: t('visual.onlyHere'), + text: t('visual.notInFile'), }), ]); } @@ -104,7 +104,8 @@ export class VisualDiff { let label = t('visual.matched'); if (record.status === 'MISMATCH') label = t('visual.changed'); - else if (record.status === 'ONLY_A' || record.status === 'ONLY_B') label = t('visual.onlyHere'); + else if (record.status === 'ONLY_A') label = t('visual.onlyA'); + else if (record.status === 'ONLY_B') label = t('visual.onlyB'); else if (record.status === 'DUPLICATE' || record.status === 'AMBIGUOUS') label = t('visual.ambiguous'); return el('div', {