ueberpruefen

This commit is contained in:
2025-10-28 23:38:37 +01:00
parent c2c5dd1041
commit 8dab9bfd25
56 changed files with 13640 additions and 2002 deletions

View File

@@ -0,0 +1,72 @@
import { getSettings } from '../db/repositories/settingsRepository';
export interface AnalyticsEvent {
name: string;
properties?: Record<string, any>;
}
/**
* Analytics abstraction with privacy controls
* By default, all events are no-op unless user opts in
*/
class Analytics {
private optedIn: boolean = false;
async initialize(userId?: string): Promise<void> {
// Analytics opt-in will be loaded once user is logged in
// For now, default to false
this.optedIn = false;
}
setOptIn(optIn: boolean): void {
this.optedIn = optIn;
}
track(eventName: string, properties?: Record<string, any>): void {
if (!this.optedIn) return;
// In a real app, this would send to analytics service
// For now, just log in dev mode
if (__DEV__) {
console.log('[Analytics]', eventName, properties);
}
// TODO: Integrate with Sentry, Amplitude, or similar
// Example: Sentry.addBreadcrumb({ category: 'analytics', message: eventName, data: properties });
}
// Convenience methods for common events
appOpen(cold: boolean): void {
this.track('app_open', { cold });
}
projectCreated(withCover: boolean): void {
this.track('project_created', { with_cover: withCover });
}
stepAdded(type: string, hasPhotos: boolean): void {
this.track('step_added', { type, has_photos: hasPhotos });
}
firingLogged(cone: string, tempUnit: string): void {
this.track('firing_logged', { cone, temp_unit: tempUnit });
}
glazeAddedToStep(brand: string, isCustom: boolean, coats: number): void {
this.track('glaze_added_to_step', { brand, is_custom: isCustom, coats });
}
settingsChanged(unitSystem: string, tempUnit: string): void {
this.track('settings_changed', { unitSystem, tempUnit });
}
exportDone(format: string): void {
this.track('export_done', { format });
}
contactSubmitted(success: boolean): void {
this.track('contact_submitted', { success });
}
}
export const analytics = new Analytics();

194
src/lib/db/index.ts Normal file
View File

@@ -0,0 +1,194 @@
import * as SQLite from 'expo-sqlite';
import { CREATE_TABLES, SCHEMA_VERSION } from './schema';
let db: SQLite.SQLiteDatabase | null = null;
/**
* Open or create the database
*/
export async function openDatabase(): Promise<SQLite.SQLiteDatabase> {
if (db) {
return db;
}
db = await SQLite.openDatabaseAsync('pottery_diary.db');
// Enable foreign keys
await db.execAsync('PRAGMA foreign_keys = ON;');
// Check schema version
try {
const result = await db.getFirstAsync<{ user_version: number }>(
'PRAGMA user_version;'
);
const currentVersion = result?.user_version || 0;
if (currentVersion < SCHEMA_VERSION) {
await migrateDatabase(db, currentVersion);
}
} catch (error) {
console.error('Error checking schema version:', error);
throw error;
}
return db;
}
/**
* Run database migrations
*/
async function migrateDatabase(
database: SQLite.SQLiteDatabase,
fromVersion: number
): Promise<void> {
console.log(`Migrating database from version ${fromVersion} to ${SCHEMA_VERSION}`);
if (fromVersion === 0) {
// Initial schema creation
await database.execAsync(CREATE_TABLES);
await database.execAsync(`PRAGMA user_version = ${SCHEMA_VERSION};`);
return;
}
// Migration from v1 to v2: Add color column to glazes
if (fromVersion < 2) {
console.log('Migrating to version 2: Dropping and recreating tables with color column');
// Drop all tables and recreate with new schema
await database.execAsync(`
DROP TABLE IF EXISTS step_glazes;
DROP TABLE IF EXISTS steps;
DROP TABLE IF EXISTS projects;
DROP TABLE IF EXISTS glazes;
DROP TABLE IF EXISTS settings;
`);
await database.execAsync(CREATE_TABLES);
await database.execAsync(`PRAGMA user_version = 2;`);
console.log('Migration to version 2 complete');
}
// Migration from v2 to v3: Re-seed glazes with 125 glazes
if (fromVersion < 3) {
console.log('Migrating to version 3: Clearing and re-seeding glaze catalog');
// Just delete all glazes and they will be re-seeded on next app init
await database.execAsync(`DELETE FROM glazes;`);
await database.execAsync(`PRAGMA user_version = 3;`);
console.log('Migration to version 3 complete');
}
// Migration from v3 to v4: Add mixed glaze support
if (fromVersion < 4) {
console.log('Migrating to version 4: Adding mixed glaze columns');
await database.execAsync(`
ALTER TABLE glazes ADD COLUMN is_mix INTEGER NOT NULL DEFAULT 0;
ALTER TABLE glazes ADD COLUMN mixed_glaze_ids TEXT;
ALTER TABLE glazes ADD COLUMN mix_ratio TEXT;
`);
await database.execAsync(`PRAGMA user_version = 4;`);
console.log('Migration to version 4 complete');
}
// Migration from v4 to v5: Add mix_notes to glazing_fields
if (fromVersion < 5) {
console.log('Migrating to version 5: Adding mix_notes to glazing_fields');
await database.execAsync(`
ALTER TABLE glazing_fields ADD COLUMN mix_notes TEXT;
`);
await database.execAsync(`PRAGMA user_version = 5;`);
console.log('Migration to version 5 complete');
}
// Migration from v5 to v6: Fix steps table to include 'trimming' type
if (fromVersion < 6) {
console.log('Migrating to version 6: Recreating steps table with trimming type');
// SQLite doesn't support modifying CHECK constraints, so we need to recreate the table
await database.execAsync(`
-- Create new table with correct constraint
CREATE TABLE steps_new (
id TEXT PRIMARY KEY NOT NULL,
project_id TEXT NOT NULL,
type TEXT NOT NULL CHECK(type IN ('forming', 'trimming', 'drying', 'bisque_firing', 'glazing', 'glaze_firing', 'misc')),
notes_markdown TEXT,
photo_uris TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
);
-- Copy data from old table
INSERT INTO steps_new SELECT * FROM steps;
-- Drop old table
DROP TABLE steps;
-- Rename new table
ALTER TABLE steps_new RENAME TO steps;
-- Recreate indexes
CREATE INDEX IF NOT EXISTS idx_steps_project_id ON steps(project_id);
CREATE INDEX IF NOT EXISTS idx_steps_type ON steps(type);
`);
await database.execAsync(`PRAGMA user_version = 6;`);
console.log('Migration to version 6 complete - trimming type now supported');
}
// Migration from v6 to v7: Add user_id columns for multi-user support
if (fromVersion < 7) {
console.log('Migrating to version 7: Adding user_id columns for multi-user support');
// For simplicity during development, we'll drop and recreate all tables
// In production, you'd want to preserve data and migrate it properly
await database.execAsync(`
-- Drop all tables
DROP TABLE IF EXISTS firing_fields;
DROP TABLE IF EXISTS glazing_fields;
DROP TABLE IF EXISTS steps;
DROP TABLE IF EXISTS projects;
DROP TABLE IF EXISTS glazes;
DROP TABLE IF EXISTS settings;
DROP TABLE IF EXISTS news_items;
`);
// Recreate with new schema
await database.execAsync(CREATE_TABLES);
await database.execAsync(`PRAGMA user_version = 7;`);
console.log('Migration to version 7 complete - multi-user support added');
console.log('Note: All existing data was cleared. Glazes will be re-seeded.');
}
}
/**
* Close the database
*/
export async function closeDatabase(): Promise<void> {
if (db) {
await db.closeAsync();
db = null;
}
}
/**
* Get the current database instance
*/
export function getDatabase(): SQLite.SQLiteDatabase {
if (!db) {
throw new Error('Database not initialized. Call openDatabase() first.');
}
return db;
}
/**
* Execute a transaction
*/
export async function runTransaction(
callback: (tx: SQLite.SQLiteDatabase) => Promise<void>
): Promise<void> {
const database = getDatabase();
try {
await database.execAsync('BEGIN TRANSACTION;');
await callback(database);
await database.execAsync('COMMIT;');
} catch (error) {
await database.execAsync('ROLLBACK;');
throw error;
}
}

