feat(zitare): integrate spiral-db for visual quote storage

Add spiral-db integration to Zitare as the second app (after Todo) to
use pixel-based spiral data visualization. Favorites are encoded as
colored pixels in a spiral pattern and can be exported/imported as PNG.

Changes:
- Add createQuoteSchema() to spiral-db with fields for category,
  language, author, text, and quoteId
- Create Svelte 5 spiral store with importFavorites, CRUD, PNG export
- Add SpiralCanvas component for interactive visualization
- Add /spiral route with stats, records list, and actions
- Wire up navigation (Ctrl+6) and auto-import favorites on mount

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-23 10:44:39 +01:00
parent 67a181bb04
commit 5bcbb4b71d
8 changed files with 1018 additions and 1 deletions

View file

@ -48,6 +48,7 @@
"@manacore/shared-theme": "workspace:*",
"@manacore/shared-theme-ui": "workspace:*",
"@manacore/shared-ui": "workspace:*",
"@manacore/spiral-db": "workspace:^",
"@zitare/content": "workspace:*",
"svelte-i18n": "^4.0.1"
},

View file

@ -0,0 +1,165 @@
<script lang="ts">
import type { SpiralImage } from '@manacore/spiral-db';
import { spiralToXY, xyToSpiral } from '@manacore/spiral-db';
interface Props {
image: SpiralImage;
scale?: number;
showGrid?: boolean;
highlightIndex?: number | null;
onPixelClick?: (index: number, x: number, y: number) => void;
}
let {
image,
scale = 10,
showGrid = false,
highlightIndex = null,
onPixelClick,
}: Props = $props();
let canvas: HTMLCanvasElement;
let hoveredIndex = $state<number | null>(null);
$effect(() => {
if (!canvas || !image) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const { width, height, pixels } = image;
canvas.width = width * scale;
canvas.height = height * scale;
ctx.fillStyle = '#1a1a1a';
ctx.fillRect(0, 0, canvas.width, canvas.height);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const offset = (y * width + x) * 3;
const r = pixels[offset];
const g = pixels[offset + 1];
const b = pixels[offset + 2];
ctx.fillStyle = `rgb(${r}, ${g}, ${b})`;
ctx.fillRect(x * scale, y * scale, scale, scale);
}
}
if (showGrid && scale >= 8) {
ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
ctx.lineWidth = 1;
for (let x = 0; x <= width; x++) {
ctx.beginPath();
ctx.moveTo(x * scale, 0);
ctx.lineTo(x * scale, height * scale);
ctx.stroke();
}
for (let y = 0; y <= height; y++) {
ctx.beginPath();
ctx.moveTo(0, y * scale);
ctx.lineTo(width * scale, y * scale);
ctx.stroke();
}
}
const center = Math.floor(width / 2);
ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)';
ctx.lineWidth = 2;
ctx.strokeRect(center * scale, center * scale, scale, scale);
if (highlightIndex !== null && highlightIndex >= 0) {
const point = spiralToXY(highlightIndex, width);
ctx.strokeStyle = '#fbbf24';
ctx.lineWidth = 2;
ctx.strokeRect(point.x * scale, point.y * scale, scale, scale);
}
if (hoveredIndex !== null) {
const point = spiralToXY(hoveredIndex, width);
ctx.strokeStyle = '#8b5cf6';
ctx.lineWidth = 2;
ctx.strokeRect(point.x * scale, point.y * scale, scale, scale);
}
});
function handleMouseMove(e: MouseEvent) {
if (!canvas || !image) return;
const rect = canvas.getBoundingClientRect();
const x = Math.floor((e.clientX - rect.left) / scale);
const y = Math.floor((e.clientY - rect.top) / scale);
if (x >= 0 && x < image.width && y >= 0 && y < image.height) {
hoveredIndex = xyToSpiral(x, y, image.width);
} else {
hoveredIndex = null;
}
}
function handleMouseLeave() {
hoveredIndex = null;
}
function handleClick(e: MouseEvent) {
if (!canvas || !image || !onPixelClick) return;
const rect = canvas.getBoundingClientRect();
const x = Math.floor((e.clientX - rect.left) / scale);
const y = Math.floor((e.clientY - rect.top) / scale);
if (x >= 0 && x < image.width && y >= 0 && y < image.height) {
const index = xyToSpiral(x, y, image.width);
onPixelClick(index, x, y);
}
}
</script>
<div class="spiral-canvas-container">
<canvas
bind:this={canvas}
onmousemove={handleMouseMove}
onmouseleave={handleMouseLeave}
onclick={handleClick}
class="spiral-canvas"
class:clickable={!!onPixelClick}
></canvas>
{#if hoveredIndex !== null}
<div class="pixel-info">
Pixel #{hoveredIndex}
</div>
{/if}
</div>
<style>
.spiral-canvas-container {
position: relative;
display: inline-block;
}
.spiral-canvas {
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
.spiral-canvas.clickable {
cursor: pointer;
}
.pixel-info {
position: absolute;
bottom: -30px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 4px 12px;
border-radius: 4px;
font-size: 12px;
font-family: monospace;
white-space: nowrap;
}
</style>

View file

@ -0,0 +1,233 @@
/**
* Spiral DB Store for Zitare
* Manages SpiralDB state for visual quote storage
*/
import {
SpiralDB,
createQuoteSchema,
type SpiralImage,
type SpiralRecord,
exportToPngBytes,
importFromPngBytes,
downloadPng,
} from '@manacore/spiral-db';
interface QuoteData extends Record<string, unknown> {
id: number;
status: number;
category: number;
language: number;
createdAt: Date;
quoteId: string;
author: string;
text: string;
}
interface SpiralStats {
imageSize: number;
totalPixels: number;
usedPixels: number;
totalRecords: number;
activeRecords: number;
deletedRecords: number;
currentRing: number;
compressionRatio: number;
}
const CATEGORY_MAP: Record<string, number> = {
motivation: 0,
weisheit: 1,
liebe: 2,
leben: 3,
erfolg: 4,
glueck: 5,
freundschaft: 6,
mut: 7,
hoffnung: 8,
natur: 9,
};
const CATEGORY_NAMES: Record<number, string> = Object.fromEntries(
Object.entries(CATEGORY_MAP).map(([k, v]) => [v, k])
);
const LANGUAGE_MAP: Record<string, number> = {
original: 0,
de: 1,
en: 2,
it: 3,
fr: 4,
es: 5,
};
class SpiralStore {
private db: SpiralDB<QuoteData>;
image = $state<SpiralImage | null>(null);
stats = $state<SpiralStats | null>(null);
records = $state<SpiralRecord<QuoteData>[]>([]);
isLoading = $state(false);
error = $state<string | null>(null);
constructor() {
this.db = new SpiralDB<QuoteData>({
schema: createQuoteSchema(),
compression: true,
});
this.updateState();
}
private updateState() {
this.image = this.db.getImage();
this.records = this.db.getAll();
const dbStats = this.db.getStats();
const jsonSize = JSON.stringify(this.records.map((r) => r.data)).length || 1;
const pixelBytes = Math.ceil((dbStats.usedPixels * 3) / 8);
this.stats = {
...dbStats,
compressionRatio: Math.round((1 - pixelBytes / jsonSize) * 100),
};
}
/**
* Import favorites from the favorites store, merged with quote data
*/
importFavorites(
favorites: Array<{
quoteId: string;
createdAt?: string | Date;
}>,
getQuote: (quoteId: string) => {
author: string;
text: string;
category: string;
language?: string;
} | null
) {
this.db = new SpiralDB<QuoteData>({
schema: createQuoteSchema(),
compression: true,
});
for (const fav of favorites) {
const quote = getQuote(fav.quoteId);
if (!quote) continue;
const result = this.db.insert({
id: 0,
status: 2, // favorited
category: CATEGORY_MAP[quote.category] ?? 0,
language: LANGUAGE_MAP[quote.language ?? 'de'] ?? 1,
createdAt: fav.createdAt ? new Date(fav.createdAt) : new Date(),
quoteId: fav.quoteId.slice(0, 100),
author: quote.author.slice(0, 100),
text: quote.text.slice(0, 255),
});
if (result.success) {
this.db.complete(result.recordId!);
}
}
this.updateState();
}
/**
* Add a single quote to the spiral
*/
addQuote(quote: {
quoteId: string;
author: string;
text: string;
category: string;
language?: string;
}) {
const result = this.db.insert({
id: 0,
status: 0,
category: CATEGORY_MAP[quote.category] ?? 0,
language: LANGUAGE_MAP[quote.language ?? 'de'] ?? 1,
createdAt: new Date(),
quoteId: quote.quoteId.slice(0, 100),
author: quote.author.slice(0, 100),
text: quote.text.slice(0, 255),
});
if (result.success) {
this.updateState();
}
return result;
}
/**
* Remove a quote (soft delete)
*/
removeQuote(id: number) {
const result = this.db.delete(id);
if (result.success) {
this.updateState();
}
return result;
}
/**
* Mark a quote as favorited
*/
favoriteQuote(id: number) {
const result = this.db.complete(id);
if (result.success) {
this.updateState();
}
return result;
}
downloadPng(filename = 'spiral-quotes.png') {
if (this.image) {
downloadPng(this.image, filename);
}
}
getPngBytes(): Uint8Array | null {
if (!this.image) return null;
return exportToPngBytes(this.image);
}
clear() {
this.db = new SpiralDB<QuoteData>({
schema: createQuoteSchema(),
compression: true,
});
this.updateState();
}
async importFromPng(file: File): Promise<{ success: boolean; error?: string }> {
try {
this.isLoading = true;
this.error = null;
const buffer = await file.arrayBuffer();
const bytes = new Uint8Array(buffer);
const image = await importFromPngBytes(bytes);
this.db = SpiralDB.fromImage<QuoteData>(image, createQuoteSchema());
this.updateState();
return { success: true };
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
this.error = errorMessage;
return { success: false, error: errorMessage };
} finally {
this.isLoading = false;
}
}
getCategoryName(index: number): string {
return CATEGORY_NAMES[index] ?? 'unknown';
}
}
export const spiralStore = new SpiralStore();

View file

@ -95,6 +95,7 @@
{ href: '/favorites', label: $_('nav.favorites'), icon: 'heart' },
{ href: '/lists', label: $_('nav.lists'), icon: 'list' },
{ href: '/settings', label: $_('nav.settings'), icon: 'settings' },
{ href: '/spiral', label: 'Spiral', icon: 'sparkles' },
]);
// Filter hidden nav items
@ -103,7 +104,7 @@
);
// Navigation routes for keyboard shortcuts
const navRoutes = ['/', '/categories', '/favorites', '/lists', '/settings'];
const navRoutes = ['/', '/categories', '/favorites', '/lists', '/settings', '/spiral'];
function handleToggleTheme() {
theme.toggleMode();

View file

@ -0,0 +1,549 @@
<script lang="ts">
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { COLORS } from '@manacore/spiral-db';
import type { ColorIndex } from '@manacore/spiral-db';
import { spiralStore } from '$lib/stores/spiral.svelte';
import { favoritesStore } from '$lib/stores/favorites.svelte';
import { quotesStore } from '$lib/stores/quotes.svelte';
import SpiralCanvas from '$lib/components/SpiralCanvas.svelte';
import { QUOTES, type Quote } from '@zitare/content';
let zoom = $state(10);
let showGrid = $state(false);
let selectedPixel = $state<number | null>(null);
let fileInput: HTMLInputElement;
// Build a lookup map for quotes
const quoteMap = new Map<string, Quote>();
for (const q of QUOTES) {
quoteMap.set(q.id, q);
}
function getQuoteData(quoteId: string) {
const quote = quoteMap.get(quoteId);
if (!quote) return null;
return {
author: quote.author,
text: quotesStore.getText(quote),
category: quote.category,
language: quotesStore.language,
};
}
function handleImportFavorites() {
spiralStore.importFavorites(
favoritesStore.favorites.map((f) => ({
quoteId: f.quoteId,
createdAt: f.createdAt,
})),
getQuoteData
);
}
function handlePixelClick(index: number) {
selectedPixel = selectedPixel === index ? null : index;
}
async function handleImportPng() {
fileInput?.click();
}
async function handleFileSelected(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
await spiralStore.importFromPng(file);
input.value = '';
}
// Category display helpers
const categoryIcons: Record<string, string> = {
motivation: '🔥',
weisheit: '🦉',
liebe: '❤️',
leben: '🌱',
erfolg: '🏆',
glueck: '🍀',
freundschaft: '🤝',
mut: '🦁',
hoffnung: '🌅',
natur: '🌿',
};
onMount(() => {
if (favoritesStore.favorites.length > 0) {
handleImportFavorites();
}
});
</script>
<div class="spiral-page">
<h1 class="title">SpiralDB</h1>
<p class="subtitle">Deine Zitate als Pixel-Spirale</p>
<div class="layout">
<!-- Visualization -->
<section class="section viz-section">
{#if spiralStore.image}
<div class="canvas-wrapper">
<SpiralCanvas
image={spiralStore.image}
scale={zoom}
{showGrid}
highlightIndex={selectedPixel}
onPixelClick={handlePixelClick}
/>
</div>
<div class="controls">
<label class="control">
<span>Zoom</span>
<input type="range" min="4" max="20" bind:value={zoom} />
<span class="mono">{zoom}x</span>
</label>
<label class="control">
<input type="checkbox" bind:checked={showGrid} />
<span>Grid</span>
</label>
</div>
{:else}
<div class="empty-state">
<p>Keine Daten. Importiere deine Favoriten oder lade eine PNG-Datei.</p>
</div>
{/if}
</section>
<!-- Stats -->
{#if spiralStore.stats}
<section class="section">
<h2 class="section-title">Statistiken</h2>
<div class="stats-grid">
<div class="stat">
<span class="stat-value"
>{spiralStore.stats.imageSize}x{spiralStore.stats.imageSize}</span
>
<span class="stat-label">Bildgr&ouml;&szlig;e</span>
</div>
<div class="stat">
<span class="stat-value">{spiralStore.stats.activeRecords}</span>
<span class="stat-label">Zitate</span>
</div>
<div class="stat">
<span class="stat-value">{spiralStore.stats.usedPixels}</span>
<span class="stat-label">Pixel belegt</span>
</div>
<div class="stat highlight">
<span class="stat-value">{spiralStore.stats.compressionRatio}%</span>
<span class="stat-label">Kompression vs JSON</span>
</div>
</div>
<!-- Color Legend -->
<div class="color-legend">
{#each Object.entries(COLORS) as [idx, color]}
<div class="color-item">
<span
class="color-swatch"
style="background: rgb({color.rgb.r}, {color.rgb.g}, {color.rgb.b})"
></span>
<span class="color-name">{color.name}</span>
<span class="color-bits mono">{color.bits.join('')}</span>
</div>
{/each}
</div>
</section>
{/if}
<!-- Records -->
<section class="section">
<h2 class="section-title">
Gespeicherte Zitate
{#if spiralStore.records.length > 0}
<span class="badge">{spiralStore.records.length}</span>
{/if}
</h2>
{#if spiralStore.records.length === 0}
<p class="empty-hint">Noch keine Zitate in der Spirale.</p>
{:else}
<div class="records-list">
{#each spiralStore.records as record}
{@const cat = spiralStore.getCategoryName(record.data.category)}
<div class="record" class:completed={record.meta.status === 'completed'}>
<div class="record-header">
<span class="record-icon">{categoryIcons[cat] ?? '💬'}</span>
<span class="record-author">{record.data.author}</span>
<span class="record-id mono">#{record.meta.id}</span>
</div>
<p class="record-text">{record.data.text}</p>
<div class="record-footer">
<span class="record-category">{cat}</span>
<button
class="btn-small btn-danger"
onclick={() => spiralStore.removeQuote(record.meta.id)}
>
&times;
</button>
</div>
</div>
{/each}
</div>
{/if}
</section>
<!-- Actions -->
<section class="section">
<h2 class="section-title">Aktionen</h2>
<div class="actions">
<button
class="btn btn-primary"
onclick={() => spiralStore.downloadPng()}
disabled={!spiralStore.stats || spiralStore.stats.totalRecords === 0}
>
PNG herunterladen
</button>
<button class="btn" onclick={handleImportPng}> PNG importieren </button>
<button
class="btn"
onclick={handleImportFavorites}
disabled={favoritesStore.favorites.length === 0}
>
Favoriten neu importieren ({favoritesStore.favorites.length})
</button>
<button
class="btn btn-danger"
onclick={() => spiralStore.clear()}
disabled={!spiralStore.stats || spiralStore.stats.totalRecords === 0}
>
Alles l&ouml;schen
</button>
</div>
<div class="info-box">
<p>
<strong>SpiralDB</strong> kodiert deine Zitate als farbige Pixel in einem Spiralmuster. Jedes
Pixel speichert 3 Bit (8 Farben). Das Bild w&auml;chst von der Mitte nach au&szlig;en, je mehr
Zitate du sammelst.
</p>
</div>
</section>
</div>
<input
bind:this={fileInput}
type="file"
accept=".png"
class="hidden"
onchange={handleFileSelected}
/>
</div>
<style>
.spiral-page {
padding: 1rem 0;
}
.title {
font-size: 1.5rem;
font-weight: 700;
color: var(--color-foreground);
margin-bottom: 0.25rem;
}
.subtitle {
color: var(--color-muted-foreground);
font-size: 0.875rem;
margin-bottom: 1.5rem;
}
.layout {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.section {
background: var(--color-card, rgba(255, 255, 255, 0.05));
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.1));
border-radius: 12px;
padding: 1.25rem;
}
.section-title {
font-size: 1rem;
font-weight: 600;
color: var(--color-foreground);
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.badge {
background: var(--color-primary, #8b5cf6);
color: white;
font-size: 0.75rem;
padding: 0.125rem 0.5rem;
border-radius: 999px;
}
.viz-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
.canvas-wrapper {
overflow: auto;
max-width: 100%;
max-height: 500px;
border-radius: 8px;
}
.controls {
display: flex;
gap: 1.5rem;
align-items: center;
flex-wrap: wrap;
}
.control {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: var(--color-muted-foreground);
}
.control input[type='range'] {
width: 100px;
}
.mono {
font-family: monospace;
font-size: 0.8rem;
}
/* Stats */
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
margin-bottom: 1rem;
}
@media (min-width: 640px) {
.stats-grid {
grid-template-columns: repeat(4, 1fr);
}
}
.stat {
text-align: center;
padding: 0.75rem;
background: var(--color-background, rgba(0, 0, 0, 0.2));
border-radius: 8px;
}
.stat-value {
display: block;
font-size: 1.25rem;
font-weight: 700;
color: var(--color-foreground);
font-family: monospace;
}
.stat-label {
display: block;
font-size: 0.7rem;
color: var(--color-muted-foreground);
margin-top: 0.25rem;
}
.stat.highlight {
background: linear-gradient(135deg, rgba(139, 92, 246, 0.2), rgba(99, 102, 241, 0.2));
}
/* Color Legend */
.color-legend {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.color-item {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: var(--color-muted-foreground);
}
.color-swatch {
width: 14px;
height: 14px;
border-radius: 3px;
border: 1px solid rgba(255, 255, 255, 0.2);
flex-shrink: 0;
}
.color-bits {
opacity: 0.6;
}
/* Records */
.records-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-height: 400px;
overflow-y: auto;
}
.record {
padding: 0.75rem;
background: var(--color-background, rgba(0, 0, 0, 0.2));
border-radius: 8px;
border-left: 3px solid var(--color-primary, #8b5cf6);
}
.record.completed {
border-left-color: #22c55e;
}
.record-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.375rem;
}
.record-icon {
font-size: 1rem;
}
.record-author {
font-weight: 600;
font-size: 0.875rem;
color: var(--color-foreground);
flex: 1;
}
.record-id {
font-size: 0.7rem;
color: var(--color-muted-foreground);
}
.record-text {
font-size: 0.8rem;
color: var(--color-muted-foreground);
line-height: 1.4;
margin-bottom: 0.375rem;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.record-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.record-category {
font-size: 0.7rem;
color: var(--color-muted-foreground);
text-transform: capitalize;
}
.empty-hint {
color: var(--color-muted-foreground);
font-size: 0.875rem;
text-align: center;
padding: 1rem;
}
.empty-state {
text-align: center;
padding: 2rem;
color: var(--color-muted-foreground);
}
/* Actions */
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1rem;
}
.btn {
padding: 0.5rem 1rem;
border-radius: 8px;
font-size: 0.875rem;
font-weight: 500;
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.1));
background: var(--color-background, rgba(0, 0, 0, 0.2));
color: var(--color-foreground);
cursor: pointer;
transition: all 0.15s;
}
.btn:hover:not(:disabled) {
background: var(--color-accent, rgba(255, 255, 255, 0.1));
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: var(--color-primary, #8b5cf6);
border-color: var(--color-primary, #8b5cf6);
color: white;
}
.btn-primary:hover:not(:disabled) {
opacity: 0.9;
}
.btn-danger {
color: #ef4444;
border-color: rgba(239, 68, 68, 0.3);
}
.btn-danger:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.1);
}
.btn-small {
padding: 0.125rem 0.5rem;
font-size: 0.75rem;
border-radius: 4px;
background: transparent;
border: 1px solid transparent;
cursor: pointer;
color: var(--color-muted-foreground);
}
.btn-small:hover {
color: #ef4444;
}
.info-box {
padding: 0.75rem;
background: var(--color-background, rgba(0, 0, 0, 0.2));
border-radius: 8px;
font-size: 0.8rem;
color: var(--color-muted-foreground);
line-height: 1.5;
}
.hidden {
display: none;
}
</style>

View file

@ -92,6 +92,7 @@ export {
// Schema utilities
export {
createTodoSchema,
createQuoteSchema,
encodeSchema,
decodeSchema,
getSchemaPixelCount,

View file

@ -8,6 +8,7 @@ import {
decodeSchema,
getSchemaPixelCount,
createTodoSchema,
createQuoteSchema,
validateRecord,
getFieldNames,
} from './schema.js';
@ -193,6 +194,52 @@ describe('validateRecord', () => {
});
});
describe('Quote Schema', () => {
it('should create quote schema with correct fields', () => {
const schema = createQuoteSchema();
expect(schema.name).toBe('quote');
expect(schema.version).toBe(1);
expect(schema.fields).toHaveLength(8);
expect(schema.fields.map((f) => f.name)).toEqual([
'id',
'status',
'category',
'language',
'createdAt',
'quoteId',
'author',
'text',
]);
});
it('should round-trip quote schema encode/decode', () => {
const schema = createQuoteSchema();
const pixels = encodeSchema(schema);
const names = getFieldNames(schema);
const decoded = decodeSchema(pixels, names);
expect(decoded.fields.length).toBe(schema.fields.length);
for (let i = 0; i < schema.fields.length; i++) {
expect(decoded.fields[i].type).toBe(schema.fields[i].type);
expect(decoded.fields[i].maxLength).toBe(schema.fields[i].maxLength);
}
});
it('should validate a valid quote record', () => {
const schema = createQuoteSchema();
const result = validateRecord(schema, {
id: 0,
status: 0,
category: 3,
language: 1,
createdAt: new Date(),
quoteId: 'q-123',
author: 'Goethe',
text: 'Ein kluges Wort',
});
expect(result.valid).toBe(true);
});
});
describe('getFieldNames', () => {
it('should return field names in order', () => {
const schema = createTodoSchema();

View file

@ -110,6 +110,26 @@ export function createTodoSchema(): SchemaDefinition {
};
}
/**
* Create a schema for Quote items (Zitare app)
*/
export function createQuoteSchema(): SchemaDefinition {
return {
version: 1,
name: 'quote',
fields: [
{ name: 'id', type: 'int', maxLength: 12 }, // 0-4095
{ name: 'status', type: 'int', maxLength: 3 }, // 0=active, 2=favorited, 4=removed
{ name: 'category', type: 'int', maxLength: 4 }, // 10 categories (0-15)
{ name: 'language', type: 'int', maxLength: 3 }, // 6 languages (0-7)
{ name: 'createdAt', type: 'timestamp', maxLength: 24 },
{ name: 'quoteId', type: 'string', maxLength: 100 }, // Reference to content package
{ name: 'author', type: 'string', maxLength: 100 },
{ name: 'text', type: 'string', maxLength: 255 },
],
};
}
/**
* Validate that a record matches a schema
*/