DEV Community

A0mineTV
A0mineTV

Posted on

JSON/CSV Diff Viewer

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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[];
}
Enter fullscreen mode Exit fullscreen mode

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;
  };
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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[]
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

3. Virtual Scrolling

<RecycleScroller
  :items="filteredRows"
  :item-size="48"
  key-field="id"
>
  <template #default="{ item }">
    <!-- Only visible rows are rendered -->
  </template>
</RecycleScroller>
Enter fullscreen mode Exit fullscreen mode

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
  );
});
Enter fullscreen mode Exit fullscreen mode

5. Search Debouncing

let searchTimeout: number;
const debouncedSearch = (text: string) => {
  clearTimeout(searchTimeout);
  searchTimeout = setTimeout(() => {
    searchText.value = text;
  }, 300);
};
Enter fullscreen mode Exit fullscreen mode

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("||");
Enter fullscreen mode Exit fullscreen mode

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
};
Enter fullscreen mode Exit fullscreen mode

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
];
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
        }
      ]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

2. CSV Export

Columns:

ID, Field, Old Value, New Value, Change Type
123, price, 99.99, 89.99, modified
124, -, -, {...}, added
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}
>
Enter fullscreen mode Exit fullscreen mode

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();
      }
    }
  });
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

3. Data Audit

Scenario: Detect unauthorized changes
- Before/after snapshot in CSV
- Ignore fields: ["last_login", "session_*"]
- Filter: Modified only
- Export JSON for traceability
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode

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'];
}>();
Enter fullscreen mode Exit fullscreen mode

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);
  });
});
Enter fullscreen mode Exit fullscreen mode

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);
  });
});
Enter fullscreen mode Exit fullscreen mode

Browser Support

Chrome/Edge:  90+
Firefox:      88+
Safari:       14+
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
  }
});
Enter fullscreen mode Exit fullscreen mode

4. Collaborative Mode

- Share diff configurations via URL
- WebRTC to compare datasets between peers
- Export reusable configurations
Enter fullscreen mode Exit fullscreen mode

5. Artificial Intelligence

- Automatic primary key detection
- Field mapping suggestions
- Anomaly detection in changes
Enter fullscreen mode Exit fullscreen mode

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

  1. Architecture:

    • Suggestion of composables vs centralized store approach
    • TypeScript interface design
    • Separation of concerns patterns
  2. Algorithm:

    • Diff optimization from O(n²) → O(n)
    • Numeric tolerance logic
    • Edge case handling (null, undefined, mixed types)
  3. Performance:

    • Early identification of virtual scrolling need
    • Memoization suggestions
    • Debouncing patterns
  4. Accessibility:

    • ARIA labels implementation
    • Keyboard navigation
    • Focus management
  5. 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
Enter fullscreen mode Exit fullscreen mode

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)