View File

@@ -0,0 +1,250 @@
import { getDatabase } from '../index';
import { Glaze } from '../../../types';
import { generateUUID } from '../../utils/uuid';
import { now } from '../../utils/datetime';
import seedGlazes from '../../../../assets/seed/glazes.json';
export async function seedGlazeCatalog(): Promise<void> {
const db = getDatabase();
// Check if glazes are already seeded (seed glazes have no user_id)
const count = await db.getFirstAsync<{ count: number }>(
'SELECT COUNT(*) as count FROM glazes WHERE is_custom = 0 AND user_id IS NULL'
);
if (count && count.count > 0) {
return; // Already seeded
}
// Insert seed glazes (with NULL user_id to indicate they're shared)
for (const seedGlaze of seedGlazes) {
await db.runAsync(
`INSERT INTO glazes (id, user_id, brand, name, code, color, finish, notes, is_custom, created_at)
VALUES (?, NULL, ?, ?, ?, ?, ?, ?, 0, ?)`,
[
generateUUID(),
seedGlaze.brand,
seedGlaze.name,
seedGlaze.code || null,
(seedGlaze as any).color || null,
seedGlaze.finish || 'unknown',
seedGlaze.notes || null,
now(),
]
);
}
}
export async function createCustomGlaze(
userId: string,
brand: string,
name: string,
code?: string,
finish?: Glaze['finish'],
notes?: string
): Promise<Glaze> {
const db = getDatabase();
const glaze: Glaze = {
id: generateUUID(),
brand,
name,
code,
finish,
notes,
isCustom: true,
};
await db.runAsync(
`INSERT INTO glazes (id, user_id, brand, name, code, finish, notes, is_custom, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?)`,
[
glaze.id,
userId,
glaze.brand,
glaze.name,
glaze.code || null,
glaze.finish || 'unknown',
glaze.notes || null,
now(),
]
);
return glaze;
}
export async function createMixedGlaze(
userId: string,
glazes: Glaze[],
mixedColor: string,
mixRatio?: string
): Promise<Glaze> {
const db = getDatabase();
// Generate a name for the mix
const mixName = glazes.map(g => g.name).join(' + ');
const mixBrand = 'Mixed';
const glaze: Glaze = {
id: generateUUID(),
brand: mixBrand,
name: mixName,
color: mixedColor,
finish: 'unknown',
notes: mixRatio || undefined,
isCustom: true,
isMix: true,
mixedGlazeIds: glazes.map(g => g.id),
mixRatio: mixRatio || undefined,
};
await db.runAsync(
`INSERT INTO glazes (id, user_id, brand, name, code, color, finish, notes, is_custom, is_mix, mixed_glaze_ids, mix_ratio, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?, ?)`,
[
glaze.id,
userId,
glaze.brand,
glaze.name,
null, // no code for mixed glazes
glaze.color || null,
glaze.finish || null,
glaze.notes || null,
JSON.stringify(glaze.mixedGlazeIds),
glaze.mixRatio || null,
now(),
]
);
return glaze;
}
export async function getGlaze(id: string): Promise<Glaze | null> {
const db = getDatabase();
const row = await db.getFirstAsync<any>(
'SELECT * FROM glazes WHERE id = ?',
[id]
);
if (!row) return null;
return {
id: row.id,
brand: row.brand,
name: row.name,
code: row.code || undefined,
color: row.color || undefined,
finish: row.finish || undefined,
notes: row.notes || undefined,
isCustom: row.is_custom === 1,
isMix: row.is_mix === 1,
mixedGlazeIds: row.mixed_glaze_ids ? JSON.parse(row.mixed_glaze_ids) : undefined,
mixRatio: row.mix_ratio || undefined,
};
}
export async function getAllGlazes(userId?: string): Promise<Glaze[]> {
const db = getDatabase();
// Get seed glazes (user_id IS NULL) and user's custom glazes
const rows = await db.getAllAsync<any>(
userId
? 'SELECT * FROM glazes WHERE user_id IS NULL OR user_id = ? ORDER BY brand, name'
: 'SELECT * FROM glazes WHERE user_id IS NULL ORDER BY brand, name',
userId ? [userId] : []
);
return rows.map(row => ({
id: row.id,
brand: row.brand,
name: row.name,
code: row.code || undefined,
color: row.color || undefined,
finish: row.finish || undefined,
notes: row.notes || undefined,
isCustom: row.is_custom === 1,
isMix: row.is_mix === 1,
mixedGlazeIds: row.mixed_glaze_ids ? JSON.parse(row.mixed_glaze_ids) : undefined,
mixRatio: row.mix_ratio || undefined,
}));
}
export async function searchGlazes(query: string, userId?: string): Promise<Glaze[]> {
const db = getDatabase();
const searchTerm = `%${query}%`;
const rows = await db.getAllAsync<any>(
userId
? `SELECT * FROM glazes
WHERE (user_id IS NULL OR user_id = ?)
AND (brand LIKE ? OR name LIKE ? OR code LIKE ?)
ORDER BY brand, name`
: `SELECT * FROM glazes
WHERE user_id IS NULL
AND (brand LIKE ? OR name LIKE ? OR code LIKE ?)
ORDER BY brand, name`,
userId
? [userId, searchTerm, searchTerm, searchTerm]
: [searchTerm, searchTerm, searchTerm]
);
return rows.map(row => ({
id: row.id,
brand: row.brand,
name: row.name,
code: row.code || undefined,
color: row.color || undefined,
finish: row.finish || undefined,
notes: row.notes || undefined,
isCustom: row.is_custom === 1,
isMix: row.is_mix === 1,
mixedGlazeIds: row.mixed_glaze_ids ? JSON.parse(row.mixed_glaze_ids) : undefined,
mixRatio: row.mix_ratio || undefined,
}));
}
export async function updateGlaze(
id: string,
updates: Partial<Omit<Glaze, 'id' | 'isCustom'>>
): Promise<void> {
const db = getDatabase();
const glaze = await getGlaze(id);
if (!glaze) throw new Error('Glaze not found');
if (!glaze.isCustom) throw new Error('Cannot update seed glazes');
const updated = { ...glaze, ...updates };
await db.runAsync(
`UPDATE glazes
SET brand = ?, name = ?, code = ?, finish = ?, notes = ?
WHERE id = ?`,
[
updated.brand,
updated.name,
updated.code || null,
updated.finish || 'unknown',
updated.notes || null,
id,
]
);
}
export async function deleteGlaze(id: string): Promise<void> {
const db = getDatabase();
const glaze = await getGlaze(id);
if (!glaze) return;
if (!glaze.isCustom) throw new Error('Cannot delete seed glazes');
await db.runAsync('DELETE FROM glazes WHERE id = ?', [id]);
}
export async function getGlazesByIds(ids: string[]): Promise<Glaze[]> {
if (ids.length === 0) return [];
const glazes: Glaze[] = [];
for (const id of ids) {
const glaze = await getGlaze(id);
if (glaze) glazes.push(glaze);
}
return glazes;
}

