A single-page web application for comparing two datasets (JSON or CSV) with advanced differentiation capabilities. Built with Vue 3, TypeScript, and Vite, this application runs entirely client-side without requiring a backend.
Project developed by: Vincent Capek
GitHub Repository: https://github.com/VincentCapek/diff_viewer
Technical Architecture
Technology Stack
Framework: Vue 3 (Composition API with <script setup>)
Language: TypeScript (strict mode)
Build Tool: Vite
Styling: Tailwind CSS v4
Dependencies:
- papaparse: CSV parsing with streaming support
- fast-deep-equal: Deep object comparison
- vue-virtual-scroller: Virtualization for large tables
File Structure
src/
├── components/
│ ├── UploadPanel.vue # Upload panel with drag-and-drop
│ ├── DiffControls.vue # Diff rules configuration
│ ├── DiffSummary.vue # Statistics and export options
│ └── DiffTable.vue # Virtualized results table
│
├── composables/
│ ├── useCsvJson.ts # CSV/JSON parsing & normalization
│ └── useDiffEngine.ts # Main diff algorithm
│
├── utils/
│ └── export.ts # Export utilities (JSON/CSV/HTML)
│
├── types.ts # TypeScript definitions
├── App.vue # Main component with state management
├── main.ts # Entry point
└── style.css # Tailwind + custom styles
Data Types
Core Types
// Recursive value supporting nested structures
type RecordValue =
| string
| number
| boolean
| null
| Record<string, unknown>
| RecordValue[];
// Data row (key-value object)
interface Row {
[key: string]: RecordValue;
}
// Complete dataset
interface Dataset {
rows: Row[];
}
Difference Types
type DiffKind = 'added' | 'removed' | 'modified' | 'unchanged';
// Cell-level diff
interface DiffCell {
key: string;
left?: RecordValue; // Value in left dataset
right?: RecordValue; // Value in right dataset
changed: boolean; // Change indicator
delta?: number; // Numeric delta if applicable
}
// Row-level diff
interface DiffRow {
id: string; // Composite identifier
kind: DiffKind;
cells: DiffCell[];
}
// Complete result with metadata
interface DiffResult {
rows: DiffRow[];
counts: {
added: number;
removed: number;
modified: number;
unchanged: number;
};
}
Rules Configuration
interface DiffRules {
// Primary keys (composite keys support)
primaryKeys: string[];
// Field mapping (Left → Right) with dot notation support
fieldMap: Record<string, string>;
// Fields to ignore (wildcards support)
ignoreKeys: string[];
// Absolute numeric tolerance
numericToleranceAbs: number;
// Percentage numeric tolerance (0.1 = 10%)
numericTolerancePct: number;
// Treat arrays as sets (ignore order)
arrayAsSet: boolean;
// Case-insensitive comparison
caseInsensitive: boolean;
// Type coercion ("42" == 42)
coerceTypes: boolean;
}
Diff Algorithm
Process Steps
1. INDEX CONSTRUCTION
├─ Create Map<key, row> for left dataset
└─ Create Map<key, row> for right dataset (after mapping)
2. ROW CHANGE IDENTIFICATION
├─ Added rows: keys present only on right
├─ Removed rows: keys present only on left
└─ Common rows: keys present on both sides
3. FIELD MAPPING
└─ Apply fieldMap to right dataset
Example: full_name → name, user.email → contact.email
4. FIELD FILTERING
└─ Exclude fields matching ignoreKeys
Wildcard support: metadata.*, temp_*
5. CELL COMPARISON
├─ Strings:
│ └─ Apply caseInsensitive if enabled
├─ Numbers:
│ ├─ Calculate absolute delta
│ ├─ Calculate percentage delta
│ └─ Accept if within ABS OR PCT tolerance
├─ Arrays:
│ ├─ If arrayAsSet: compare as sets (frequencies)
│ └─ Otherwise: ordered deep comparison
├─ Objects:
│ └─ Recursive deep comparison
└─ Type coercion if enabled:
└─ "42" → 42, "true" → true, "null" → null
6. RESULT GENERATION
└─ DiffResult with annotated rows and counters
Complexity
Indexing: O(n) for each dataset
Matching: O(1) lookups with Map
Comparison: O(n × m) where n = rows, m = columns
Total complexity: O(n × m)
Main Composables
1. useCsvJson.ts
- Parsing and Normalization
Responsibilities:
- JSON and CSV file parsing
- Data normalization (trim, coercion)
- Unique keys extraction
- Dot notation support for nested paths
- Nested object flattening
Key functions:
// Parse a JSON or CSV file
parseFile(file: File): Promise<Dataset>
// Normalize a dataset according to rules
normalize(dataset: Dataset, rules: Partial<DiffRules>): Dataset
// Extract all unique keys
extractKeys(dataset: Dataset): string[]
// Access a value via path (e.g., "user.name")
getValueAtPath(obj: Row, path: string): RecordValue | undefined
// Flatten a nested object to dot notation
flattenObject(obj: Row, prefix: string): Record<string, RecordValue>
Usage example:
const file = uploadedFile;
const dataset = await parseFile(file);
// dataset.rows = [{ id: 1, name: "John" }, ...]
const flattened = flattenObject({ user: { name: "John", age: 30 } });
// { "user.name": "John", "user.age": 30 }
2. useDiffEngine.ts
- Comparison Engine
Responsibilities:
- Main diff algorithm
- Value comparison with configurable rules
- Result filtering and search
- Column extraction from results
Key functions:
// Compute complete diff between two datasets
computeDiff(
left: Dataset,
right: Dataset,
rules: DiffRules
): DiffResult
// Extract unique column names
extractColumns(diffResult: DiffResult): string[]
// Filter results by type and search
filterDiffRows(
rows: DiffRow[],
kindFilter: DiffKind | 'all',
searchText: string
): DiffRow[]
Numeric comparison logic:
// Absolute tolerance: |right - left| ≤ numericToleranceAbs
const delta = Math.abs(rightVal - leftVal);
const withinAbsTolerance = delta <= rules.numericToleranceAbs;
// Percentage tolerance: delta / |left| ≤ numericTolerancePct
const pctDelta = leftVal !== 0 ? delta / Math.abs(leftVal) : delta;
const withinPctTolerance = pctDelta <= rules.numericTolerancePct;
// Accepted if either condition is met
const equal = withinAbsTolerance || withinPctTolerance;
Vue Components
1. UploadPanel.vue
Features:
- Upload by click or drag-and-drop
- JSON and CSV support
- Sample data buttons
- Loaded file indicators
2. DiffControls.vue
Features:
- Primary keys selection (multiple)
- Field mapping with dot notation
- Fields to ignore (wildcards)
- Numeric tolerances (absolute and percentage)
- Comparison options (toggles)
Configuration example:
{
primaryKeys: ["id"],
fieldMap: {
"full_name": "name",
"user.email": "contact.emailAddress"
},
ignoreKeys: ["metadata.*", "temp_*"],
numericToleranceAbs: 0.01,
numericTolerancePct: 0.05, // 5%
arrayAsSet: true,
caseInsensitive: false,
coerceTypes: true
}
3. DiffSummary.vue
Features:
- Change statistics (colored badges)
- JSON export (complete report)
- CSV export (changes only)
- HTML export (standalone with styles)
4. DiffTable.vue
Features:
- Virtualized table (vue-virtual-scroller)
- Sticky headers and first column
- Cell highlighting:
- 🟢 Green: added value
- 🔴 Red: removed value
- 🟡 Yellow: modified value
- Detailed tooltips on hover
- Expandable rows (raw JSON)
- Real-time filtering
- Dark mode support
Performance Optimizations
1. Efficient Parsing
// CSV: Streaming parse with PapaParse
Papa.parse(file, {
header: true,
skipEmptyLines: true,
worker: false // Can be enabled for very large files
});
// JSON: Direct reading with FileReader
const data = JSON.parse(await file.text());
2. O(n) Diff with Map
// Instead of nested loops O(n²)
for (const leftRow of leftRows) {
for (const rightRow of rightRows) {
if (leftRow.id === rightRow.id) { /* ... */ }
}
}
// Use Map for O(n)
const leftMap = new Map(leftRows.map(r => [r.id, r]));
const rightMap = new Map(rightRows.map(r => [r.id, r]));
for (const [key, leftRow] of leftMap) {
const rightRow = rightMap.get(key); // O(1) lookup
}
3. Virtual Scrolling
<RecycleScroller
:items="filteredRows"
:item-size="48"
key-field="id"
>
<template #default="{ item }">
<!-- Only visible rows are rendered -->
</template>
</RecycleScroller>
Benefit:
- 10,000 rows: ~50ms render instead of 2-3s
- Constant memory usage
- Smooth 60 FPS scrolling
4. Computed Properties with Memoization
// Vue automatically recalculates only if dependencies change
const filteredRows = computed(() => {
return filterDiffRows(
diffResult.value.rows,
kindFilter.value,
searchText.value
);
});
5. Search Debouncing
let searchTimeout: number;
const debouncedSearch = (text: string) => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
searchText.value = text;
}, 300);
};
Advanced Features
1. Composite Primary Keys
// Example: identify by region AND product_id
rules.primaryKeys = ["region", "product_id"];
// Generates key: "EU||12345"
const keyParts = ["EU", "12345"];
const compositeKey = keyParts.join("||");
2. Field Mapping with Dot Notation
// Map renamed or moved fields
rules.fieldMap = {
"full_name": "name", // Simple rename
"address.city": "location.city", // Nested paths
"user.email": "contact_email" // Flattening
};
3. Ignore Patterns with Wildcards
rules.ignoreKeys = [
"metadata.*", // Ignore all metadata fields
"temp_*", // Ignore fields starting with temp_
"*.internal", // Ignore .internal fields at any level
"id" // Ignore exact field
];
4. Array Comparison as Sets
// Without arrayAsSet (order matters)
[1, 2, 3] !== [3, 2, 1]
// With arrayAsSet (order ignored)
[1, 2, 3] === [3, 2, 1]
// Uses frequency map
const leftMap = new Map([
['1', 1], ['2', 1], ['3', 1]
]);
const rightMap = new Map([
['3', 1], ['2', 1], ['1', 1]
]);
// Compare maps
Exports
1. JSON Report
Format:
{
"metadata": {
"timestamp": "2025-10-03T14:30:00Z",
"rules": { /* DiffRules */ },
"counts": {
"added": 5,
"removed": 2,
"modified": 12,
"unchanged": 231
}
},
"changes": [
{
"id": "123",
"kind": "modified",
"fields": [
{
"key": "price",
"oldValue": 99.99,
"newValue": 89.99,
"delta": -10.00
}
]
}
]
}
2. CSV Export
Columns:
ID, Field, Old Value, New Value, Change Type
123, price, 99.99, 89.99, modified
124, -, -, {...}, added
3. Standalone HTML
- Inline CSS styles
- JavaScript for interactivity
- No external dependencies
- Easy sharing
Dark Mode
/* Automatic system preference detection */
@media (prefers-color-scheme: dark) {
:root {
--bg-primary: #1a1a1a;
--text-primary: #e5e5e5;
--diff-added: #065f46;
--diff-removed: #991b1b;
--diff-modified: #92400e;
}
}
Adaptive highlighting:
- Light mode: pastel backgrounds
- Dark mode: dark backgrounds with contrasted text
Accessibility
1. Keyboard Navigation
// Tab: navigation between controls
// Arrow keys: table navigation
// Enter/Space: row expansion
// Escape: close modals
2. ARIA Labels
<button
aria-label="Upload left dataset"
aria-describedby="upload-help"
>
Upload
</button>
<table
role="grid"
aria-label="Diff results"
aria-rowcount={filteredRows.length}
>
3. Focus Management
// Trap focus in modals
const trapFocus = (container: HTMLElement) => {
const focusableElements = container.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstFocusable = focusableElements[0];
const lastFocusable = focusableElements[focusableElements.length - 1];
container.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
if (e.shiftKey && document.activeElement === firstFocusable) {
e.preventDefault();
lastFocusable.focus();
} else if (!e.shiftKey && document.activeElement === lastFocusable) {
e.preventDefault();
firstFocusable.focus();
}
}
});
};
Use Cases
1. Database Migration
Scenario: Verify production → staging migration
- Export before/after data in JSON
- Configure primary key: ["user_id"]
- Ignore: ["created_at", "updated_at"]
- Numeric tolerance: 0.01 (for amounts)
- Identify discrepancies
2. API Validation
Scenario: Compare API v1 vs v2 responses
- Field mapping: {"full_name": "name", "addr": "address"}
- Array as set: true (for tags/permissions)
- Type coercion: true (API v1 returns strings)
- Export HTML for documentation
3. Data Audit
Scenario: Detect unauthorized changes
- Before/after snapshot in CSV
- Ignore fields: ["last_login", "session_*"]
- Filter: Modified only
- Export JSON for traceability
Vue 3 Patterns
1. Composition API
// Instead of Options API
export default {
data() { return { count: 0 } },
methods: { increment() { this.count++ } }
}
// Composition API with <script setup>
<script setup lang="ts">
import { ref } from 'vue';
const count = ref(0);
const increment = () => count.value++;
</script>
Advantages:
- Better reusability (composables)
- Type safety with TypeScript
- Logical co-location
- Tree-shaking
2. Reactive State Management
// No need for Vuex/Pinia for this app
const state = reactive({
leftDataset: null as Dataset | null,
rightDataset: null as Dataset | null,
rules: { /* ... */ },
diffResult: null as DiffResult | null
});
// Derived computed
const hasData = computed(() =>
state.leftDataset !== null && state.rightDataset !== null
);
3. Props with TypeScript
interface Props {
diffResult: DiffResult;
columns: string[];
filter: DiffFilter;
}
const props = defineProps<Props>();
const emit = defineEmits<{
'update:filter': [DiffFilter];
'export': [format: 'json' | 'csv' | 'html'];
}>();
Testing
1. Unit Tests (Vitest)
// Example: diff engine tests
describe('computeDiff', () => {
it('should detect added rows', () => {
const left = { rows: [{ id: 1, name: 'A' }] };
const right = { rows: [{ id: 1, name: 'A' }, { id: 2, name: 'B' }] };
const rules = { primaryKeys: ['id'], /* ... */ };
const result = computeDiff(left, right, rules);
expect(result.counts.added).toBe(1);
expect(result.rows.find(r => r.id === '2')?.kind).toBe('added');
});
it('should respect numeric tolerance', () => {
const left = { rows: [{ id: 1, price: 100 }] };
const right = { rows: [{ id: 1, price: 100.005 }] };
const rules = {
primaryKeys: ['id'],
numericToleranceAbs: 0.01,
numericTolerancePct: 0,
/* ... */
};
const result = computeDiff(left, right, rules);
expect(result.counts.modified).toBe(0);
expect(result.counts.unchanged).toBe(1);
});
});
2. Component Tests
import { mount } from '@vue/test-utils';
import DiffTable from './DiffTable.vue';
describe('DiffTable', () => {
it('should render diff rows', () => {
const wrapper = mount(DiffTable, {
props: {
diffResult: mockDiffResult,
columns: ['id', 'name'],
filter: { kind: 'all', searchText: '' }
}
});
expect(wrapper.findAll('tr')).toHaveLength(3);
});
});
Browser Support
Chrome/Edge: 90+
Firefox: 88+
Safari: 14+
Used features:
- ES2020 (optional chaining, nullish coalescing)
- FileReader API
- Map/Set
- CSS Grid/Flexbox
- CSS Custom Properties
Future Enhancements
1. WebWorker for Very Large Files
// Move computeDiff to a worker
const diffWorker = new Worker('./diffWorker.js');
diffWorker.postMessage({ left, right, rules });
diffWorker.onmessage = (e) => {
diffResult.value = e.data;
};
Benefit: Non-blocking UI for 100k+ row datasets
2. 3-Way Diff
// Compare 3 versions simultaneously
computeThreeWayDiff(base, left, right, rules);
// Useful for merge conflicts
3. Plugin System
interface DiffPlugin {
name: string;
compareValues?: (left, right, rules) => { equal: boolean };
renderCell?: (cell: DiffCell) => VNode;
}
registerPlugin({
name: 'date-comparison',
compareValues: (left, right) => {
// Custom logic for dates
}
});
4. Collaborative Mode
- Share diff configurations via URL
- WebRTC to compare datasets between peers
- Export reusable configurations
5. Artificial Intelligence
- Automatic primary key detection
- Field mapping suggestions
- Anomaly detection in changes
Development with Claude Sonnet 4.5
This project was developed by Vincent Capek with assistance from Claude Sonnet 4.5 via Claude Code.
AI Contributions
-
Architecture:
- Suggestion of composables vs centralized store approach
- TypeScript interface design
- Separation of concerns patterns
-
Algorithm:
- Diff optimization from O(n²) → O(n)
- Numeric tolerance logic
- Edge case handling (null, undefined, mixed types)
-
Performance:
- Early identification of virtual scrolling need
- Memoization suggestions
- Debouncing patterns
-
Accessibility:
- ARIA labels implementation
- Keyboard navigation
- Focus management
-
Tests:
- Complete unit tests writing
- Edge case identification
- Edge case coverage
Development Workflow
1. Initial prompt: "Build a JSON/CSV diff viewer with Vue 3"
2. Architecture iteration → validation
3. Feature-by-feature implementation
4. Refactoring with Claude feedback
5. Tests and optimizations
6. Documentation
Conclusion
This project demonstrates:
✅ Modern Vue 3 patterns: Composition API, strict TypeScript, composables
✅ Performance: Virtual scrolling, O(n) algorithms, memoization
✅ Flexibility: Granular configuration, multiple format support
✅ Accessibility: Keyboard navigation, ARIA, focus management
✅ Production-ready: Tests, TypeScript, error handling, exports
Repository: https://github.com/VincentCapek/diff_viewer
Top comments (0)