ueberpruefen
This commit is contained in:
72
src/lib/analytics/index.ts
Normal file
72
src/lib/analytics/index.ts
Normal 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
194
src/lib/db/index.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
250
src/lib/db/repositories/glazeRepository.ts
Normal file
250
src/lib/db/repositories/glazeRepository.ts
Normal 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;
|
||||
}
|
||||
5
src/lib/db/repositories/index.ts
Normal file
5
src/lib/db/repositories/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './projectRepository';
|
||||
export * from './stepRepository';
|
||||
export * from './glazeRepository';
|
||||
export * from './settingsRepository';
|
||||
export * from './newsRepository';
|
||||
54
src/lib/db/repositories/newsRepository.ts
Normal file
54
src/lib/db/repositories/newsRepository.ts
Normal 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()]
|
||||
);
|
||||
}
|
||||
130
src/lib/db/repositories/projectRepository.ts
Normal file
130
src/lib/db/repositories/projectRepository.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
51
src/lib/db/repositories/settingsRepository.ts
Normal file
51
src/lib/db/repositories/settingsRepository.ts
Normal 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,
|
||||
]
|
||||
);
|
||||
}
|
||||
229
src/lib/db/repositories/stepRepository.ts
Normal file
229
src/lib/db/repositories/stepRepository.ts
Normal 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
111
src/lib/db/schema.ts
Normal 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
138
src/lib/theme/index.ts
Normal 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',
|
||||
};
|
||||
108
src/lib/utils/colorMixing.ts
Normal file
108
src/lib/utils/colorMixing.ts
Normal 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`;
|
||||
}
|
||||
137
src/lib/utils/coneConverter.ts
Normal file
137
src/lib/utils/coneConverter.ts
Normal 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'];
|
||||
}
|
||||
65
src/lib/utils/conversions.ts
Normal file
65
src/lib/utils/conversions.ts
Normal 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
76
src/lib/utils/datetime.ts
Normal 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
6
src/lib/utils/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from './conversions';
|
||||
export * from './coneConverter';
|
||||
export * from './uuid';
|
||||
export * from './datetime';
|
||||
export * from './stepOrdering';
|
||||
export * from './colorMixing';
|
||||
93
src/lib/utils/stepOrdering.ts
Normal file
93
src/lib/utils/stepOrdering.ts
Normal 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
10
src/lib/utils/uuid.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user