View File

@@ -0,0 +1,5 @@
export * from './projectRepository';
export * from './stepRepository';
export * from './glazeRepository';
export * from './settingsRepository';
export * from './newsRepository';

View File

@@ -0,0 +1,54 @@
import { getDatabase } from '../index';
import { NewsItem } from '../../../types';
import { generateUUID } from '../../utils/uuid';
import { now } from '../../utils/datetime';
export async function cacheNewsItems(items: NewsItem[]): Promise<void> {
const db = getDatabase();
const timestamp = now();
for (const item of items) {
// Upsert news item
await db.runAsync(
`INSERT OR REPLACE INTO news_items (id, title, excerpt, url, content_html, published_at, cached_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[
item.id,
item.title,
item.excerpt || null,
item.url || null,
item.contentHtml || null,
item.publishedAt,
timestamp,
]
);
}
}
export async function getCachedNewsItems(limit: number = 20): Promise<NewsItem[]> {
const db = getDatabase();
const rows = await db.getAllAsync<any>(
'SELECT * FROM news_items ORDER BY published_at DESC LIMIT ?',
[limit]
);
return rows.map(row => ({
id: row.id,
title: row.title,
excerpt: row.excerpt || undefined,
url: row.url || undefined,
contentHtml: row.content_html || undefined,
publishedAt: row.published_at,
}));
}
export async function clearOldNewsCache(daysToKeep: number = 30): Promise<void> {
const db = getDatabase();
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - daysToKeep);
await db.runAsync(
'DELETE FROM news_items WHERE cached_at < ?',
[cutoffDate.toISOString()]
);
}

View File

@@ -0,0 +1,130 @@
import { getDatabase } from '../index';
import { Project } from '../../../types';
import { generateUUID } from '../../utils/uuid';
import { now } from '../../utils/datetime';
export async function createProject(
userId: string,
title: string,
tags: string[] = [],
status: Project['status'] = 'in_progress',
coverImageUri?: string
): Promise<Project> {
const db = getDatabase();
const project: Project = {
id: generateUUID(),
title,
status,
tags,
coverImageUri,
createdAt: now(),
updatedAt: now(),
};
await db.runAsync(
`INSERT INTO projects (id, user_id, title, status, tags, cover_image_uri, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
project.id,
userId,
project.title,
project.status,
JSON.stringify(project.tags),
project.coverImageUri || null,
project.createdAt,
project.updatedAt,
]
);
return project;
}
export async function getProject(id: string): Promise<Project | null> {
const db = getDatabase();
const row = await db.getFirstAsync<any>(
'SELECT * FROM projects WHERE id = ?',
[id]
);
if (!row) return null;
return {
id: row.id,
title: row.title,
status: row.status,
tags: JSON.parse(row.tags),
coverImageUri: row.cover_image_uri || undefined,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
export async function getAllProjects(userId: string): Promise<Project[]> {
const db = getDatabase();
const rows = await db.getAllAsync<any>(
'SELECT * FROM projects WHERE user_id = ? ORDER BY updated_at DESC',
[userId]
);
return rows.map(row => ({
id: row.id,
title: row.title,
status: row.status,
tags: JSON.parse(row.tags),
coverImageUri: row.cover_image_uri || undefined,
createdAt: row.created_at,
updatedAt: row.updated_at,
}));
}
export async function updateProject(
id: string,
updates: Partial<Omit<Project, 'id' | 'createdAt' | 'updatedAt'>>
): Promise<void> {
const db = getDatabase();
const project = await getProject(id);
if (!project) throw new Error('Project not found');
const updatedProject = {
...project,
...updates,
updatedAt: now(),
};
await db.runAsync(
`UPDATE projects
SET title = ?, status = ?, tags = ?, cover_image_uri = ?, updated_at = ?
WHERE id = ?`,
[
updatedProject.title,
updatedProject.status,
JSON.stringify(updatedProject.tags),
updatedProject.coverImageUri || null,
updatedProject.updatedAt,
id,
]
);
}
export async function deleteProject(id: string): Promise<void> {
const db = getDatabase();
await db.runAsync('DELETE FROM projects WHERE id = ?', [id]);
}
export async function getProjectsByStatus(userId: string, status: Project['status']): Promise<Project[]> {
const db = getDatabase();
const rows = await db.getAllAsync<any>(
'SELECT * FROM projects WHERE user_id = ? AND status = ? ORDER BY updated_at DESC',
[userId, status]
);
return rows.map(row => ({
id: row.id,
title: row.title,
status: row.status,
tags: JSON.parse(row.tags),
coverImageUri: row.cover_image_uri || undefined,
createdAt: row.created_at,
updatedAt: row.updated_at,
}));
}

View File

@@ -0,0 +1,51 @@
import { getDatabase } from '../index';
import { Settings } from '../../../types';
export async function getSettings(userId: string): Promise<Settings> {
const db = getDatabase();
const row = await db.getFirstAsync<any>(
'SELECT * FROM settings WHERE user_id = ?',
[userId]
);
if (!row) {
// Create default settings for this user
const defaults: Settings = {
unitSystem: 'imperial',
tempUnit: 'F',
analyticsOptIn: false,
};
await db.runAsync(
`INSERT INTO settings (user_id, unit_system, temp_unit, analytics_opt_in)
VALUES (?, ?, ?, ?)`,
[userId, defaults.unitSystem, defaults.tempUnit, defaults.analyticsOptIn ? 1 : 0]
);
return defaults;
}
return {
unitSystem: row.unit_system,
tempUnit: row.temp_unit,
analyticsOptIn: row.analytics_opt_in === 1,
};
}
export async function updateSettings(userId: string, updates: Partial<Settings>): Promise<void> {
const db = getDatabase();
const current = await getSettings(userId);
const updated = { ...current, ...updates };
await db.runAsync(
`UPDATE settings
SET unit_system = ?, temp_unit = ?, analytics_opt_in = ?
WHERE user_id = ?`,
[
updated.unitSystem,
updated.tempUnit,
updated.analyticsOptIn ? 1 : 0,
userId,
]
);
}

View File

@@ -0,0 +1,229 @@
import { getDatabase } from '../index';
import { Step, StepType, FiringFields, GlazingFields } from '../../../types';
import { generateUUID } from '../../utils/uuid';
import { now } from '../../utils/datetime';
interface CreateStepParams {
projectId: string;
type: StepType;
notesMarkdown?: string;
photoUris?: string[];
firing?: FiringFields;
glazing?: GlazingFields;
}
export async function createStep(params: CreateStepParams): Promise<Step> {
const db = getDatabase();
const stepId = generateUUID();
const timestamp = now();
// Insert base step
await db.runAsync(
`INSERT INTO steps (id, project_id, type, notes_markdown, photo_uris, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[
stepId,
params.projectId,
params.type,
params.notesMarkdown || null,
JSON.stringify(params.photoUris || []),
timestamp,
timestamp,
]
);
// Insert type-specific fields
if (params.type === 'bisque_firing' || params.type === 'glaze_firing') {
if (params.firing) {
await db.runAsync(
`INSERT INTO firing_fields (step_id, cone, temperature_value, temperature_unit, duration_minutes, kiln_notes)
VALUES (?, ?, ?, ?, ?, ?)`,
[
stepId,
params.firing.cone || null,
params.firing.temperature?.value || null,
params.firing.temperature?.unit || null,
params.firing.durationMinutes || null,
params.firing.kilnNotes || null,
]
);
}
} else if (params.type === 'glazing') {
if (params.glazing) {
await db.runAsync(
`INSERT INTO glazing_fields (step_id, glaze_ids, coats, application, mix_notes)
VALUES (?, ?, ?, ?, ?)`,
[
stepId,
JSON.stringify(params.glazing.glazeIds),
params.glazing.coats || null,
params.glazing.application || null,
params.glazing.mixNotes || null,
]
);
}
}
// Update project's updated_at
await db.runAsync(
'UPDATE projects SET updated_at = ? WHERE id = ?',
[timestamp, params.projectId]
);
const step = await getStep(stepId);
if (!step) throw new Error('Failed to create step');
return step;
}
export async function getStep(id: string): Promise<Step | null> {
const db = getDatabase();
const stepRow = await db.getFirstAsync<any>(
'SELECT * FROM steps WHERE id = ?',
[id]
);
if (!stepRow) return null;
const baseStep = {
id: stepRow.id,
projectId: stepRow.project_id,
type: stepRow.type as StepType,
notesMarkdown: stepRow.notes_markdown || undefined,
photoUris: JSON.parse(stepRow.photo_uris),
createdAt: stepRow.created_at,
updatedAt: stepRow.updated_at,
};
// Fetch type-specific fields
if (stepRow.type === 'bisque_firing' || stepRow.type === 'glaze_firing') {
const firingRow = await db.getFirstAsync<any>(
'SELECT * FROM firing_fields WHERE step_id = ?',
[id]
);
const firing: FiringFields = {
cone: firingRow?.cone || undefined,
temperature: firingRow?.temperature_value
? { value: firingRow.temperature_value, unit: firingRow.temperature_unit }
: undefined,
durationMinutes: firingRow?.duration_minutes || undefined,
kilnNotes: firingRow?.kiln_notes || undefined,
};
return { ...baseStep, type: stepRow.type, firing } as Step;
} else if (stepRow.type === 'glazing') {
const glazingRow = await db.getFirstAsync<any>(
'SELECT * FROM glazing_fields WHERE step_id = ?',
[id]
);
const glazing: GlazingFields = {
glazeIds: glazingRow ? JSON.parse(glazingRow.glaze_ids) : [],
coats: glazingRow?.coats || undefined,
application: glazingRow?.application || undefined,
mixNotes: glazingRow?.mix_notes || undefined,
};
return { ...baseStep, type: 'glazing', glazing } as Step;
}
return baseStep as Step;
}
export async function getStepsByProject(projectId: string): Promise<Step[]> {
const db = getDatabase();
const rows = await db.getAllAsync<any>(
'SELECT id FROM steps WHERE project_id = ? ORDER BY created_at ASC',
[projectId]
);
const steps: Step[] = [];
for (const row of rows) {
const step = await getStep(row.id);
if (step) steps.push(step);
}
return steps;
}
export async function updateStep(id: string, updates: Partial<CreateStepParams>): Promise<void> {
const db = getDatabase();
const step = await getStep(id);
if (!step) throw new Error('Step not found');
const timestamp = now();
// Update base fields
if (updates.notesMarkdown !== undefined || updates.photoUris !== undefined) {
await db.runAsync(
`UPDATE steps
SET notes_markdown = ?, photo_uris = ?, updated_at = ?
WHERE id = ?`,
[
updates.notesMarkdown !== undefined ? updates.notesMarkdown : step.notesMarkdown || null,
updates.photoUris !== undefined ? JSON.stringify(updates.photoUris) : JSON.stringify(step.photoUris),
timestamp,
id,
]
);
}
// Update type-specific fields
if (step.type === 'bisque_firing' || step.type === 'glaze_firing') {
if (updates.firing) {
const existingFiring = 'firing' in step ? step.firing : {};
const mergedFiring = { ...existingFiring, ...updates.firing };
await db.runAsync(
`UPDATE firing_fields
SET cone = ?, temperature_value = ?, temperature_unit = ?, duration_minutes = ?, kiln_notes = ?
WHERE step_id = ?`,
[
mergedFiring.cone || null,
mergedFiring.temperature?.value || null,
mergedFiring.temperature?.unit || null,
mergedFiring.durationMinutes || null,
mergedFiring.kilnNotes || null,
id,
]
);
}
} else if (step.type === 'glazing' && updates.glazing) {
const existingGlazing = 'glazing' in step ? step.glazing : { glazeIds: [] };
const mergedGlazing = { ...existingGlazing, ...updates.glazing };
await db.runAsync(
`UPDATE glazing_fields
SET glaze_ids = ?, coats = ?, application = ?, mix_notes = ?
WHERE step_id = ?`,
[
JSON.stringify(mergedGlazing.glazeIds),
mergedGlazing.coats || null,
mergedGlazing.application || null,
mergedGlazing.mixNotes || null,
id,
]
);
}
// Update project's updated_at
await db.runAsync(
'UPDATE projects SET updated_at = ? WHERE id = ?',
[timestamp, step.projectId]
);
}
export async function deleteStep(id: string): Promise<void> {
const db = getDatabase();
const step = await getStep(id);
if (!step) return;
await db.runAsync('DELETE FROM steps WHERE id = ?', [id]);
// Update project's updated_at
await db.runAsync(
'UPDATE projects SET updated_at = ? WHERE id = ?',
[now(), step.projectId]
);
}

111
src/lib/db/schema.ts Normal file
View File

@@ -0,0 +1,111 @@
/**
* Database schema definitions and migration scripts
*/
export const SCHEMA_VERSION = 7;
export const CREATE_TABLES = `
-- Projects table
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL,
title TEXT NOT NULL,
status TEXT NOT NULL CHECK(status IN ('in_progress', 'done', 'archived')),
tags TEXT NOT NULL, -- JSON array
cover_image_uri TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- Steps table
CREATE TABLE IF NOT EXISTS steps (
id TEXT PRIMARY KEY NOT NULL,
project_id TEXT NOT NULL,
type TEXT NOT NULL CHECK(type IN ('forming', 'trimming', 'drying', 'bisque_firing', 'glazing', 'glaze_firing', 'misc')),
notes_markdown TEXT,
photo_uris TEXT NOT NULL, -- JSON array
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
);
-- Firing fields (for bisque_firing and glaze_firing steps)
CREATE TABLE IF NOT EXISTS firing_fields (
step_id TEXT PRIMARY KEY NOT NULL,
cone TEXT,
temperature_value INTEGER,
temperature_unit TEXT CHECK(temperature_unit IN ('F', 'C')),
duration_minutes INTEGER,
kiln_notes TEXT,
FOREIGN KEY (step_id) REFERENCES steps(id) ON DELETE CASCADE
);
-- Glazing fields (for glazing steps)
CREATE TABLE IF NOT EXISTS glazing_fields (
step_id TEXT PRIMARY KEY NOT NULL,
glaze_ids TEXT NOT NULL, -- JSON array
coats INTEGER,
application TEXT CHECK(application IN ('brush', 'dip', 'spray', 'pour', 'other')),
mix_notes TEXT, -- notes about glaze mixing ratios (e.g., "50/50", "3:1")
FOREIGN KEY (step_id) REFERENCES steps(id) ON DELETE CASCADE
);
-- Glazes catalog
CREATE TABLE IF NOT EXISTS glazes (
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT, -- NULL for seed glazes, set for custom glazes
brand TEXT NOT NULL,
name TEXT NOT NULL,
code TEXT,
color TEXT, -- hex color code for preview
finish TEXT CHECK(finish IN ('glossy', 'satin', 'matte', 'special', 'unknown')),
notes TEXT,
is_custom INTEGER NOT NULL DEFAULT 0, -- 0 = seed, 1 = user custom
is_mix INTEGER NOT NULL DEFAULT 0, -- 0 = regular glaze, 1 = mixed glaze
mixed_glaze_ids TEXT, -- JSON array of glaze IDs that were mixed
mix_ratio TEXT, -- user's notes about the mix ratio (e.g., "50/50", "3:1")
created_at TEXT NOT NULL
);
-- Settings (per user)
CREATE TABLE IF NOT EXISTS settings (
user_id TEXT PRIMARY KEY NOT NULL,
unit_system TEXT NOT NULL CHECK(unit_system IN ('imperial', 'metric')),
temp_unit TEXT NOT NULL CHECK(temp_unit IN ('F', 'C')),
analytics_opt_in INTEGER NOT NULL DEFAULT 0
);
-- News/Tips cache (per user)
CREATE TABLE IF NOT EXISTS news_items (
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL,
title TEXT NOT NULL,
excerpt TEXT,
url TEXT,
content_html TEXT,
published_at TEXT NOT NULL,
cached_at TEXT NOT NULL
);
-- Create indexes for better query performance
CREATE INDEX IF NOT EXISTS idx_steps_project_id ON steps(project_id);
CREATE INDEX IF NOT EXISTS idx_steps_type ON steps(type);
CREATE INDEX IF NOT EXISTS idx_projects_user_id ON projects(user_id);
CREATE INDEX IF NOT EXISTS idx_projects_status ON projects(status);
CREATE INDEX IF NOT EXISTS idx_projects_updated_at ON projects(updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_glazes_user_id ON glazes(user_id);
CREATE INDEX IF NOT EXISTS idx_glazes_brand ON glazes(brand);
CREATE INDEX IF NOT EXISTS idx_glazes_is_custom ON glazes(is_custom);
CREATE INDEX IF NOT EXISTS idx_news_user_id ON news_items(user_id);
CREATE INDEX IF NOT EXISTS idx_news_published_at ON news_items(published_at DESC);
`;
export const DROP_ALL_TABLES = `
DROP TABLE IF EXISTS firing_fields;
DROP TABLE IF EXISTS glazing_fields;
DROP TABLE IF EXISTS steps;
DROP TABLE IF EXISTS projects;
DROP TABLE IF EXISTS glazes;
DROP TABLE IF EXISTS settings;
DROP TABLE IF EXISTS news_items;
`;

138
src/lib/theme/index.ts Normal file
View File

@@ -0,0 +1,138 @@
/**
* Theme configuration for Pottery Diary
* Retro, vintage-inspired design with warm earth tones
*/
export const colors = {
// Primary colors - warm vintage terracotta
primary: '#C17855', // Warm terracotta/clay
primaryLight: '#D4956F',
primaryDark: '#A5643E',
// Backgrounds - vintage paper tones
background: '#F5EDE4', // Warm cream/aged paper
backgroundSecondary: '#EBE0D5', // Darker vintage beige
card: '#FAF6F1', // Soft off-white card
// Text - warm vintage ink colors
text: '#3E2A1F', // Dark brown instead of black
textSecondary: '#8B7355', // Warm mid-brown
textTertiary: '#B59A7F', // Light warm brown
// Borders - subtle warm tones
border: '#D4C4B0',
borderLight: '#E5D9C9',
// Status colors - muted retro palette
success: '#6B8E4E', // Muted olive green
warning: '#D4894F', // Warm amber
error: '#B85C50', // Muted terracotta red
info: '#5B8A9F', // Muted teal
// Step type colors (icons) - vintage pottery palette
forming: '#8B5A3C', // Rich clay brown
trimming: '#A67C52', // Warm tan/tool color
drying: '#D4A05B', // Warm sand
bisqueFiring: '#C75B3F', // Burnt orange
glazing: '#7B5E7B', // Muted purple/mauve
glazeFiring: '#D86F4D', // Warm coral
misc: '#6B7B7B', // Vintage grey-blue
};
export const spacing = {
xs: 4,
sm: 8,
md: 16,
lg: 24,
xl: 32,
xxl: 48,
};
export const typography = {
fontSize: {
xs: 12,
sm: 14,
md: 16,
lg: 20, // Slightly larger for retro feel
xl: 28, // Bigger, bolder headers
xxl: 36,
},
fontWeight: {
regular: '400' as const,
medium: '500' as const,
semiBold: '600' as const,
bold: '800' as const, // Bolder for retro headings
},
// Retro-style letter spacing
letterSpacing: {
tight: -0.5,
normal: 0,
wide: 0.5,
wider: 1,
},
};
export const borderRadius = {
sm: 6,
md: 12, // More rounded for retro feel
lg: 16,
xl: 24,
full: 9999,
};
export const shadows = {
sm: {
shadowColor: '#3E2A1F',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.08,
shadowRadius: 3,
elevation: 2,
},
md: {
shadowColor: '#3E2A1F',
shadowOffset: { width: 0, height: 3 },
shadowOpacity: 0.12,
shadowRadius: 6,
elevation: 4,
},
lg: {
shadowColor: '#3E2A1F',
shadowOffset: { width: 0, height: 5 },
shadowOpacity: 0.15,
shadowRadius: 10,
elevation: 6,
},
};
// Minimum tappable size for accessibility
export const MIN_TAP_SIZE = 44;
export const stepTypeColors = {
forming: colors.forming,
trimming: colors.trimming,
drying: colors.drying,
bisque_firing: colors.bisqueFiring,
glazing: colors.glazing,
glaze_firing: colors.glazeFiring,
misc: colors.misc,
};
export const stepTypeIcons: Record<string, string> = {
forming: '🏺',
trimming: '🔧',
drying: '☀️',
bisque_firing: '🔥',
glazing: '🎨',
glaze_firing: '⚡',
misc: '📝',
};
export const stepTypeLabels: Record<string, string> = {
forming: 'Forming',
trimming: 'Trimming',
drying: 'Drying',
bisque_firing: 'Bisque Firing',
glazing: 'Glazing',
glaze_firing: 'Glaze Firing',
misc: 'Misc',
};

View File

@@ -0,0 +1,108 @@
/**
* Utility functions for mixing glaze colors
*/
/**
* Convert hex color to RGB
*/
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result
? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16),
}
: null;
}
/**
* Convert RGB to hex color
*/
function rgbToHex(r: number, g: number, b: number): string {
const toHex = (n: number) => {
const hex = Math.round(n).toString(16);
return hex.length === 1 ? '0' + hex : hex;
};
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
}
/**
* Mix multiple hex colors together (average RGB values)
* @param colors Array of hex color strings (e.g. ['#FF0000', '#0000FF'])
* @returns Mixed hex color string
*/
export function mixColors(colors: string[]): string {
if (colors.length === 0) {
return '#808080'; // Default gray
}
if (colors.length === 1) {
return colors[0];
}
// Convert all colors to RGB
const rgbColors = colors.map(hexToRgb).filter((rgb) => rgb !== null) as {
r: number;
g: number;
b: number;
}[];
if (rgbColors.length === 0) {
return '#808080'; // Default gray
}
// Calculate average RGB values
const avgR = rgbColors.reduce((sum, rgb) => sum + rgb.r, 0) / rgbColors.length;
const avgG = rgbColors.reduce((sum, rgb) => sum + rgb.g, 0) / rgbColors.length;
const avgB = rgbColors.reduce((sum, rgb) => sum + rgb.b, 0) / rgbColors.length;
// Convert back to hex
return rgbToHex(avgR, avgG, avgB);
}
/**
* Mix two colors with custom ratios
* @param color1 First hex color
* @param color2 Second hex color
* @param ratio1 Weight of first color (0-1), ratio2 will be (1 - ratio1)
* @returns Mixed hex color string
*/
export function mixColorsWithRatio(color1: string, color2: string, ratio1: number): string {
const rgb1 = hexToRgb(color1);
const rgb2 = hexToRgb(color2);
if (!rgb1 || !rgb2) {
return '#808080';
}
const ratio2 = 1 - ratio1;
const r = rgb1.r * ratio1 + rgb2.r * ratio2;
const g = rgb1.g * ratio1 + rgb2.g * ratio2;
const b = rgb1.b * ratio1 + rgb2.b * ratio2;
return rgbToHex(r, g, b);
}
/**
* Generate a name for a mixed glaze
* @param glazeNames Array of glaze names that were mixed
* @returns Generated name like "Blue + Yellow Mix"
*/
export function generateMixName(glazeNames: string[]): string {
if (glazeNames.length === 0) {
return 'Custom Mix';
}
if (glazeNames.length === 1) {
return glazeNames[0];
}
if (glazeNames.length === 2) {
return `${glazeNames[0]} + ${glazeNames[1]} Mix`;
}
// For 3+ glazes, show first two and count
return `${glazeNames[0]} + ${glazeNames[1]} + ${glazeNames.length - 2} more`;
}

View File

@@ -0,0 +1,137 @@
import { Temperature } from '../../types';
/**
* Orton Cone temperature reference data (self-supporting, end point temperatures)
* Based on standard firing rates for ceramics in Fahrenheit
*/
export interface ConeData {
cone: string;
fahrenheit: number;
celsius: number;
description: string;
}
export const CONE_TEMPERATURE_CHART: ConeData[] = [
{ cone: '022', fahrenheit: 1087, celsius: 586, description: 'Very low fire - overglaze' },
{ cone: '021', fahrenheit: 1112, celsius: 600, description: 'Very low fire' },
{ cone: '020', fahrenheit: 1159, celsius: 626, description: 'Low fire' },
{ cone: '019', fahrenheit: 1213, celsius: 656, description: 'Low fire' },
{ cone: '018', fahrenheit: 1267, celsius: 686, description: 'Low fire' },
{ cone: '017', fahrenheit: 1301, celsius: 705, description: 'Low fire' },
{ cone: '016', fahrenheit: 1368, celsius: 742, description: 'Low fire' },
{ cone: '015', fahrenheit: 1436, celsius: 780, description: 'Low fire' },
{ cone: '014', fahrenheit: 1485, celsius: 807, description: 'Low fire' },
{ cone: '013', fahrenheit: 1539, celsius: 837, description: 'Low fire' },
{ cone: '012', fahrenheit: 1582, celsius: 861, description: 'Low fire' },
{ cone: '011', fahrenheit: 1641, celsius: 894, description: 'Low fire' },
{ cone: '010', fahrenheit: 1657, celsius: 903, description: 'Low fire' },
{ cone: '09', fahrenheit: 1688, celsius: 920, description: 'Low fire' },
{ cone: '08', fahrenheit: 1728, celsius: 942, description: 'Low fire' },
{ cone: '07', fahrenheit: 1789, celsius: 976, description: 'Low fire' },
{ cone: '06', fahrenheit: 1828, celsius: 998, description: 'Earthenware / Low fire glaze' },
{ cone: '05', fahrenheit: 1888, celsius: 1031, description: 'Earthenware / Low fire glaze' },
{ cone: '04', fahrenheit: 1945, celsius: 1063, description: 'Bisque firing / Low fire glaze' },
{ cone: '03', fahrenheit: 1987, celsius: 1086, description: 'Bisque firing' },
{ cone: '02', fahrenheit: 2016, celsius: 1102, description: 'Mid-range' },
{ cone: '01', fahrenheit: 2046, celsius: 1119, description: 'Mid-range' },
{ cone: '1', fahrenheit: 2079, celsius: 1137, description: 'Mid-range' },
{ cone: '2', fahrenheit: 2088, celsius: 1142, description: 'Mid-range' },
{ cone: '3', fahrenheit: 2106, celsius: 1152, description: 'Mid-range' },
{ cone: '4', fahrenheit: 2124, celsius: 1162, description: 'Mid-range' },
{ cone: '5', fahrenheit: 2167, celsius: 1186, description: 'Mid-range / Stoneware' },
{ cone: '6', fahrenheit: 2232, celsius: 1222, description: 'Stoneware / Mid-range glaze' },
{ cone: '7', fahrenheit: 2262, celsius: 1239, description: 'Stoneware' },
{ cone: '8', fahrenheit: 2280, celsius: 1249, description: 'Stoneware' },
{ cone: '9', fahrenheit: 2300, celsius: 1260, description: 'High fire' },
{ cone: '10', fahrenheit: 2345, celsius: 1285, description: 'High fire / Stoneware' },
{ cone: '11', fahrenheit: 2361, celsius: 1294, description: 'High fire' },
{ cone: '12', fahrenheit: 2383, celsius: 1306, description: 'High fire' },
{ cone: '13', fahrenheit: 2410, celsius: 1321, description: 'Very high fire' },
{ cone: '14', fahrenheit: 2431, celsius: 1332, description: 'Very high fire / Porcelain' },
];
/**
* Get temperature for a specific Orton cone
* @param cone - The cone number (e.g., "04", "6", "10")
* @param preferredUnit - The user's preferred temperature unit ('F' or 'C')
*/
export function getConeTemperature(cone: string, preferredUnit: 'F' | 'C' = 'F'): Temperature | null {
const normalized = cone.trim().toLowerCase().replace(/^0+(?=[1-9])/, '0');
const coneData = CONE_TEMPERATURE_CHART.find(
c => c.cone.toLowerCase() === normalized
);
if (!coneData) {
return null;
}
return {
value: preferredUnit === 'C' ? coneData.celsius : coneData.fahrenheit,
unit: preferredUnit,
};
}
/**
* Get cone data by cone number
*/
export function getConeData(cone: string): ConeData | null {
const normalized = cone.trim().toLowerCase().replace(/^0+(?=[1-9])/, '0');
return CONE_TEMPERATURE_CHART.find(
c => c.cone.toLowerCase() === normalized
) || null;
}
/**
* Suggest cone based on temperature (returns closest match)
*/
export function suggestConeFromTemperature(temp: Temperature): ConeData | null {
const fahrenheit = temp.unit === 'F' ? temp.value : temp.value * 9/5 + 32;
let closest = CONE_TEMPERATURE_CHART[0];
let minDiff = Math.abs(closest.fahrenheit - fahrenheit);
for (const coneData of CONE_TEMPERATURE_CHART) {
const diff = Math.abs(coneData.fahrenheit - fahrenheit);
if (diff < minDiff) {
minDiff = diff;
closest = coneData;
}
}
return minDiff <= 100 ? closest : null; // Only suggest if within 100°F
}
/**
* Get all available cone numbers
*/
export function getAllCones(): string[] {
return CONE_TEMPERATURE_CHART.map(c => c.cone);
}
/**
* Validate if a cone number exists in the chart
*/
export function isValidCone(cone: string): boolean {
return getConeData(cone) !== null;
}
/**
* Format cone for display (e.g., "04" -> "Cone 04")
*/
export function formatCone(cone: string): string {
return `Cone ${cone}`;
}
/**
* Get common bisque firing cones
*/
export function getBisqueCones(): string[] {
return ['04', '03', '02', '01'];
}
/**
* Get common glaze firing cones
*/
export function getGlazeFiringCones(): string[] {
return ['06', '05', '04', '5', '6', '7', '8', '9', '10'];
}

View File

@@ -0,0 +1,65 @@
import { Temperature, TemperatureUnit } from '../../types';
/**
* Convert Fahrenheit to Celsius
*/
export function fahrenheitToCelsius(f: number): number {
return (f - 32) * (5 / 9);
}
/**
* Convert Celsius to Fahrenheit
*/
export function celsiusToFahrenheit(c: number): number {
return (c * 9 / 5) + 32;
}
/**
* Convert temperature between units
*/
export function convertTemperature(temp: Temperature, toUnit: TemperatureUnit): Temperature {
if (temp.unit === toUnit) {
return temp;
}
const value = temp.unit === 'F'
? fahrenheitToCelsius(temp.value)
: celsiusToFahrenheit(temp.value);
return { value: Math.round(value), unit: toUnit };
}
/**
* Format temperature for display
*/
export function formatTemperature(temp: Temperature): string {
return `${temp.value}°${temp.unit}`;
}
/**
* Convert pounds to kilograms
*/
export function poundsToKilograms(lb: number): number {
return lb * 0.453592;
}
/**
* Convert kilograms to pounds
*/
export function kilogramsToPounds(kg: number): number {
return kg / 0.453592;
}
/**
* Convert inches to centimeters
*/
export function inchesToCentimeters(inches: number): number {
return inches * 2.54;
}
/**
* Convert centimeters to inches
*/
export function centimetersToInches(cm: number): number {
return cm / 2.54;
}

76
src/lib/utils/datetime.ts Normal file
View File

@@ -0,0 +1,76 @@
/**
* Get current ISO timestamp
*/
export function now(): string {
return new Date().toISOString();
}
/**
* Format ISO date to readable string
*/
export function formatDate(isoString: string): string {
const date = new Date(isoString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
/**
* Format ISO date to readable date and time
*/
export function formatDateTime(isoString: string): string {
const date = new Date(isoString);
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
}
/**
* Get relative time string (e.g., "2 days ago")
*/
export function getRelativeTime(isoString: string): string {
const date = new Date(isoString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
if (diffDays < 30) return `${Math.floor(diffDays / 7)}w ago`;
if (diffDays < 365) return `${Math.floor(diffDays / 30)}mo ago`;
return `${Math.floor(diffDays / 365)}y ago`;
}
/**
* Format duration in minutes to h:mm
*/
export function formatDuration(minutes: number): string {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return `${hours}:${mins.toString().padStart(2, '0')}`;
}
/**
* Parse duration string (h:mm) to minutes
*/
export function parseDuration(durationString: string): number | null {
const match = durationString.match(/^(\d+):(\d{2})$/);
if (!match) return null;
const hours = parseInt(match[1], 10);
const mins = parseInt(match[2], 10);
if (mins >= 60) return null;
return hours * 60 + mins;
}

6
src/lib/utils/index.ts Normal file
View File

@@ -0,0 +1,6 @@
export * from './conversions';
export * from './coneConverter';
export * from './uuid';
export * from './datetime';
export * from './stepOrdering';
export * from './colorMixing';

View File

@@ -0,0 +1,93 @@
import { Step, StepType } from '../../types';
/**
* Defines the logical order of pottery steps in the creation process
*/
const STEP_ORDER: StepType[] = [
'forming',
'trimming',
'drying',
'bisque_firing',
'glazing',
'glaze_firing',
'misc',
];
/**
* Get the order index for a step type (lower = earlier in process)
*/
export function getStepOrderIndex(stepType: StepType): number {
const index = STEP_ORDER.indexOf(stepType);
return index === -1 ? 999 : index;
}
/**
* Sort steps by their logical order in the pottery process
* Steps of the same type are sorted by creation date
*/
export function sortStepsByLogicalOrder(steps: Step[]): Step[] {
return [...steps].sort((a, b) => {
const orderA = getStepOrderIndex(a.type);
const orderB = getStepOrderIndex(b.type);
if (orderA !== orderB) {
return orderA - orderB;
}
// Same step type, sort by creation date
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
});
}
/**
* Get the next suggested step based on completed steps
* Returns null if all typical steps are completed
*/
export function suggestNextStep(completedSteps: Step[]): StepType | null {
const completedTypes = new Set(completedSteps.map(s => s.type));
// Find first step type not yet completed
for (const stepType of STEP_ORDER) {
if (stepType === 'misc') continue; // Skip misc, it's optional
if (!completedTypes.has(stepType)) {
return stepType;
}
}
return null; // All steps completed
}
/**
* Check if steps are in a reasonable order
* Returns warnings for potentially out-of-order steps
*/
export function validateStepOrder(steps: Step[]): string[] {
const warnings: string[] = [];
const stepIndices = steps.map(s => ({ type: s.type, order: getStepOrderIndex(s.type) }));
// Check for major order violations (e.g., glazing before bisque firing)
for (let i = 0; i < stepIndices.length - 1; i++) {
const current = stepIndices[i];
const next = stepIndices[i + 1];
// If we go backwards by more than 1 step (allowing for some flexibility)
if (current.order > next.order + 1) {
warnings.push(`${current.type} typically comes before ${next.type}`);
}
}
// Specific pottery logic checks
const hasGlazing = steps.some(s => s.type === 'glazing');
const hasBisque = steps.some(s => s.type === 'bisque_firing');
const hasGlazeFiring = steps.some(s => s.type === 'glaze_firing');
if (hasGlazing && !hasBisque) {
warnings.push('Glazing typically requires bisque firing first');
}
if (hasGlazeFiring && !hasGlazing) {
warnings.push('Glaze firing typically requires glazing first');
}
return warnings;
}

10
src/lib/utils/uuid.ts Normal file
View File

@@ -0,0 +1,10 @@
/**
* Generate a UUID v4
*/
export function generateUUID(): string {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}