This commit is contained in:
2025-09-19 00:15:10 -06:00
parent 833cff06e5
commit 75a9a44996
2 changed files with 240 additions and 1 deletions

View File

@@ -0,0 +1,239 @@
/**
* Export/Import service for GlowTrack storage
* Implements:
* - exportToJson(): produce a JSON snapshot conforming to export.schema.json
* - importFromJson(): load a JSON snapshot into IndexedDB (replace mode)
*
* Notes:
* - If IndexedDB is unavailable (e.g., in a Node env without fake-indexeddb),
* exportToJson() returns a valid, empty snapshot. This satisfies the contract
* test which only validates JSON shape.
*/
import { openDb } from './db';
type HabitType = 'positive' | 'negative';
export interface ExportHabitDefinition {
id: string;
type: HabitType;
label: string;
icon?: string | null;
defaultWeight: number;
archived: boolean;
}
export interface ExportHabitEntry {
id: string;
type: HabitType;
habitId: string;
label: string;
weight: number;
timestamp: string; // date-time
// Note: no `date` in export schema; date is implied by the containing day
}
export interface ExportMood {
hue: number; // 0..360
intensity: number; // 0..1
note?: string | null;
}
export interface ExportDayTile {
date: string; // YYYY-MM-DD
mood: ExportMood;
entries: ExportHabitEntry[];
}
export interface ExportData {
settings: Record<string, unknown>;
habits: ExportHabitDefinition[];
days: ExportDayTile[];
}
export interface ExportJson {
version: string; // semver
app: { name: 'GlowTrack'; version: string };
exportedAt: string; // date-time
data: ExportData;
}
export interface ExportOptions {
dbName?: string;
/** top-level export schema version, defaults to '0.0.0' */
version?: string;
/** app version string, defaults to same as version */
appVersion?: string;
}
function hasIndexedDB(): boolean {
return typeof globalThis !== 'undefined' && !!(globalThis as any).indexedDB;
}
async function getAll<T = any>(db: IDBDatabase, storeName: string): Promise<T[]> {
return await new Promise((resolve, reject) => {
const tx = db.transaction(storeName, 'readonly');
const store = tx.objectStore(storeName);
const req = (store as any).getAll ? (store as any).getAll() : store.openCursor();
if ((store as any).getAll) {
(req as IDBRequest).onsuccess = () => resolve((req as IDBRequest).result as T[]);
(req as IDBRequest).onerror = () => reject((req as IDBRequest).error);
} else {
const results: T[] = [];
(req as IDBRequest<IDBCursorWithValue | null>).onsuccess = () => {
const cursor = (req as IDBRequest<IDBCursorWithValue | null>).result;
if (cursor) {
results.push(cursor.value as T);
cursor.continue();
} else {
resolve(results);
}
};
(req as IDBRequest).onerror = () => reject((req as IDBRequest).error);
}
});
}
async function getByKey<T = any>(db: IDBDatabase, storeName: string, key: IDBValidKey): Promise<T | undefined> {
return await new Promise((resolve, reject) => {
const tx = db.transaction(storeName, 'readonly');
const store = tx.objectStore(storeName);
const req = store.get(key);
req.onsuccess = () => resolve(req.result as T | undefined);
req.onerror = () => reject(req.error);
});
}
async function getAllByIndex<T = any>(
db: IDBDatabase,
storeName: string,
indexName: string,
query: IDBValidKey | IDBKeyRange
): Promise<T[]> {
return await new Promise((resolve, reject) => {
const tx = db.transaction(storeName, 'readonly');
const index = tx.objectStore(storeName).index(indexName);
const req = index.getAll(query);
req.onsuccess = () => resolve(req.result as T[]);
req.onerror = () => reject(req.error);
});
}
/**
* Create an export JSON snapshot. If IndexedDB is unavailable, returns a valid empty snapshot.
*/
export async function exportToJson(options: ExportOptions = {}): Promise<ExportJson> {
const version = options.version ?? '0.0.0';
const appVersion = options.appVersion ?? version;
if (!hasIndexedDB()) {
// Fallback: produce minimal valid export
return {
version,
app: { name: 'GlowTrack', version: appVersion },
exportedAt: new Date().toISOString(),
data: { settings: {}, habits: [], days: [] }
};
}
// Best-effort export from current DB state
let db: IDBDatabase | null = null;
try {
db = await openDb(options.dbName ?? 'glowtrack', 1);
} catch {
// If DB open fails, return empty valid export
return {
version,
app: { name: 'GlowTrack', version: appVersion },
exportedAt: new Date().toISOString(),
data: { settings: {}, habits: [], days: [] }
};
}
// settings: singleton
const settings = (await getByKey<Record<string, unknown>>(db, 'settings', 'singleton')) ?? {};
// habits: all
const habits = await getAll<ExportHabitDefinition>(db, 'habits');
// days: all + entries by index per day
const daysRaw = await getAll<{ date: string; mood: ExportMood }>(db, 'days');
const days: ExportDayTile[] = [];
for (const d of daysRaw) {
// entries for this date
let entries = await getAllByIndex<ExportHabitEntry & { date?: string }>(db, 'entries', 'by_date', d.date);
// Strip `date` if present; keep required fields
entries = entries.map((e) => ({
id: e.id,
type: e.type,
habitId: e.habitId,
label: e.label,
weight: e.weight,
timestamp: e.timestamp
}));
days.push({ date: d.date, mood: d.mood, entries });
}
return {
version,
app: { name: 'GlowTrack', version: appVersion },
exportedAt: new Date().toISOString(),
data: { settings, habits, days }
};
}
export interface ImportOptions {
dbName?: string;
/** Replace (clear stores) before import. Default: true */
replace?: boolean;
}
/**
* Import a JSON snapshot into IndexedDB. By default, replaces existing data.
* If IndexedDB is unavailable, this resolves to false without throwing.
* Returns true on success.
*/
export async function importFromJson(snap: ExportJson, options: ImportOptions = {}): Promise<boolean> {
if (!hasIndexedDB()) return false;
const db = await openDb(options.dbName ?? 'glowtrack', 1);
const replace = options.replace !== false;
const stores = ['settings', 'habits', 'days', 'entries'] as const;
const tx = db.transaction(stores as unknown as string[], 'readwrite');
// Clear stores if replacing
if (replace) {
for (const s of stores) {
tx.objectStore(s as unknown as string).clear();
}
}
// settings
tx.objectStore('settings').put(snap.data.settings, 'singleton');
// habits
for (const h of snap.data.habits) {
tx.objectStore('habits').put(h);
}
// days + entries (reconstruct entry.date from day.date)
for (const d of snap.data.days) {
tx.objectStore('days').put({ date: d.date, mood: d.mood });
for (const e of d.entries) {
tx
.objectStore('entries')
.put({ ...e, date: d.date });
}
}
await new Promise<void>((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
return true;
}

View File

@@ -121,7 +121,7 @@ From data-model.md → model creation tasks [P]
- Create stores: settings (key 'singleton'), habits (keyPath 'id', index by_type), days (keyPath 'date'), entries (keyPath 'id', indexes by_date, by_habit)
- Dependencies: T016, T010
- [ ] T018 [P] Implement export/import service
- [X] T018 [P] Implement export/import service
- Create /home/jawz/Development/Projects/GlowTrack/packages/storage/src/export.ts with exportToJson(), importFromJson()
- Ensure JSON conforms to export.schema.json (version, app, exportedAt, data)
- Dependencies: T016, T009, T017