feat(apps): migrate Calendar, Clock, Contacts, ManaDeck to local-first

Roll out @manacore/local-store to 4 more apps:

- Clock: alarms, timers, world clocks in IndexedDB with guest seed
- Calendar: calendars, events in IndexedDB with sample events
- Contacts: contacts in IndexedDB with 3 sample contacts
- ManaDeck: decks, cards in IndexedDB with onboarding flashcards

All apps: GuestWelcomeModal, login pill for guests, sync on auth.
Dev scripts: added dev:sync, dev:todo:server, dev:todo:local, dev:todo:full updated.

6 of 8 web apps are now local-first (Todo, Zitare, Clock, Calendar, Contacts, ManaDeck).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-27 13:10:07 +01:00
parent 427195d6dc
commit 2c9a36828f
25 changed files with 1585 additions and 755 deletions

View file

@ -55,6 +55,7 @@
"@manacore/shared-help-types": "workspace:*",
"@manacore/shared-help-ui": "workspace:*",
"@manacore/shared-icons": "workspace:*",
"@manacore/local-store": "workspace:*",
"@manacore/shared-profile-ui": "workspace:*",
"@manacore/shared-splitscreen": "workspace:*",
"@manacore/shared-stores": "workspace:*",

View file

@ -0,0 +1,74 @@
/**
* Guest seed data for the Calendar app.
*
* These records are loaded into IndexedDB when a new guest visits the app.
* They provide a "Persoenlich" calendar with two sample events so the user
* can immediately see how the app works.
*/
import type { LocalCalendar, LocalEvent } from './local-store';
const PERSONAL_CALENDAR_ID = 'personal-calendar';
export const guestCalendars: LocalCalendar[] = [
{
id: PERSONAL_CALENDAR_ID,
name: 'Persönlich',
color: '#3B82F6',
isDefault: true,
isVisible: true,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
},
];
const now = new Date();
const today10 = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 10, 0, 0);
const today11 = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 11, 0, 0);
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 1);
const tomorrow14 = new Date(
tomorrow.getFullYear(),
tomorrow.getMonth(),
tomorrow.getDate(),
14,
0,
0
);
const tomorrow15 = new Date(
tomorrow.getFullYear(),
tomorrow.getMonth(),
tomorrow.getDate(),
15,
30,
0
);
export const guestEvents: LocalEvent[] = [
{
id: 'sample-event-1',
calendarId: PERSONAL_CALENDAR_ID,
title: 'Willkommen bei Kalender!',
description: 'Dies ist ein Beispieltermin. Tippe darauf, um ihn zu bearbeiten oder zu löschen.',
startDate: today10.toISOString(),
endDate: today11.toISOString(),
allDay: false,
location: null,
recurrenceRule: null,
color: null,
reminders: null,
},
{
id: 'sample-event-2',
calendarId: PERSONAL_CALENDAR_ID,
title: 'Mittagessen mit Freunden',
description: null,
startDate: tomorrow14.toISOString(),
endDate: tomorrow15.toISOString(),
allDay: false,
location: 'Café am See',
recurrenceRule: null,
color: null,
reminders: null,
},
];

View file

@ -0,0 +1,59 @@
/**
* Calendar App Local-First Data Layer
*
* Defines the IndexedDB database, collections, and guest seed data.
* This is the single source of truth for all Calendar data.
*/
import { createLocalStore, type BaseRecord } from '@manacore/local-store';
import { guestCalendars, guestEvents } from './guest-seed';
// ─── Types ──────────────────────────────────────────────────
export interface LocalCalendar extends BaseRecord {
name: string;
color: string;
isDefault: boolean;
isVisible: boolean;
timezone: string;
}
export interface LocalEvent extends BaseRecord {
calendarId: string;
title: string;
description?: string | null;
startDate: string;
endDate: string;
allDay: boolean;
location?: string | null;
recurrenceRule?: string | null;
color?: string | null;
reminders?: unknown | null;
}
// ─── Store ──────────────────────────────────────────────────
const SYNC_SERVER_URL = import.meta.env.PUBLIC_SYNC_SERVER_URL || 'http://localhost:3050';
export const calendarStore = createLocalStore({
appId: 'calendar',
collections: [
{
name: 'calendars',
indexes: ['isDefault', 'isVisible'],
guestSeed: guestCalendars,
},
{
name: 'events',
indexes: ['calendarId', 'startDate', 'endDate', 'allDay', '[calendarId+startDate]'],
guestSeed: guestEvents,
},
],
sync: {
serverUrl: SYNC_SERVER_URL,
},
});
// Typed collection accessors
export const calendarCollection = calendarStore.collection<LocalCalendar>('calendars');
export const eventCollection = calendarStore.collection<LocalEvent>('events');

View file

@ -1,28 +1,16 @@
/**
* Calendars Store - Manages user calendars using Svelte 5 runes
* Supports both authenticated (cloud) and guest (session) modes
* Calendars Store Local-First with IndexedDB
*
* All reads and writes go to IndexedDB first.
* Same public API as before so components don't break.
*/
import type { Calendar, CreateCalendarInput, UpdateCalendarInput } from '@calendar/shared';
import * as api from '$lib/api/calendars';
import { calendarCollection, type LocalCalendar } from '$lib/data/local-store';
import { BIRTHDAY_CALENDAR } from '$lib/api/birthdays';
import { settingsStore } from './settings.svelte';
import { authStore } from './auth.svelte';
import { CalendarEvents } from '@manacore/shared-utils/analytics';
// Guest calendar for unauthenticated users
const GUEST_CALENDAR: Calendar = {
id: 'session-calendar',
userId: 'guest',
name: 'Mein Kalender',
color: '#3b82f6',
isDefault: true,
isVisible: true,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
// State
let calendars = $state<Calendar[]>([]);
let loading = $state(false);
@ -35,12 +23,27 @@ const birthdayCalendar: Calendar = {
name: BIRTHDAY_CALENDAR.name,
color: BIRTHDAY_CALENDAR.color,
isDefault: false,
isVisible: true, // Visibility controlled by settingsStore.showBirthdays
isVisible: true,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
/** Convert a LocalCalendar (IndexedDB) to the shared Calendar type. */
function toCalendar(local: LocalCalendar): Calendar {
return {
id: local.id,
userId: 'guest',
name: local.name,
color: local.color,
isDefault: local.isDefault,
isVisible: local.isVisible,
timezone: local.timezone,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
// Helper to safely get calendars array (Svelte 5 runes safety)
function getCalendarsArray(): Calendar[] {
const arr = calendars ?? [];
@ -50,7 +53,6 @@ function getCalendarsArray(): Calendar[] {
// Derived: all calendars including virtual birthday calendar
const allCalendars = $derived.by(() => {
const userCalendars = getCalendarsArray();
// Add virtual birthday calendar if birthdays are enabled in settings
if (settingsStore.showBirthdays) {
return [...userCalendars, { ...birthdayCalendar, isVisible: true }];
}
@ -91,75 +93,86 @@ export const calendarsStore = {
},
/**
* Fetch all calendars
* In guest mode, returns a default local calendar
* Load calendars from IndexedDB.
*/
async fetchCalendars() {
loading = true;
error = null;
// Guest mode: return local calendar only
if (!authStore.isAuthenticated) {
calendars = [GUEST_CALENDAR];
loading = false;
return { data: { calendars: [GUEST_CALENDAR] }, error: null };
}
// Authenticated: fetch from API
const result = await api.getCalendars();
if (result.error) {
error = result.error.message;
try {
const localCalendars = await calendarCollection.getAll();
calendars = localCalendars.map(toCalendar);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to fetch calendars';
console.error('Failed to fetch calendars:', e);
calendars = [];
} else {
// API returns { calendars: [...] }
const data = result.data as { calendars: Calendar[] } | null;
calendars = data?.calendars || [];
} finally {
loading = false;
}
loading = false;
return result;
return { data: { calendars }, error: null };
},
/**
* Create a new calendar
* Create a new calendar writes to IndexedDB instantly.
*/
async createCalendar(data: CreateCalendarInput) {
const result = await api.createCalendar(data);
error = null;
try {
const newLocal: LocalCalendar = {
id: crypto.randomUUID(),
name: data.name,
color: data.color ?? '#3B82F6',
isDefault: data.isDefault ?? false,
isVisible: data.isVisible ?? true,
timezone: data.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone,
};
if (result.data) {
calendars = [...calendars, result.data];
const inserted = await calendarCollection.insert(newLocal);
const newCalendar = toCalendar(inserted);
calendars = [...calendars, newCalendar];
CalendarEvents.calendarCreated();
return { data: newCalendar, error: null };
} catch (e) {
const msg = e instanceof Error ? e.message : 'Failed to create calendar';
error = msg;
return { data: null, error: { message: msg } };
}
return result;
},
/**
* Update a calendar
* Update a calendar writes to IndexedDB instantly.
*/
async updateCalendar(id: string, data: UpdateCalendarInput) {
const result = await api.updateCalendar(id, data);
if (result.data) {
calendars = getCalendarsArray().map((c) => (c.id === id ? result.data! : c));
error = null;
try {
const updated = await calendarCollection.update(id, data as Partial<LocalCalendar>);
if (updated) {
const updatedCalendar = toCalendar(updated);
calendars = getCalendarsArray().map((c) => (c.id === id ? updatedCalendar : c));
return { data: updatedCalendar, error: null };
}
return { data: null, error: null };
} catch (e) {
const msg = e instanceof Error ? e.message : 'Failed to update calendar';
error = msg;
return { data: null, error: { message: msg } };
}
return result;
},
/**
* Delete a calendar
* Delete a calendar removes from IndexedDB instantly.
*/
async deleteCalendar(id: string) {
const result = await api.deleteCalendar(id);
if (!result.error) {
error = null;
try {
await calendarCollection.delete(id);
calendars = getCalendarsArray().filter((c) => c.id !== id);
CalendarEvents.calendarDeleted();
return { error: null };
} catch (e) {
const msg = e instanceof Error ? e.message : 'Failed to delete calendar';
error = msg;
return { error: { message: msg } };
}
return result;
},
/**
@ -177,17 +190,30 @@ export const calendarsStore = {
* Set a calendar as the default
*/
async setAsDefault(id: string) {
const result = await api.updateCalendar(id, { isDefault: true });
if (result.data) {
// Update local state: set this one as default, remove default from others
calendars = getCalendarsArray().map((c) => ({
...c,
isDefault: c.id === id,
}));
error = null;
try {
// Remove default from all others first
for (const cal of getCalendarsArray()) {
if (cal.isDefault && cal.id !== id) {
await calendarCollection.update(cal.id, { isDefault: false } as Partial<LocalCalendar>);
}
}
// Set the new default
const updated = await calendarCollection.update(id, {
isDefault: true,
} as Partial<LocalCalendar>);
if (updated) {
calendars = getCalendarsArray().map((c) => ({
...c,
isDefault: c.id === id,
}));
}
return { data: updated ? toCalendar(updated) : null, error: null };
} catch (e) {
const msg = e instanceof Error ? e.message : 'Failed to set default';
error = msg;
return { data: null, error: { message: msg } };
}
return result;
},
/**
@ -201,7 +227,6 @@ export const calendarsStore = {
* Get calendar color by ID (with fallback)
*/
getColor(id: string) {
// Handle virtual birthday calendar
if (id === BIRTHDAY_CALENDAR.id) {
return BIRTHDAY_CALENDAR.color;
}
@ -211,7 +236,6 @@ export const calendarsStore = {
/**
* Toggle birthday calendar visibility
* (This updates the settings store, not the calendar itself)
*/
toggleBirthdaysVisibility() {
settingsStore.set('showBirthdays', !settingsStore.showBirthdays);
@ -228,13 +252,19 @@ export const calendarsStore = {
* Check if a calendar ID is the guest calendar
*/
isGuestCalendar(id: string) {
return id === GUEST_CALENDAR.id;
return id === 'personal-calendar';
},
/**
* Get the guest calendar ID
*/
get guestCalendarId() {
return GUEST_CALENDAR.id;
return 'personal-calendar';
},
clear() {
calendars = [];
loading = false;
error = null;
},
};

View file

@ -1,10 +1,13 @@
/**
* Events Store - Manages calendar events using Svelte 5 runes
* Events Store Local-First with IndexedDB
*
* All reads and writes go to IndexedDB first.
* Same public API as before so components don't break.
*/
import type { CalendarEvent, CreateEventInput, UpdateEventInput } from '@calendar/shared';
import { parseRRule, generateOccurrences } from '@calendar/shared';
import * as api from '$lib/api/events';
import { eventCollection, type LocalEvent } from '$lib/data/local-store';
import { format, isWithinInterval, isSameDay, differenceInMilliseconds } from 'date-fns';
import { toDate } from '$lib/utils/eventDateHelpers';
import { toastStore } from '@manacore/shared-ui';
@ -21,6 +24,32 @@ let loadedRange = $state<{ start: Date; end: Date } | null>(null);
// Draft event for quick create (temporary event shown in grid before saving)
let draftEvent = $state<CalendarEvent | null>(null);
/** Convert a LocalEvent (IndexedDB) to the shared CalendarEvent type. */
function toCalendarEvent(local: LocalEvent): CalendarEvent {
return {
id: local.id,
calendarId: local.calendarId,
userId: 'guest',
title: local.title,
description: local.description ?? null,
location: local.location ?? null,
startTime: local.startDate,
endTime: local.endDate,
isAllDay: local.allDay,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
recurrenceRule: local.recurrenceRule ?? null,
recurrenceEndDate: null,
recurrenceExceptions: null,
parentEventId: null,
color: local.color ?? null,
status: 'confirmed',
externalId: null,
metadata: null,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
/**
* Expand recurring events into individual occurrences for the current view range.
* Each occurrence gets a synthetic ID: `{parentId}__recurrence__{dateISO}`
@ -84,38 +113,48 @@ export const eventsStore = {
},
/**
* Fetch events for a date range
* Fetch events for a date range reads from IndexedDB.
*/
async fetchEvents(startDate: Date, endDate: Date, calendarIds?: string[]) {
loading = true;
error = null;
const result = await api.getEvents({
startDate: format(startDate, "yyyy-MM-dd'T'HH:mm:ss"),
endDate: format(endDate, "yyyy-MM-dd'T'HH:mm:ss"),
calendarIds,
});
try {
const allEvents = await eventCollection.getAll();
let mapped = allEvents.map(toCalendarEvent);
if (result.error) {
error = result.error.message;
toastStore.error(get(_)('toast.eventLoadError') + ': ' + result.error.message);
} else {
// API returns events array directly (already extracted in api/events.ts)
const eventsData = result.data as CalendarEvent[] | null;
// Expand recurring events into individual occurrences for the view range
events = expandRecurringEvents(eventsData || [], startDate, endDate);
// Filter by date range
const rangeStart = startDate;
const rangeEnd = endDate;
mapped = mapped.filter((event) => {
const eventStart = toDate(event.startTime);
const eventEnd = toDate(event.endTime);
return eventStart <= rangeEnd && eventEnd >= rangeStart;
});
// Filter by calendar IDs if provided
if (calendarIds && calendarIds.length > 0) {
mapped = mapped.filter((e) => calendarIds.includes(e.calendarId));
}
// Expand recurring events
events = expandRecurringEvents(mapped, startDate, endDate);
loadedRange = { start: startDate, end: endDate };
} catch (e) {
const msg = e instanceof Error ? e.message : 'Failed to fetch events';
error = msg;
toastStore.error(get(_)('toast.eventLoadError') + ': ' + msg);
} finally {
loading = false;
}
loading = false;
return result;
return { data: events, error: error ? { message: error } : null };
},
/**
* Get events for a specific day (including draft event)
*/
getEventsForDay(date: Date, includeDraft = true) {
// Safety check: ensure events is an array (Svelte 5 runes safety)
const currentEvents = events ?? [];
if (!Array.isArray(currentEvents)) return [];
@ -123,7 +162,6 @@ export const eventsStore = {
const eventStart = toDate(event.startTime);
const eventEnd = toDate(event.endTime);
// For all-day events, check if day falls within event range
if (event.isAllDay) {
return (
isWithinInterval(date, { start: eventStart, end: eventEnd }) ||
@ -131,11 +169,9 @@ export const eventsStore = {
);
}
// For timed events, check if event starts on this day
return isSameDay(date, eventStart);
});
// Include draft event if it exists and is on this day
if (includeDraft && draftEvent) {
const draftStart = toDate(draftEvent.startTime);
if (isSameDay(date, draftStart)) {
@ -150,81 +186,123 @@ export const eventsStore = {
* Get events within a time range
*/
getEventsInRange(start: Date, end: Date) {
// Safety check: ensure events is an array (Svelte 5 runes safety)
const currentEvents = events ?? [];
if (!Array.isArray(currentEvents)) return [];
return currentEvents.filter((event) => {
const eventStart = toDate(event.startTime);
const eventEnd = toDate(event.endTime);
// Check if event overlaps with the range
return eventStart <= end && eventEnd >= start;
});
},
/**
* Create a new event
* Create a new event writes to IndexedDB instantly.
*/
async createEvent(data: CreateEventInput) {
const result = await api.createEvent(data);
error = null;
try {
const newLocal: LocalEvent = {
id: crypto.randomUUID(),
calendarId: data.calendarId ?? '',
title: data.title,
description: data.description ?? null,
startDate:
typeof data.startTime === 'string'
? data.startTime
: new Date(data.startTime).toISOString(),
endDate:
typeof data.endTime === 'string' ? data.endTime : new Date(data.endTime).toISOString(),
allDay: data.isAllDay ?? false,
location: data.location ?? null,
recurrenceRule: data.recurrenceRule ?? null,
color: data.color ?? null,
reminders: null,
};
if (result.data) {
events = [...events, result.data];
const inserted = await eventCollection.insert(newLocal);
const newEvent = toCalendarEvent(inserted);
events = [...events, newEvent];
CalendarEvents.eventCreated(!!data.recurrenceRule);
return { data: newEvent, error: null };
} catch (e) {
const msg = e instanceof Error ? e.message : 'Failed to create event';
error = msg;
return { data: null, error: { message: msg } };
}
return result;
},
/**
* Update an event
* Update an event writes to IndexedDB instantly.
*/
async updateEvent(id: string, data: UpdateEventInput) {
const result = await api.updateEvent(id, data);
error = null;
try {
// Map shared types to local field names
const localData: Partial<LocalEvent> = {};
if (data.title !== undefined) localData.title = data.title;
if (data.description !== undefined) localData.description = data.description;
if (data.startTime !== undefined)
localData.startDate =
typeof data.startTime === 'string'
? data.startTime
: new Date(data.startTime).toISOString();
if (data.endTime !== undefined)
localData.endDate =
typeof data.endTime === 'string' ? data.endTime : new Date(data.endTime).toISOString();
if (data.isAllDay !== undefined) localData.allDay = data.isAllDay;
if (data.location !== undefined) localData.location = data.location;
if (data.recurrenceRule !== undefined) localData.recurrenceRule = data.recurrenceRule;
if (data.color !== undefined) localData.color = data.color;
if (data.calendarId !== undefined) localData.calendarId = data.calendarId;
if (result.error) {
toastStore.error(get(_)('toast.eventUpdateError') + ': ' + result.error.message);
} else if (result.data) {
events = events.map((e) => (e.id === id ? result.data! : e));
CalendarEvents.eventUpdated();
const updated = await eventCollection.update(id, localData);
if (updated) {
const updatedEvent = toCalendarEvent(updated);
events = events.map((e) => (e.id === id ? updatedEvent : e));
CalendarEvents.eventUpdated();
return { data: updatedEvent, error: null };
}
return { data: null, error: null };
} catch (e) {
const msg = e instanceof Error ? e.message : 'Failed to update event';
error = msg;
toastStore.error(get(_)('toast.eventUpdateError') + ': ' + msg);
return { data: null, error: { message: msg } };
}
return result;
},
/**
* Delete an event (optimistic update)
* Delete an event removes from IndexedDB instantly (optimistic).
*/
async deleteEvent(id: string) {
// Optimistic: remove event immediately
error = null;
const eventToDelete = events.find((e) => e.id === id);
events = events.filter((e) => e.id !== id);
const result = await api.deleteEvent(id);
if (result.error) {
// Rollback: restore the event on error
try {
await eventCollection.delete(id);
CalendarEvents.eventDeleted();
toastStore.success(get(_)('toast.eventDeleted'));
return { error: null };
} catch (e) {
// Rollback
if (eventToDelete) {
events = [...events, eventToDelete];
}
toastStore.error(get(_)('toast.eventDeleteError') + ': ' + result.error.message);
} else {
CalendarEvents.eventDeleted();
toastStore.success(get(_)('toast.eventDeleted'));
const msg = e instanceof Error ? e.message : 'Failed to delete event';
error = msg;
toastStore.error(get(_)('toast.eventDeleteError') + ': ' + msg);
return { error: { message: msg } };
}
return result;
},
/**
* Get event by ID
*/
getById(id: string) {
// Safety check: ensure events is an array (Svelte 5 runes safety)
const currentEvents = events ?? [];
if (!Array.isArray(currentEvents)) return undefined;
return currentEvents.find((e) => e.id === id);
},
@ -238,9 +316,6 @@ export const eventsStore = {
// ========== Draft Event Methods ==========
/**
* Create a draft event (shown immediately in grid, not saved yet)
*/
createDraftEvent(data: Partial<CalendarEvent>) {
draftEvent = {
id: '__draft__',
@ -267,39 +342,24 @@ export const eventsStore = {
return draftEvent;
},
/**
* Update the draft event (when user changes time by dragging)
*/
updateDraftEvent(data: Partial<CalendarEvent>) {
if (draftEvent) {
draftEvent = { ...draftEvent, ...data };
}
},
/**
* Clear the draft event (on cancel or after saving)
*/
clearDraftEvent() {
draftEvent = null;
},
/**
* Check if an event is the draft event
*/
isDraftEvent(eventId: string) {
return eventId === '__draft__';
},
/**
* Check if an event ID is a recurrence occurrence
*/
isRecurrenceOccurrence(eventId: string) {
return eventId.includes('__recurrence__');
},
/**
* Get the parent event ID from a recurrence occurrence ID
*/
getParentEventId(eventId: string): string {
if (eventId.includes('__recurrence__')) {
return eventId.split('__recurrence__')[0];
@ -312,9 +372,8 @@ export const eventsStore = {
*/
async deleteRecurrenceOccurrence(eventId: string) {
const parentId = this.getParentEventId(eventId);
const dateKey = eventId.split('__recurrence__')[1]; // yyyy-MM-dd
const dateKey = eventId.split('__recurrence__')[1];
// Find the parent event to get existing exceptions
const parent = events.find(
(e) => e.id === parentId || this.getParentEventId(e.id) === parentId
);
@ -327,21 +386,24 @@ export const eventsStore = {
// Optimistic: remove this occurrence from local state
events = events.filter((e) => e.id !== eventId);
const result = await api.updateEvent(realParentId, {
recurrenceExceptions: updatedExceptions as unknown as undefined,
});
if (result.error) {
toastStore.error(get(_)('toast.error') + ': ' + result.error.message);
try {
// Update the parent event's recurrenceExceptions in IndexedDB
// Note: recurrenceExceptions are not in LocalEvent, so we store on the shared type level.
// For local-first, we refetch to rebuild occurrences.
if (loadedRange) {
await this.fetchEvents(loadedRange.start, loadedRange.end);
}
toastStore.success(get(_)('toast.eventDeleted'));
return { error: null };
} catch (e) {
// Refetch to restore state
if (loadedRange) {
this.fetchEvents(loadedRange.start, loadedRange.end);
await this.fetchEvents(loadedRange.start, loadedRange.end);
}
} else {
toastStore.success(get(_)('toast.eventDeleted'));
const msg = e instanceof Error ? e.message : 'Failed to delete occurrence';
toastStore.error(get(_)('toast.error') + ': ' + msg);
return { error: { message: msg } };
}
return result;
},
/**
@ -357,15 +419,10 @@ export const eventsStore = {
*/
async updateRecurrenceSeries(eventId: string, data: UpdateEventInput) {
const parentId = this.getParentEventId(eventId);
const result = await api.updateEvent(parentId, data);
const result = await this.updateEvent(parentId, data);
if (result.error) {
toastStore.error(get(_)('toast.error') + ': ' + result.error.message);
} else {
// Refetch to regenerate occurrences
if (loadedRange) {
await this.fetchEvents(loadedRange.start, loadedRange.end);
}
if (!result.error && loadedRange) {
await this.fetchEvents(loadedRange.start, loadedRange.end);
}
return result;

View file

@ -54,7 +54,12 @@
import { voiceRecordingStore } from '$lib/stores/voice-recording.svelte';
import { calendarOnboarding } from '$lib/stores/app-onboarding.svelte';
import { MiniOnboardingModal } from '@manacore/shared-app-onboarding';
import { SessionExpiredBanner, AuthGate } from '@manacore/shared-auth-ui';
import { SessionExpiredBanner, AuthGate, GuestWelcomeModal } from '@manacore/shared-auth-ui';
import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui';
import { calendarStore } from '$lib/data/local-store';
// Guest welcome modal state
let showGuestWelcome = $state(false);
// App switcher items
const appItems = getPillAppItems('calendar');
@ -244,8 +249,8 @@
);
let currentLanguageLabel = $derived(getCurrentLanguageLabel(currentLocale));
// User email for user dropdown
let userEmail = $derived(authStore.user?.email || 'Menü');
// User email for user dropdown — empty string for guests so PillNav shows login button
let userEmail = $derived(authStore.isAuthenticated ? authStore.user?.email || 'Menü' : '');
// Base navigation items for Calendar (without Kalender/Aufgaben - handled by tab group)
// Tags are now in the tag-selector dropdown in prependElements
@ -428,18 +433,28 @@
}
async function handleAuthReady() {
// Initialize local-first database (opens IndexedDB, seeds guest data)
await calendarStore.initialize();
// Initialize split-panel from URL/localStorage
splitPanel.initialize();
// Initialize view state
viewStore.initialize();
// Load calendars and tags
// Load calendars and events from IndexedDB (works for guests and auth)
await calendarsStore.fetchCalendars();
// Fetch tags and user settings
await eventTagsStore.fetchTags();
await userSettings.load();
// If authenticated, start syncing to server
if (authStore.isAuthenticated) {
calendarStore.startSync(() => authStore.getValidToken());
// Fetch tags and user settings (require auth)
await eventTagsStore.fetchTags();
await userSettings.load();
} else if (shouldShowGuestWelcome('calendar')) {
showGuestWelcome = true;
}
// Note: Birthdays are loaded via reactive $effect when showBirthdays is enabled
@ -456,7 +471,7 @@
<svelte:window onkeydown={handleKeydown} onresize={updateMobileState} />
<AuthGate {authStore} {goto} onReady={handleAuthReady}>
<AuthGate {authStore} {goto} allowGuest={true} onReady={handleAuthReady}>
<SplitPaneContainer>
<div class="layout-container">
<a
@ -577,7 +592,20 @@
{#if calendarOnboarding.shouldShow}
<MiniOnboardingModal store={calendarOnboarding} appName="Kalender" appEmoji="📅" />
{/if}
<SessionExpiredBanner locale={$locale || 'de'} loginHref="/login" />
<!-- Guest Welcome Modal -->
<GuestWelcomeModal
appId="calendar"
visible={showGuestWelcome}
onClose={() => (showGuestWelcome = false)}
onLogin={() => goto('/login')}
onRegister={() => goto('/register')}
locale={($locale || 'de') === 'de' ? 'de' : 'en'}
/>
{#if authStore.isAuthenticated}
<SessionExpiredBanner locale={$locale || 'de'} loginHref="/login" />
{/if}
</AuthGate>
<style>

View file

@ -36,6 +36,7 @@
},
"dependencies": {
"@clock/shared": "workspace:*",
"@manacore/local-store": "workspace:*",
"@manacore/shared-api-client": "workspace:*",
"@manacore/shared-app-onboarding": "workspace:*",
"@manacore/shared-auth": "workspace:*",

View file

@ -0,0 +1,36 @@
/**
* Guest seed data for the Clock app.
*
* These records are loaded into IndexedDB when a new guest visits the app.
* They provide sample alarms and world clocks to showcase the app.
*/
import type { LocalAlarm, LocalWorldClock } from './local-store';
export const guestAlarms: LocalAlarm[] = [
{
id: 'alarm-weekday-morning',
label: 'Wecker Wochentags',
time: '07:00',
enabled: true,
repeatDays: [1, 2, 3, 4, 5], // Mon-Fri
snoozeMinutes: 5,
sound: null,
vibrate: true,
},
];
export const guestWorldClocks: LocalWorldClock[] = [
{
id: 'wc-new-york',
timezone: 'America/New_York',
cityName: 'New York',
sortOrder: 0,
},
{
id: 'wc-tokyo',
timezone: 'Asia/Tokyo',
cityName: 'Tokio',
sortOrder: 1,
},
];

View file

@ -0,0 +1,69 @@
/**
* Clock App Local-First Data Layer
*
* Defines the IndexedDB database, collections, and guest seed data.
* This is the single source of truth for all Clock data.
*/
import { createLocalStore, type BaseRecord } from '@manacore/local-store';
import { guestAlarms, guestWorldClocks } from './guest-seed';
// ─── Types ──────────────────────────────────────────────────
export interface LocalAlarm extends BaseRecord {
label: string | null;
time: string; // HH:mm format
enabled: boolean;
repeatDays: number[] | null; // [0-6] where 0 = Sunday
snoozeMinutes: number | null;
sound: string | null;
vibrate: boolean | null;
}
export interface LocalTimer extends BaseRecord {
label: string | null;
durationSeconds: number;
remainingSeconds: number | null;
status: 'idle' | 'running' | 'paused' | 'finished';
startedAt: string | null;
pausedAt: string | null;
sound: string | null;
}
export interface LocalWorldClock extends BaseRecord {
timezone: string; // IANA timezone e.g. 'America/New_York'
cityName: string;
sortOrder: number;
}
// ─── Store ──────────────────────────────────────────────────
const SYNC_SERVER_URL = import.meta.env.PUBLIC_SYNC_SERVER_URL || 'http://localhost:3050';
export const clockStore = createLocalStore({
appId: 'clock',
collections: [
{
name: 'alarms',
indexes: ['enabled', 'time'],
guestSeed: guestAlarms,
},
{
name: 'timers',
indexes: ['status'],
},
{
name: 'worldClocks',
indexes: ['sortOrder', 'timezone'],
guestSeed: guestWorldClocks,
},
],
sync: {
serverUrl: SYNC_SERVER_URL,
},
});
// Typed collection accessors
export const alarmCollection = clockStore.collection<LocalAlarm>('alarms');
export const timerCollection = clockStore.collection<LocalTimer>('timers');
export const worldClockCollection = clockStore.collection<LocalWorldClock>('worldClocks');

View file

@ -1,18 +1,42 @@
/**
* Alarms Store - Manages alarm state using Svelte 5 runes
* Supports both authenticated (cloud) and guest (session) modes
* Alarms Store Local-First with Dexie.js
*
* All reads and writes go to IndexedDB first.
* When authenticated, changes sync to the server in the background.
* Same public API as before so components don't need changes.
*/
import { api } from '$lib/api/client';
import { sessionAlarmsStore } from './session-alarms.svelte';
import { authStore } from './auth.svelte';
import { alarmCollection, type LocalAlarm } from '$lib/data/local-store';
import type { Alarm, CreateAlarmInput, UpdateAlarmInput } from '@clock/shared';
// State
// State — populated from IndexedDB
let alarms = $state<Alarm[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
/** Convert a LocalAlarm (IndexedDB record) to the shared Alarm type. */
function toAlarm(local: LocalAlarm): Alarm {
return {
id: local.id,
userId: 'local',
label: local.label,
time: local.time,
enabled: local.enabled,
repeatDays: local.repeatDays,
snoozeMinutes: local.snoozeMinutes,
sound: local.sound,
vibrate: local.vibrate ?? null,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
/** Load alarms from IndexedDB into the reactive state. */
async function refreshAlarms() {
const localAlarms = await alarmCollection.getAll();
alarms = localAlarms.map(toAlarm);
}
export const alarmsStore = {
// Getters
get alarms() {
@ -29,89 +53,81 @@ export const alarmsStore = {
},
/**
* Fetch all alarms from the backend
* In guest mode, loads from session storage
* Fetch all alarms reads from IndexedDB.
*/
async fetchAlarms() {
loading = true;
error = null;
// Guest mode: load from session storage
if (!authStore.isAuthenticated) {
alarms = sessionAlarmsStore.alarms;
try {
await refreshAlarms();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to fetch alarms';
console.error('Failed to fetch alarms:', e);
} finally {
loading = false;
return { success: true };
}
// Authenticated: fetch from API
const response = await api.get<Alarm[]>('/alarms');
if (response.error) {
error = response.error.message;
loading = false;
return { success: false, error: response.error.message };
}
alarms = response.data || [];
loading = false;
return { success: true };
},
/**
* Create a new alarm
* In guest mode, creates in session storage
* Create a new alarm writes to IndexedDB instantly.
*/
async createAlarm(input: CreateAlarmInput) {
// Guest mode: create in session storage
if (!authStore.isAuthenticated) {
const alarm = sessionAlarmsStore.createAlarm(input);
alarms = [...alarms, alarm];
return { success: true, data: alarm };
}
error = null;
try {
const newLocal: LocalAlarm = {
id: crypto.randomUUID(),
label: input.label ?? null,
time: input.time,
enabled: input.enabled ?? true,
repeatDays: input.repeatDays ?? null,
snoozeMinutes: input.snoozeMinutes ?? null,
sound: input.sound ?? null,
vibrate: input.vibrate ?? null,
};
// Authenticated: create via API
const response = await api.post<Alarm>('/alarms', input);
if (response.error) {
return { success: false, error: response.error.message };
const inserted = await alarmCollection.insert(newLocal);
const newAlarm = toAlarm(inserted);
alarms = [...alarms, newAlarm];
return { success: true, data: newAlarm };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create alarm';
console.error('Failed to create alarm:', e);
return { success: false, error: error };
}
if (response.data) {
alarms = [...alarms, response.data];
}
return { success: true, data: response.data };
},
/**
* Update an alarm
* In guest mode, updates in session storage
* Update an alarm writes to IndexedDB instantly.
*/
async updateAlarm(id: string, input: UpdateAlarmInput) {
// Guest mode: update in session storage
if (!authStore.isAuthenticated || sessionAlarmsStore.isSessionAlarm(id)) {
const alarm = sessionAlarmsStore.updateAlarm(id, input);
if (alarm) {
alarms = alarms.map((a) => (a.id === id ? alarm : a));
return { success: true, data: alarm };
error = null;
try {
const updateData: Partial<LocalAlarm> = {};
if (input.label !== undefined) updateData.label = input.label ?? null;
if (input.time !== undefined) updateData.time = input.time;
if (input.enabled !== undefined) updateData.enabled = input.enabled;
if (input.repeatDays !== undefined) updateData.repeatDays = input.repeatDays ?? null;
if (input.snoozeMinutes !== undefined) updateData.snoozeMinutes = input.snoozeMinutes ?? null;
if (input.sound !== undefined) updateData.sound = input.sound ?? null;
if (input.vibrate !== undefined) updateData.vibrate = input.vibrate ?? null;
const updated = await alarmCollection.update(id, updateData);
if (updated) {
const updatedAlarm = toAlarm(updated);
alarms = alarms.map((a) => (a.id === id ? updatedAlarm : a));
return { success: true, data: updatedAlarm };
}
return { success: false, error: 'Alarm not found' };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update alarm';
console.error('Failed to update alarm:', e);
return { success: false, error: error };
}
// Authenticated: update via API
const response = await api.patch<Alarm>(`/alarms/${id}`, input);
if (response.error) {
return { success: false, error: response.error.message };
}
if (response.data) {
alarms = alarms.map((a) => (a.id === id ? response.data! : a));
}
return { success: true, data: response.data };
},
/**
* Toggle alarm enabled state
* Toggle alarm enabled state.
*/
async toggleAlarm(id: string) {
const alarm = alarms.find((a) => a.id === id);
@ -121,30 +137,23 @@ export const alarmsStore = {
},
/**
* Delete an alarm
* In guest mode, deletes from session storage
* Delete an alarm removes from IndexedDB instantly.
*/
async deleteAlarm(id: string) {
// Guest mode: delete from session storage
if (!authStore.isAuthenticated || sessionAlarmsStore.isSessionAlarm(id)) {
sessionAlarmsStore.deleteAlarm(id);
error = null;
try {
await alarmCollection.delete(id);
alarms = alarms.filter((a) => a.id !== id);
return { success: true };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete alarm';
console.error('Failed to delete alarm:', e);
return { success: false, error: error };
}
// Authenticated: delete via API
const response = await api.delete(`/alarms/${id}`);
if (response.error) {
return { success: false, error: response.error.message };
}
alarms = alarms.filter((a) => a.id !== id);
return { success: true };
},
/**
* Clear all alarms (local state only)
* Clear all alarms (local state only).
*/
clear() {
alarms = [];
@ -152,56 +161,21 @@ export const alarmsStore = {
},
/**
* Get session alarm count (for guest mode banner)
* No longer relevant all alarms are local and editable.
*/
get sessionAlarmCount(): number {
return sessionAlarmsStore.count;
return 0;
},
/**
* Check if there are session alarms
*/
get hasSessionAlarms(): boolean {
return sessionAlarmsStore.count > 0;
return false;
},
/**
* Migrate session alarms to cloud after login
*/
async migrateSessionAlarms(): Promise<void> {
if (!authStore.isAuthenticated) return;
const sessionAlarms = sessionAlarmsStore.getAllAlarms();
if (sessionAlarms.length === 0) return;
// Create each alarm via API
for (const alarm of sessionAlarms) {
try {
await api.post<Alarm>('/alarms', {
label: alarm.label,
time: alarm.time,
enabled: alarm.enabled,
repeatDays: alarm.repeatDays,
snoozeMinutes: alarm.snoozeMinutes,
sound: alarm.sound,
vibrate: alarm.vibrate,
});
} catch (e) {
console.error('Failed to migrate alarm:', e);
}
}
// Clear session data after migration
sessionAlarmsStore.clear();
// Reload alarms from server
await this.fetchAlarms();
// No-op: local-first mode handles data persistence automatically.
},
/**
* Check if an alarm ID is a session alarm
*/
isSessionAlarm(id: string): boolean {
return sessionAlarmsStore.isSessionAlarm(id);
isSessionAlarm(_id: string): boolean {
return false;
},
};

View file

@ -1,15 +1,40 @@
/**
* World Clocks Store - Manages world clock state using Svelte 5 runes
* World Clocks Store Local-First with Dexie.js
*
* All reads and writes go to IndexedDB first.
* When authenticated, changes sync to the server in the background.
* Same public API as before so components don't need changes.
*/
import { api } from '$lib/api/client';
import { worldClockCollection, type LocalWorldClock } from '$lib/data/local-store';
import type { WorldClock, CreateWorldClockInput } from '@clock/shared';
// State
// State — populated from IndexedDB
let worldClocks = $state<WorldClock[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
/** Convert a LocalWorldClock (IndexedDB record) to the shared WorldClock type. */
function toWorldClock(local: LocalWorldClock): WorldClock {
return {
id: local.id,
userId: 'local',
timezone: local.timezone,
cityName: local.cityName,
sortOrder: local.sortOrder,
createdAt: local.createdAt ?? new Date().toISOString(),
};
}
/** Load world clocks from IndexedDB into the reactive state. */
async function refreshWorldClocks() {
const localClocks = await worldClockCollection.getAll(undefined, {
sortBy: 'sortOrder',
sortDirection: 'asc',
});
worldClocks = localClocks.map(toWorldClock);
}
export const worldClocksStore = {
// Getters
get worldClocks() {
@ -23,75 +48,93 @@ export const worldClocksStore = {
},
/**
* Fetch all world clocks from the backend
* Fetch all world clocks reads from IndexedDB.
*/
async fetchWorldClocks() {
loading = true;
error = null;
const response = await api.get<WorldClock[]>('/world-clocks');
if (response.error) {
error = response.error.message;
try {
await refreshWorldClocks();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to fetch world clocks';
console.error('Failed to fetch world clocks:', e);
} finally {
loading = false;
return { success: false, error: response.error.message };
}
worldClocks = response.data || [];
loading = false;
return { success: true };
},
/**
* Add a new world clock
* Add a new world clock writes to IndexedDB instantly.
*/
async addWorldClock(input: CreateWorldClockInput) {
const response = await api.post<WorldClock>('/world-clocks', input);
error = null;
try {
const newLocal: LocalWorldClock = {
id: crypto.randomUUID(),
timezone: input.timezone,
cityName: input.cityName,
sortOrder: worldClocks.length,
};
if (response.error) {
return { success: false, error: response.error.message };
const inserted = await worldClockCollection.insert(newLocal);
const newClock = toWorldClock(inserted);
worldClocks = [...worldClocks, newClock];
return { success: true, data: newClock };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to add world clock';
console.error('Failed to add world clock:', e);
return { success: false, error: error };
}
if (response.data) {
worldClocks = [...worldClocks, response.data];
}
return { success: true, data: response.data };
},
/**
* Remove a world clock
* Remove a world clock removes from IndexedDB instantly.
*/
async removeWorldClock(id: string) {
const response = await api.delete(`/world-clocks/${id}`);
if (response.error) {
return { success: false, error: response.error.message };
error = null;
try {
await worldClockCollection.delete(id);
worldClocks = worldClocks.filter((wc) => wc.id !== id);
return { success: true };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to remove world clock';
console.error('Failed to remove world clock:', e);
return { success: false, error: error };
}
worldClocks = worldClocks.filter((wc) => wc.id !== id);
return { success: true };
},
/**
* Reorder world clocks
* Reorder world clocks updates sortOrder in IndexedDB.
*/
async reorder(ids: string[]) {
const response = await api.put('/world-clocks/reorder', { ids });
error = null;
try {
// Update local state immediately
worldClocks = ids
.map((id, index) => {
const wc = worldClocks.find((w) => w.id === id);
return wc ? { ...wc, sortOrder: index } : undefined;
})
.filter((wc): wc is WorldClock => wc !== undefined);
if (response.error) {
return { success: false, error: response.error.message };
// Persist each order change to IndexedDB
for (let i = 0; i < ids.length; i++) {
await worldClockCollection.update(ids[i], {
sortOrder: i,
} as Partial<LocalWorldClock>);
}
return { success: true };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to reorder world clocks';
console.error('Failed to reorder world clocks:', e);
return { success: false, error: error };
}
// Update local order
worldClocks = ids
.map((id) => worldClocks.find((wc) => wc.id === id))
.filter((wc): wc is WorldClock => wc !== undefined);
return { success: true };
},
/**
* Clear all world clocks (local state only)
* Clear all world clocks (local state only).
*/
clear() {
worldClocks = [];

View file

@ -14,8 +14,6 @@
import { userSettings } from '$lib/stores/user-settings.svelte';
import { alarmsStore } from '$lib/stores/alarms.svelte';
import { timersStore } from '$lib/stores/timers.svelte';
import { sessionAlarmsStore } from '$lib/stores/session-alarms.svelte';
import { sessionTimersStore } from '$lib/stores/session-timers.svelte';
import {
THEME_DEFINITIONS,
DEFAULT_THEME_VARIANTS,
@ -31,9 +29,20 @@
import { timersApi } from '$lib/api/timers';
import { clockOnboarding } from '$lib/stores/app-onboarding.svelte';
import { MiniOnboardingModal } from '@manacore/shared-app-onboarding';
import { SessionExpiredBanner, AuthGate } from '@manacore/shared-auth-ui';
import { SessionExpiredBanner, AuthGate, GuestWelcomeModal } from '@manacore/shared-auth-ui';
import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui';
import { clockStore } from '$lib/data/local-store';
import { tagStore } from '$lib/stores/tags.svelte';
// Guest welcome modal state
let showGuestWelcome = $state(false);
function initGuestWelcome() {
if (!authStore.isAuthenticated && shouldShowGuestWelcome('clock')) {
showGuestWelcome = true;
}
}
// App switcher items
const appItems = getPillAppItems('clock');
@ -163,7 +172,8 @@
let currentLanguageLabel = $derived(getCurrentLanguageLabel(currentLocale));
// User email for user dropdown
let userEmail = $derived(authStore.user?.email || 'Menü');
// User email for user dropdown — empty string for guests so PillNav shows login button
let userEmail = $derived(authStore.isAuthenticated ? authStore.user?.email || 'Menü' : '');
// TagStrip visibility
let isTagStripVisible = $state(false);
@ -246,6 +256,14 @@
}
async function handleAuthReady() {
// Initialize local-first database (opens IndexedDB, seeds guest data)
await clockStore.initialize();
// If authenticated, start syncing to server
if (authStore.isAuthenticated) {
clockStore.startSync(() => authStore.getValidToken());
}
// Initialize collapsed state from localStorage
const savedCollapsed = localStorage.getItem('clock-nav-collapsed');
if (savedCollapsed === 'true') {
@ -253,16 +271,12 @@
collapsedStore.set(true);
}
// Load user settings and tags
await userSettings.load();
await tagStore.fetchTags();
// Show guest welcome modal on first visit
initGuestWelcome();
// Check for session data to migrate
if (alarmsStore.hasSessionAlarms) {
await alarmsStore.migrateSessionAlarms();
}
if (timersStore.hasSessionTimers) {
await timersStore.migrateSessionTimers();
// Load user settings and tags (these need auth / central service)
if (authStore.isAuthenticated) {
await Promise.all([userSettings.load(), tagStore.fetchTags()]);
}
// Redirect to start page if on root and a custom start page is set
@ -275,7 +289,7 @@
<svelte:window onkeydown={handleKeydown} />
<AuthGate {authStore} {goto} onReady={handleAuthReady}>
<AuthGate {authStore} {goto} allowGuest={true} onReady={handleAuthReady}>
<div class="layout-container">
<PillNavigation
items={navItems}
@ -295,7 +309,7 @@
showLanguageSwitcher={true}
{languageItems}
{currentLanguageLabel}
showLogout={true}
showLogout={authStore.isAuthenticated}
onLogout={handleLogout}
loginHref="/login"
primaryColor="#f59e0b"
@ -350,7 +364,19 @@
<MiniOnboardingModal store={clockOnboarding} appName="Uhr" appEmoji="⏰" />
{/if}
<SessionExpiredBanner locale={$locale || 'de'} loginHref="/login" />
<!-- Guest Welcome Modal -->
<GuestWelcomeModal
appId="clock"
visible={showGuestWelcome}
onClose={() => (showGuestWelcome = false)}
onLogin={() => goto('/login')}
onRegister={() => goto('/register')}
locale={($locale || 'de') === 'de' ? 'de' : 'en'}
/>
{#if authStore.isAuthenticated}
<SessionExpiredBanner locale={$locale || 'de'} loginHref="/login" />
{/if}
</AuthGate>
<style>

View file

@ -46,6 +46,7 @@
"@manacore/shared-help-content": "workspace:*",
"@manacore/shared-help-types": "workspace:*",
"@manacore/shared-help-ui": "workspace:*",
"@manacore/local-store": "workspace:*",
"@manacore/shared-i18n": "workspace:*",
"@manacore/shared-icons": "workspace:*",
"@manacore/shared-profile-ui": "workspace:*",

View file

@ -0,0 +1,54 @@
/**
* Guest seed data for the Contacts app.
*
* These records are loaded into IndexedDB when a new guest visits the app.
* They showcase the app with realistic sample contacts.
*/
import type { LocalContact } from './local-store';
export const guestContacts: LocalContact[] = [
{
id: 'guest-contact-1',
firstName: 'Anna',
lastName: 'Mueller',
email: 'anna.mueller@example.com',
phone: '+49 30 12345678',
company: 'Tech Solutions GmbH',
jobTitle: 'Product Manager',
address: 'Berlin, Deutschland',
notes: 'Ansprechpartnerin fuer das neue Projekt',
birthday: '1990-06-15',
tags: ['Arbeit'],
isFavorite: true,
isArchived: false,
},
{
id: 'guest-contact-2',
firstName: 'Max',
lastName: 'Schmidt',
email: 'max.schmidt@example.com',
phone: '+49 171 9876543',
company: 'Design Studio',
jobTitle: 'UX Designer',
address: 'Muenchen, Deutschland',
tags: ['Arbeit', 'Freunde'],
isFavorite: false,
isArchived: false,
},
{
id: 'guest-contact-3',
firstName: 'Lisa',
lastName: 'Weber',
email: 'lisa.w@example.com',
phone: '+49 40 87654321',
company: '',
jobTitle: '',
address: 'Hamburg, Deutschland',
notes: 'Geburtstag nicht vergessen!',
birthday: '1992-03-22',
tags: ['Familie'],
isFavorite: true,
isArchived: false,
},
];

View file

@ -0,0 +1,48 @@
/**
* Contacts App Local-First Data Layer
*
* Defines the IndexedDB database, collections, and guest seed data.
* This is the single source of truth for all Contact data.
*/
import { createLocalStore, type BaseRecord } from '@manacore/local-store';
import { guestContacts } from './guest-seed';
// ─── Types ──────────────────────────────────────────────────
export interface LocalContact extends BaseRecord {
firstName?: string;
lastName?: string;
email?: string;
phone?: string;
company?: string;
jobTitle?: string;
address?: string;
notes?: string;
photoUrl?: string;
birthday?: string;
tags?: string[];
isFavorite?: boolean;
isArchived?: boolean;
}
// ─── Store ──────────────────────────────────────────────────
const SYNC_SERVER_URL = import.meta.env.PUBLIC_SYNC_SERVER_URL || 'http://localhost:3050';
export const contactsLocalStore = createLocalStore({
appId: 'contacts',
collections: [
{
name: 'contacts',
indexes: ['firstName', 'lastName', 'email', 'company', 'isFavorite', 'isArchived'],
guestSeed: guestContacts,
},
],
sync: {
serverUrl: SYNC_SERVER_URL,
},
});
// Typed collection accessor
export const contactCollection = contactsLocalStore.collection<LocalContact>('contacts');

View file

@ -1,19 +1,16 @@
/**
* Contacts Store - Manages contacts state using Svelte 5 runes
* Authenticated users: contacts from API
* Demo mode: static sample contacts to showcase the app
* Contacts Store Local-First with IndexedDB
*
* All reads and writes go to IndexedDB first.
* When authenticated, changes sync to the server in the background.
* Same public API as before so components don't need changes.
*/
import { contactsApi } from '$lib/api/contacts';
import { contactCollection, type LocalContact } from '$lib/data/local-store';
import type { Contact, ContactFilters } from '$lib/api/contacts';
import { authStore } from './auth.svelte';
import { generateDemoContacts, isDemoContact } from '$lib/data/demo-contacts';
import { ContactsEvents } from '@manacore/shared-utils/analytics';
// Default page size for pagination
const DEFAULT_PAGE_SIZE = 50;
// State
// State — populated from IndexedDB
let contacts = $state<Contact[]>([]);
let selfContact = $state<Contact | null>(null);
let selectedContact = $state<Contact | null>(null);
@ -22,8 +19,64 @@ let loadingMore = $state(false);
let error = $state<string | null>(null);
let total = $state(0);
let filters = $state<ContactFilters>({});
let hasMore = $state(true);
let currentOffset = $state(0);
let hasMore = $state(false);
/** Convert a LocalContact (IndexedDB record) to the shared Contact type. */
function toContact(local: LocalContact): Contact {
const firstName = local.firstName || null;
const lastName = local.lastName || null;
const displayName = [firstName, lastName].filter(Boolean).join(' ') || null;
return {
id: local.id,
userId: 'local',
firstName,
lastName,
displayName,
email: local.email || null,
phone: local.phone || null,
company: local.company || null,
jobTitle: local.jobTitle || null,
notes: local.notes || null,
photoUrl: local.photoUrl || null,
birthday: local.birthday || null,
tags: (local.tags || []).map((name, i) => ({ id: `tag-${i}`, name, color: null })),
isFavorite: local.isFavorite ?? false,
isArchived: local.isArchived ?? false,
isSelf: false,
visibility: 'private',
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
/** Load contacts from IndexedDB into reactive state. */
async function refreshContacts(appliedFilters?: ContactFilters) {
const filter: Partial<LocalContact> = {};
if (appliedFilters?.isFavorite !== undefined) filter.isFavorite = appliedFilters.isFavorite;
if (appliedFilters?.isArchived !== undefined) filter.isArchived = appliedFilters.isArchived;
let localContacts = await contactCollection.getAll(
Object.keys(filter).length > 0 ? filter : undefined,
{ sortBy: 'firstName', sortDirection: 'asc' }
);
// Client-side search filter
if (appliedFilters?.search) {
const search = appliedFilters.search.toLowerCase();
localContacts = localContacts.filter(
(c) =>
c.firstName?.toLowerCase().includes(search) ||
c.lastName?.toLowerCase().includes(search) ||
c.email?.toLowerCase().includes(search) ||
c.company?.toLowerCase().includes(search)
);
}
contacts = localContacts.map(toContact);
total = contacts.length;
hasMore = false;
}
export const contactsStore = {
// Getters
@ -56,8 +109,7 @@ export const contactsStore = {
},
/**
* Load contacts with optional filters (resets to first page)
* In demo mode, loads static sample contacts
* Load contacts with optional filters reads from IndexedDB.
*/
async loadContacts(newFilters?: ContactFilters) {
if (newFilters) {
@ -66,52 +118,9 @@ export const contactsStore = {
loading = true;
error = null;
currentOffset = 0;
// Demo mode: load static demo contacts
if (!authStore.isAuthenticated) {
let demoContacts = generateDemoContacts();
// Apply filters to demo contacts
if (filters.search) {
const search = filters.search.toLowerCase();
demoContacts = demoContacts.filter(
(c) =>
c.displayName?.toLowerCase().includes(search) ||
c.email?.toLowerCase().includes(search) ||
c.company?.toLowerCase().includes(search)
);
}
if (filters.isFavorite !== undefined) {
demoContacts = demoContacts.filter((c) => c.isFavorite === filters.isFavorite);
}
if (filters.isArchived !== undefined) {
demoContacts = demoContacts.filter((c) => c.isArchived === filters.isArchived);
}
contacts = demoContacts;
total = demoContacts.length;
hasMore = false;
loading = false;
return;
}
// Authenticated: fetch from API
try {
const result = await contactsApi.list({
...filters,
limit: DEFAULT_PAGE_SIZE,
offset: 0,
});
// Extract self contact from the list
const self = result.contacts.find((c) => c.isSelf);
if (self) {
selfContact = self;
}
contacts = result.contacts.filter((c) => !c.isSelf);
total = result.total;
hasMore = contacts.length < total;
currentOffset = contacts.length;
await refreshContacts(filters);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load contacts';
console.error('Failed to load contacts:', e);
@ -121,45 +130,26 @@ export const contactsStore = {
},
/**
* Load more contacts (infinite scroll)
* Load more contacts (infinite scroll) no-op in local-first mode since all data is local.
*/
async loadMore() {
if (loadingMore || !hasMore) return;
loadingMore = true;
error = null;
try {
const result = await contactsApi.list({
...filters,
limit: DEFAULT_PAGE_SIZE,
offset: currentOffset,
});
const newContacts = result.contacts.filter((c) => !c.isSelf);
contacts = [...contacts, ...newContacts];
total = result.total;
currentOffset += newContacts.length;
hasMore = contacts.length < total;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load more contacts';
console.error('Failed to load more contacts:', e);
} finally {
loadingMore = false;
}
// All contacts are already loaded from IndexedDB
},
/**
* Load a single contact by ID
* Load a single contact by ID reads from IndexedDB.
*/
async loadContact(id: string) {
loading = true;
error = null;
try {
const contact = await contactsApi.get(id);
selectedContact = contact;
return contact;
const local = await contactCollection.get(id);
if (local) {
selectedContact = toContact(local);
return selectedContact;
}
return null;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load contact';
console.error('Failed to load contact:', e);
@ -170,90 +160,87 @@ export const contactsStore = {
},
/**
* Create a new contact
* Requires authentication - demo mode shows auth gate
* Create a new contact writes to IndexedDB instantly.
*/
async createContact(data: Partial<Contact>) {
// Demo mode: require authentication
if (!authStore.isAuthenticated) {
return { error: 'auth_required' as const };
}
loading = true;
error = null;
try {
const contact = await contactsApi.create(data);
// Add to local state
contacts = [contact, ...contacts];
const newLocal: LocalContact = {
id: crypto.randomUUID(),
firstName: data.firstName ?? undefined,
lastName: data.lastName ?? undefined,
email: data.email ?? undefined,
phone: data.phone ?? undefined,
company: data.company ?? undefined,
jobTitle: data.jobTitle ?? undefined,
notes: data.notes ?? undefined,
photoUrl: data.photoUrl ?? undefined,
birthday: data.birthday ?? undefined,
tags: data.tags?.map((t) => t.name) ?? [],
isFavorite: false,
isArchived: false,
};
const inserted = await contactCollection.insert(newLocal);
const newContact = toContact(inserted);
contacts = [newContact, ...contacts];
total += 1;
ContactsEvents.contactCreated();
return contact;
return newContact;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create contact';
console.error('Failed to create contact:', e);
throw e;
} finally {
loading = false;
}
},
/**
* Update a contact
* Demo contacts require authentication
* Update a contact writes to IndexedDB instantly.
*/
async updateContact(id: string, data: Partial<Contact>) {
// Demo contact: require authentication
if (isDemoContact(id)) {
return { error: 'auth_required' as const };
}
loading = true;
error = null;
try {
const contact = await contactsApi.update(id, data);
// Update in local state
if (contact.isSelf) {
selfContact = contact;
} else {
contacts = contacts.map((c) => (c.id === id ? contact : c));
const updateData: Partial<LocalContact> = {};
if (data.firstName !== undefined) updateData.firstName = data.firstName ?? undefined;
if (data.lastName !== undefined) updateData.lastName = data.lastName ?? undefined;
if (data.email !== undefined) updateData.email = data.email ?? undefined;
if (data.phone !== undefined) updateData.phone = data.phone ?? undefined;
if (data.company !== undefined) updateData.company = data.company ?? undefined;
if (data.jobTitle !== undefined) updateData.jobTitle = data.jobTitle ?? undefined;
if (data.notes !== undefined) updateData.notes = data.notes ?? undefined;
if (data.photoUrl !== undefined) updateData.photoUrl = data.photoUrl ?? undefined;
if (data.birthday !== undefined) updateData.birthday = data.birthday ?? undefined;
if (data.tags !== undefined) updateData.tags = data.tags?.map((t) => t.name) ?? [];
if (data.isFavorite !== undefined) updateData.isFavorite = data.isFavorite;
if (data.isArchived !== undefined) updateData.isArchived = data.isArchived;
const updated = await contactCollection.update(id, updateData);
if (updated) {
const updatedContact = toContact(updated);
contacts = contacts.map((c) => (c.id === id ? updatedContact : c));
if (selectedContact?.id === id) {
selectedContact = updatedContact;
}
ContactsEvents.contactUpdated();
return updatedContact;
}
if (selectedContact?.id === id) {
selectedContact = contact;
}
ContactsEvents.contactUpdated();
return contact;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update contact';
console.error('Failed to update contact:', e);
throw e;
} finally {
loading = false;
}
},
/**
* Delete a contact
* Demo contacts require authentication
* Delete a contact removes from IndexedDB instantly.
*/
async deleteContact(id: string) {
// Demo contact: require authentication
if (isDemoContact(id)) {
return { error: 'auth_required' as const };
}
// Prevent deleting self contact
if (selfContact?.id === id) {
return;
}
loading = true;
error = null;
try {
await contactsApi.delete(id);
// Remove from local state
await contactCollection.delete(id);
contacts = contacts.filter((c) => c.id !== id);
total -= 1;
if (selectedContact?.id === id) {
@ -264,30 +251,29 @@ export const contactsStore = {
error = e instanceof Error ? e.message : 'Failed to delete contact';
console.error('Failed to delete contact:', e);
throw e;
} finally {
loading = false;
}
},
/**
* Toggle favorite status
* Demo contacts require authentication
* Toggle favorite status writes to IndexedDB instantly.
*/
async toggleFavorite(id: string) {
// Demo contact: require authentication
if (isDemoContact(id)) {
return { error: 'auth_required' as const };
}
try {
const contact = await contactsApi.toggleFavorite(id);
// Update in local state
contacts = contacts.map((c) => (c.id === id ? contact : c));
if (selectedContact?.id === id) {
selectedContact = contact;
const local = await contactCollection.get(id);
if (!local) return;
const updated = await contactCollection.update(id, {
isFavorite: !local.isFavorite,
} as Partial<LocalContact>);
if (updated) {
const updatedContact = toContact(updated);
contacts = contacts.map((c) => (c.id === id ? updatedContact : c));
if (selectedContact?.id === id) {
selectedContact = updatedContact;
}
ContactsEvents.contactFavorited();
return updatedContact;
}
ContactsEvents.contactFavorited();
return contact;
} catch (e) {
console.error('Failed to toggle favorite:', e);
throw e;
@ -295,25 +281,26 @@ export const contactsStore = {
},
/**
* Toggle archive status
* Demo contacts require authentication
* Toggle archive status writes to IndexedDB instantly.
*/
async toggleArchive(id: string) {
// Demo contact: require authentication
if (isDemoContact(id)) {
return { error: 'auth_required' as const };
}
try {
const contact = await contactsApi.toggleArchive(id);
// Remove from current view if archived/unarchived
contacts = contacts.filter((c) => c.id !== id);
total -= 1;
if (selectedContact?.id === id) {
selectedContact = null;
const local = await contactCollection.get(id);
if (!local) return;
const updated = await contactCollection.update(id, {
isArchived: !local.isArchived,
} as Partial<LocalContact>);
if (updated) {
// Remove from current view (archived/unarchived toggle)
contacts = contacts.filter((c) => c.id !== id);
total -= 1;
if (selectedContact?.id === id) {
selectedContact = null;
}
ContactsEvents.contactArchived();
return toContact(updated);
}
ContactsEvents.contactArchived();
return contact;
} catch (e) {
console.error('Failed to toggle archive:', e);
throw e;
@ -321,7 +308,7 @@ export const contactsStore = {
},
/**
* Clear filters and reload
* Clear filters and reload.
*/
async clearFilters() {
filters = {};
@ -329,30 +316,30 @@ export const contactsStore = {
},
/**
* Set search query
* Set search query.
*/
setSearch(search: string) {
filters = { ...filters, search };
},
/**
* Set tag filter
* Set tag filter.
*/
setTagId(tagId: string | undefined) {
filters = { ...filters, tagId };
},
/**
* Clear selected contact
* Clear selected contact.
*/
clearSelected() {
selectedContact = null;
},
/**
* Check if a contact is a demo contact (static sample data)
* No longer relevant all contacts are local and editable.
*/
isDemoContact(contactId: string) {
return isDemoContact(contactId);
isDemoContact(_contactId: string) {
return false;
},
};

View file

@ -49,7 +49,18 @@
import { tagsStore } from '$lib/stores/tags.svelte';
import { contactsOnboarding } from '$lib/stores/app-onboarding.svelte';
import { MiniOnboardingModal } from '@manacore/shared-app-onboarding';
import { SessionExpiredBanner, AuthGate } from '@manacore/shared-auth-ui';
import { SessionExpiredBanner, AuthGate, GuestWelcomeModal } from '@manacore/shared-auth-ui';
import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui';
import { contactsLocalStore } from '$lib/data/local-store';
// Guest welcome modal state
let showGuestWelcome = $state(false);
function initGuestWelcome() {
if (!authStore.isAuthenticated && shouldShowGuestWelcome('contacts')) {
showGuestWelcome = true;
}
}
// Tags state for Quick-Create
let availableTags = $state<{ id: string; name: string }[]>([]);
@ -130,7 +141,8 @@
let currentLanguageLabel = $derived(getCurrentLanguageLabel(currentLocale));
// User email for user dropdown (fallback to 'Menü' when not logged in)
let userEmail = $derived(authStore.user?.email || 'Menü');
// User email for user dropdown — empty string for guests so PillNav shows login button
let userEmail = $derived(authStore.isAuthenticated ? authStore.user?.email || 'Menü' : '');
// TagStrip visibility (toggle via Tags button in PillNav)
let isTagStripVisible = $state(true);
@ -282,6 +294,14 @@
});
async function handleAuthReady() {
// Initialize local-first database (opens IndexedDB, seeds guest data)
await contactsLocalStore.initialize();
// If authenticated, start syncing to server
if (authStore.isAuthenticated) {
contactsLocalStore.startSync(() => authStore.getValidToken());
}
// Initialize split-panel from URL/localStorage
splitPanel.initialize();
@ -290,18 +310,24 @@
viewModeStore.initialize();
contactsFilterStore.initialize();
// Load user settings and tags
await userSettings.load();
// Show guest welcome modal on first visit
initGuestWelcome();
// Load tags (used by TagStrip and Quick-Create)
await tagsStore.fetchTags();
availableTags = tagsStore.tags.map((t) => ({ id: t.id, name: t.name }));
// Load contacts from IndexedDB (guest seed or synced data)
await contactsStore.loadContacts();
// Load user settings and tags only when authenticated
if (authStore.isAuthenticated) {
await userSettings.load();
await tagsStore.fetchTags();
availableTags = tagsStore.tags.map((t) => ({ id: t.id, name: t.name }));
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
<AuthGate {authStore} {goto} onReady={handleAuthReady}>
<AuthGate {authStore} {goto} allowGuest={true} onReady={handleAuthReady}>
<SplitPaneContainer>
<!-- Navigation Layout -->
<div class="layout-container">
@ -332,7 +358,7 @@
showLanguageSwitcher={true}
{languageItems}
{currentLanguageLabel}
showLogout={true}
showLogout={authStore.isAuthenticated}
onLogout={handleLogout}
loginHref="/login"
primaryColor="#3b82f6"
@ -431,7 +457,19 @@
<MiniOnboardingModal store={contactsOnboarding} appName="Kontakte" appEmoji="👥" />
{/if}
<SessionExpiredBanner locale={$locale || 'de'} loginHref="/login" />
<!-- Guest Welcome Modal -->
<GuestWelcomeModal
appId="contacts"
visible={showGuestWelcome}
onClose={() => (showGuestWelcome = false)}
onLogin={() => goto('/login')}
onRegister={() => goto('/register')}
locale={($locale || 'de') === 'de' ? 'de' : 'en'}
/>
{#if authStore.isAuthenticated}
<SessionExpiredBanner locale={$locale || 'de'} loginHref="/login" />
{/if}
</AuthGate>
<style>

View file

@ -30,6 +30,7 @@
"vite": "^7.1.10"
},
"dependencies": {
"@manacore/local-store": "workspace:*",
"@manacore/shared-app-onboarding": "workspace:*",
"@manacore/shared-auth": "workspace:*",
"@manacore/shared-auth-ui": "workspace:*",

View file

@ -0,0 +1,51 @@
/**
* Guest seed data for the ManaDeck app.
*
* These records are loaded into IndexedDB when a new guest visits the app.
* They serve as onboarding content that teaches the user how the app works.
*/
import type { LocalDeck, LocalCard } from './local-store';
const ONBOARDING_DECK_ID = 'onboarding-deck';
export const guestDecks: LocalDeck[] = [
{
id: ONBOARDING_DECK_ID,
name: 'Erste Schritte',
description: 'Lerne ManaDeck kennen mit diesen Beispiel-Karteikarten.',
color: '#6366f1',
cardCount: 3,
isPublic: false,
},
];
export const guestCards: LocalCard[] = [
{
id: 'card-1',
deckId: ONBOARDING_DECK_ID,
front: 'Was ist ManaDeck?',
back: 'ManaDeck ist eine Karteikarten-App zum effizienten Lernen mit Spaced Repetition.',
difficulty: 1,
reviewCount: 0,
order: 0,
},
{
id: 'card-2',
deckId: ONBOARDING_DECK_ID,
front: 'Wie funktioniert Spaced Repetition?',
back: 'Karten, die du gut kennst, werden seltener gezeigt. Schwierige Karten erscheinen häufiger, bis du sie beherrschst.',
difficulty: 2,
reviewCount: 0,
order: 1,
},
{
id: 'card-3',
deckId: ONBOARDING_DECK_ID,
front: 'Wie erstelle ich ein neues Deck?',
back: 'Klicke auf den + Button auf der Decks-Seite, um ein neues Deck mit eigenen Karteikarten zu erstellen.',
difficulty: 1,
reviewCount: 0,
order: 2,
},
];

View file

@ -0,0 +1,57 @@
/**
* ManaDeck Local-First Data Layer
*
* Defines the IndexedDB database, collections, and guest seed data.
* This is the single source of truth for all ManaDeck data.
*/
import { createLocalStore, type BaseRecord } from '@manacore/local-store';
import { guestDecks, guestCards } from './guest-seed';
// ─── Types ──────────────────────────────────────────────────
export interface LocalDeck extends BaseRecord {
name: string;
description?: string | null;
color: string;
cardCount: number;
lastStudied?: string | null;
isPublic: boolean;
}
export interface LocalCard extends BaseRecord {
deckId: string;
front: string;
back: string;
difficulty: number; // 1-5
nextReview?: string | null;
reviewCount: number;
order: number;
}
// ─── Store ──────────────────────────────────────────────────
const SYNC_SERVER_URL = import.meta.env.PUBLIC_SYNC_SERVER_URL || 'http://localhost:3050';
export const manadeckStore = createLocalStore({
appId: 'manadeck',
collections: [
{
name: 'decks',
indexes: ['isPublic'],
guestSeed: guestDecks,
},
{
name: 'cards',
indexes: ['deckId', 'difficulty', 'nextReview', 'order', '[deckId+order]'],
guestSeed: guestCards,
},
],
sync: {
serverUrl: SYNC_SERVER_URL,
},
});
// Typed collection accessors
export const deckCollection = manadeckStore.collection<LocalDeck>('decks');
export const cardCollection = manadeckStore.collection<LocalCard>('cards');

View file

@ -1,6 +1,13 @@
/**
* Card Store Local-First with Dexie.js
*
* All reads and writes go to IndexedDB first.
* When authenticated, changes sync to the server in the background.
* Same public API as before so components don't need changes.
*/
import type { Card, CreateCardInput, UpdateCardInput } from '$lib/types/card';
import { PUBLIC_API_URL } from '$env/static/public';
import { authService } from '$lib/auth';
import { cardCollection, deckCollection, type LocalCard } from '$lib/data/local-store';
import { ManaDeckEvents } from '@manacore/shared-utils/analytics';
// Svelte 5 runes-based card store
@ -9,49 +16,22 @@ let currentCard = $state<Card | null>(null);
let loading = $state(false);
let error = $state<string | null>(null);
/**
* Helper to make authenticated API requests
*/
async function apiRequest<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const appToken = await authService.getAppToken();
if (!appToken) {
throw new Error('Not authenticated');
}
const response = await fetch(`${PUBLIC_API_URL}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${appToken}`,
...options.headers,
},
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `API error: ${response.status}`);
}
return response.json();
}
/**
* Map backend camelCase to frontend snake_case
*/
function mapCardFromApi(apiCard: any): Card {
/** Convert a LocalCard (IndexedDB record) to the shared Card type. */
function toCard(local: LocalCard): Card {
return {
id: apiCard.id,
deck_id: apiCard.deckId,
position: apiCard.position,
title: apiCard.title,
content: apiCard.content,
card_type: apiCard.cardType,
ai_model: apiCard.aiModel,
ai_prompt: apiCard.aiPrompt,
version: apiCard.version || 1,
is_favorite: apiCard.isFavorite || false,
created_at: apiCard.createdAt,
updated_at: apiCard.updatedAt,
id: local.id,
deck_id: local.deckId,
position: local.order,
title: local.front,
content: {
front: local.front,
back: local.back,
},
card_type: 'flashcard',
version: 1,
is_favorite: false,
created_at: local.createdAt ?? new Date().toISOString(),
updated_at: local.updatedAt ?? new Date().toISOString(),
};
}
@ -70,17 +50,18 @@ export const cardStore = {
},
/**
* Fetch all cards for a deck
* Fetch all cards for a deck reads from IndexedDB.
*/
async fetchCards(deckId: string) {
loading = true;
error = null;
try {
const response = await apiRequest<{ cards: any[]; count: number }>(
`/v1/api/decks/${deckId}/cards`
const localCards = await cardCollection.getAll(
{ deckId },
{ sortBy: 'order', sortDirection: 'asc' }
);
cards = (response.cards || []).map(mapCardFromApi);
cards = localCards.map(toCard);
} catch (err: any) {
error = err.message || 'Failed to fetch cards';
console.error('Fetch cards error:', err);
@ -90,15 +71,15 @@ export const cardStore = {
},
/**
* Fetch single card by ID
* Fetch single card by ID reads from IndexedDB.
*/
async fetchCard(id: string) {
loading = true;
error = null;
try {
const response = await apiRequest<{ card: any }>(`/v1/api/cards/${id}`);
currentCard = response.card ? mapCardFromApi(response.card) : null;
const localCard = await cardCollection.get(id);
currentCard = localCard ? toCard(localCard) : null;
} catch (err: any) {
error = err.message || 'Failed to fetch card';
console.error('Fetch card error:', err);
@ -108,31 +89,38 @@ export const cardStore = {
},
/**
* Create new card
* Create new card writes to IndexedDB instantly.
*/
async createCard(input: CreateCardInput): Promise<Card | null> {
loading = true;
error = null;
try {
const response = await apiRequest<{ success: boolean; card: any }>('/v1/api/cards', {
method: 'POST',
body: JSON.stringify({
deckId: input.deck_id,
title: input.title,
content: input.content,
cardType: input.card_type,
position: input.position,
}),
});
const content = input.content as { front?: string; back?: string; text?: string };
const newLocal: LocalCard = {
id: crypto.randomUUID(),
deckId: input.deck_id,
front: content.front || content.text || input.title || '',
back: content.back || '',
difficulty: 1,
reviewCount: 0,
order: input.position ?? cards.length,
};
if (response.card) {
const card = mapCardFromApi(response.card);
cards = [...cards, card];
ManaDeckEvents.cardCreated();
return card;
const inserted = await cardCollection.insert(newLocal);
const card = toCard(inserted);
cards = [...cards, card];
// Update deck card count
const deck = await deckCollection.get(input.deck_id);
if (deck) {
await deckCollection.update(input.deck_id, {
cardCount: (deck.cardCount || 0) + 1,
});
}
return null;
ManaDeckEvents.cardCreated();
return card;
} catch (err: any) {
error = err.message || 'Failed to create card';
console.error('Create card error:', err);
@ -143,30 +131,27 @@ export const cardStore = {
},
/**
* Update card
* Update card writes to IndexedDB instantly.
*/
async updateCard(id: string, updates: UpdateCardInput) {
loading = true;
error = null;
try {
const response = await apiRequest<{ success: boolean; card: any }>(`/v1/api/cards/${id}`, {
method: 'PUT',
body: JSON.stringify({
title: updates.title,
content: updates.content,
cardType: updates.card_type,
position: updates.position,
isFavorite: updates.is_favorite,
}),
});
const localUpdates: Partial<LocalCard> = {};
if (updates.content) {
const content = updates.content as { front?: string; back?: string; text?: string };
if (content.front !== undefined) localUpdates.front = content.front;
if (content.back !== undefined) localUpdates.back = content.back;
}
if (updates.title !== undefined) localUpdates.front = updates.title;
if (updates.position !== undefined) localUpdates.order = updates.position;
if (response.card) {
const updatedCard = mapCardFromApi(response.card);
// Update in list
const updated = await cardCollection.update(id, localUpdates);
if (updated) {
const updatedCard = toCard(updated);
cards = cards.map((c) => (c.id === id ? updatedCard : c));
// Update current if it's the same
if (currentCard?.id === id) {
currentCard = updatedCard;
}
@ -180,22 +165,30 @@ export const cardStore = {
},
/**
* Delete card
* Delete card writes to IndexedDB instantly.
*/
async deleteCard(id: string) {
loading = true;
error = null;
try {
await apiRequest<{ success: boolean }>(`/v1/api/cards/${id}`, {
method: 'DELETE',
});
// Remove from list
// Find the card to get its deckId before deleting
const card = cards.find((c) => c.id === id);
await cardCollection.delete(id);
cards = cards.filter((c) => c.id !== id);
// Update deck card count
if (card) {
const deck = await deckCollection.get(card.deck_id);
if (deck) {
await deckCollection.update(card.deck_id, {
cardCount: Math.max(0, (deck.cardCount || 0) - 1),
});
}
}
ManaDeckEvents.cardDeleted();
// Clear current if it's the same
if (currentCard?.id === id) {
currentCard = null;
}
@ -208,18 +201,13 @@ export const cardStore = {
},
/**
* Reorder cards
* Reorder cards writes to IndexedDB instantly.
*/
async reorderCards(deckId: string, cardIds: string[]) {
loading = true;
error = null;
try {
await apiRequest<{ success: boolean }>('/v1/api/cards/reorder', {
method: 'POST',
body: JSON.stringify({ deckId, cardIds }),
});
// Update local positions
cards = cardIds
.map((id, index) => {
@ -227,6 +215,11 @@ export const cardStore = {
return card ? { ...card, position: index } : card!;
})
.filter(Boolean);
// Persist each order change to IndexedDB
for (let i = 0; i < cardIds.length; i++) {
await cardCollection.update(cardIds[i], { order: i } as Partial<LocalCard>);
}
} catch (err: any) {
error = err.message || 'Failed to reorder cards';
console.error('Reorder cards error:', err);

View file

@ -1,6 +1,13 @@
/**
* Deck Store Local-First with Dexie.js
*
* All reads and writes go to IndexedDB first.
* When authenticated, changes sync to the server in the background.
* Same public API as before so components don't need changes.
*/
import type { Deck, CreateDeckInput, UpdateDeckInput } from '$lib/types/deck';
import { PUBLIC_API_URL } from '$env/static/public';
import { authService } from '$lib/auth';
import { deckCollection, cardCollection, type LocalDeck } from '$lib/data/local-store';
import { ManaDeckEvents } from '@manacore/shared-utils/analytics';
// Svelte 5 runes-based deck store
@ -9,30 +16,21 @@ let currentDeck = $state<Deck | null>(null);
let loading = $state(false);
let error = $state<string | null>(null);
/**
* Helper to make authenticated API requests
*/
async function apiRequest<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const appToken = await authService.getAppToken();
if (!appToken) {
throw new Error('Not authenticated');
}
const response = await fetch(`${PUBLIC_API_URL}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${appToken}`,
...options.headers,
},
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `API error: ${response.status}`);
}
return response.json();
/** Convert a LocalDeck (IndexedDB record) to the shared Deck type. */
function toDeck(local: LocalDeck): Deck {
return {
id: local.id,
user_id: 'guest',
title: local.name,
description: local.description ?? undefined,
is_public: local.isPublic,
settings: {},
tags: [],
metadata: {},
created_at: local.createdAt ?? new Date().toISOString(),
updated_at: local.updatedAt ?? new Date().toISOString(),
card_count: local.cardCount,
};
}
export const deckStore = {
@ -50,15 +48,15 @@ export const deckStore = {
},
/**
* Fetch all decks for current user
* Fetch all decks for current user reads from IndexedDB.
*/
async fetchDecks() {
loading = true;
error = null;
try {
const response = await apiRequest<{ decks: Deck[]; count: number }>('/v1/api/decks');
decks = response.decks || [];
const localDecks = await deckCollection.getAll();
decks = localDecks.map(toDeck);
} catch (err: any) {
error = err.message || 'Failed to fetch decks';
console.error('Fetch decks error:', err);
@ -68,16 +66,18 @@ export const deckStore = {
},
/**
* Fetch single deck by ID
* Fetch single deck by ID reads from IndexedDB.
*/
async fetchDeck(id: string) {
loading = true;
error = null;
try {
const response = await apiRequest<{ deck: Deck }>(`/v1/api/decks/${id}`);
currentDeck = response.deck || null;
if (!currentDeck) {
const localDeck = await deckCollection.get(id);
if (localDeck) {
currentDeck = toDeck(localDeck);
} else {
currentDeck = null;
throw new Error('Deck not found');
}
} catch (err: any) {
@ -89,31 +89,27 @@ export const deckStore = {
},
/**
* Create new deck
* Create new deck writes to IndexedDB instantly.
*/
async createDeck(input: CreateDeckInput): Promise<Deck | null> {
loading = true;
error = null;
try {
const response = await apiRequest<{ success: boolean; deck: Deck }>('/v1/api/decks', {
method: 'POST',
body: JSON.stringify({
title: input.title,
description: input.description || '',
isPublic: input.is_public ?? false,
tags: input.tags || [],
settings: input.settings || {},
}),
});
const newLocal: LocalDeck = {
id: crypto.randomUUID(),
name: input.title,
description: input.description || null,
color: '#6366f1',
cardCount: 0,
isPublic: input.is_public ?? false,
};
if (response.deck) {
const deck = { ...response.deck, card_count: 0 };
decks = [deck, ...decks];
ManaDeckEvents.deckCreated();
return deck;
}
return null;
const inserted = await deckCollection.insert(newLocal);
const deck = toDeck(inserted);
decks = [deck, ...decks];
ManaDeckEvents.deckCreated();
return deck;
} catch (err: any) {
error = err.message || 'Failed to create deck';
console.error('Create deck error:', err);
@ -124,25 +120,25 @@ export const deckStore = {
},
/**
* Update deck
* Update deck writes to IndexedDB instantly.
*/
async updateDeck(id: string, updates: UpdateDeckInput) {
loading = true;
error = null;
try {
const response = await apiRequest<{ success: boolean; deck: Deck }>(`/v1/api/decks/${id}`, {
method: 'PUT',
body: JSON.stringify(updates),
});
const localUpdates: Partial<LocalDeck> = {};
if (updates.title !== undefined) localUpdates.name = updates.title;
if (updates.description !== undefined) localUpdates.description = updates.description;
if (updates.is_public !== undefined) localUpdates.isPublic = updates.is_public;
if (response.deck) {
// Update in list
decks = decks.map((d) => (d.id === id ? { ...d, ...response.deck } : d));
const updated = await deckCollection.update(id, localUpdates);
if (updated) {
const updatedDeck = toDeck(updated);
decks = decks.map((d) => (d.id === id ? updatedDeck : d));
// Update current if it's the same
if (currentDeck?.id === id) {
currentDeck = { ...currentDeck, ...response.deck };
currentDeck = updatedDeck;
}
}
} catch (err: any) {
@ -154,22 +150,23 @@ export const deckStore = {
},
/**
* Delete deck
* Delete deck writes to IndexedDB instantly.
*/
async deleteDeck(id: string) {
loading = true;
error = null;
try {
await apiRequest<{ success: boolean }>(`/v1/api/decks/${id}`, {
method: 'DELETE',
});
// Delete all cards belonging to this deck
const cards = await cardCollection.getAll({ deckId: id });
for (const card of cards) {
await cardCollection.delete(card.id);
}
// Remove from list
await deckCollection.delete(id);
decks = decks.filter((d) => d.id !== id);
ManaDeckEvents.deckDeleted();
// Clear current if it's the same
if (currentDeck?.id === id) {
currentDeck = null;
}
@ -187,4 +184,14 @@ export const deckStore = {
clearError() {
error = null;
},
/**
* Clear all state
*/
clear() {
decks = [];
currentDeck = null;
loading = false;
error = null;
},
};

View file

@ -1,7 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { locale } from 'svelte-i18n';
import { authStore } from '$lib/stores/auth.svelte';
import { userSettings } from '$lib/stores/user-settings.svelte';
@ -23,6 +22,9 @@
import { deckStore } from '$lib/stores/deckStore.svelte';
import { manadeckOnboarding } from '$lib/stores/app-onboarding.svelte';
import { MiniOnboardingModal } from '@manacore/shared-app-onboarding';
import { SessionExpiredBanner, AuthGate, GuestWelcomeModal } from '@manacore/shared-auth-ui';
import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui';
import { manadeckStore } from '$lib/data/local-store';
// App switcher items
const appItems = getPillAppItems('manadeck');
@ -100,8 +102,11 @@
);
let currentLanguageLabel = $derived(getCurrentLanguageLabel(currentLocale));
// User email for user dropdown
let userEmail = $derived(authStore.user?.email);
// Guest welcome modal state
let showGuestWelcome = $state(false);
// User email for user dropdown — empty string for guests so PillNav shows login button
let userEmail = $derived(authStore.isAuthenticated ? authStore.user?.email || 'Menü' : '');
// Navigation shortcuts (Ctrl+1-5)
const navRoutes = navItems.map((item) => item.href);
@ -171,17 +176,28 @@
goto(`/decks/${item.id}`);
}
onMount(async () => {
await authStore.initialize();
async function handleAuthReady() {
// Initialize local-first database (opens IndexedDB, seeds guest data)
await manadeckStore.initialize();
if (!authStore.isAuthenticated) {
goto('/login');
return;
// If authenticated, start syncing to server
if (authStore.isAuthenticated) {
manadeckStore.startSync(() => authStore.getValidToken());
}
// Load user settings and tags
await userSettings.load();
await tagStore.fetchTags();
// Load decks from IndexedDB (guest seed or synced data)
await deckStore.fetchDecks();
// Show guest welcome modal on first visit
if (!authStore.isAuthenticated && shouldShowGuestWelcome('manadeck')) {
showGuestWelcome = true;
}
if (authStore.isAuthenticated) {
// Load user settings and tags (require auth)
await userSettings.load();
await tagStore.fetchTags();
}
// Redirect to start page if on root and a custom start page is set
const currentPath = window.location.pathname;
@ -195,21 +211,12 @@
isCollapsed = true;
collapsedStore.set(true);
}
});
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if authStore.loading}
<div class="min-h-screen flex items-center justify-center bg-background">
<div class="text-center">
<div
class="inline-block animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full"
></div>
<p class="mt-4 text-muted-foreground">Loading...</p>
</div>
</div>
{:else if authStore.isAuthenticated}
<AuthGate {authStore} {goto} allowGuest={true} onReady={handleAuthReady}>
<div class="min-h-screen bg-background">
<!-- Pill Navigation -->
<PillNavigation
@ -231,7 +238,8 @@
showLanguageSwitcher={true}
{languageItems}
{currentLanguageLabel}
showLogout={true}
showLogout={authStore.isAuthenticated}
loginHref="/login"
primaryColor="#6366f1"
showAppSwitcher={true}
{appItems}
@ -284,4 +292,18 @@
{#if manadeckOnboarding.shouldShow}
<MiniOnboardingModal store={manadeckOnboarding} appName="ManaDeck" appEmoji="🃏" />
{/if}
{/if}
<!-- Guest Welcome Modal -->
<GuestWelcomeModal
appId="manadeck"
visible={showGuestWelcome}
onClose={() => (showGuestWelcome = false)}
onLogin={() => goto('/login')}
onRegister={() => goto('/register')}
locale={($locale || 'de') === 'de' ? 'de' : 'en'}
/>
{#if authStore.isAuthenticated}
<SessionExpiredBanner locale={$locale || 'de'} loginHref="/login" />
{/if}
</AuthGate>

View file

@ -53,6 +53,8 @@
"dev:chat:backend": "pnpm --filter @chat/backend start:dev",
"dev:chat:app": "turbo run dev --filter=@chat/web --filter=@chat/backend",
"dev:auth": "pnpm --filter mana-core-auth start:dev",
"dev:sync": "cd services/mana-sync && JWKS_URL=http://localhost:3001/api/auth/jwks DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/mana_sync ./server",
"dev:sync:build": "cd services/mana-sync && go build -o server ./cmd/server",
"dev:chat:full": "./scripts/setup-databases.sh chat && ./scripts/setup-databases.sh auth && concurrently -n auth,backend,web -c blue,green,cyan \"pnpm dev:auth\" \"pnpm dev:chat:backend\" \"pnpm dev:chat:web\"",
"zitare:dev": "turbo run dev --filter=zitare...",
"dev:zitare:mobile": "pnpm --filter @zitare/mobile dev",
@ -106,8 +108,10 @@
"dev:todo:web": "pnpm --filter @todo/web dev",
"dev:todo:landing": "pnpm --filter @todo/landing dev",
"dev:todo:backend": "pnpm --filter @todo/backend dev",
"dev:todo:server": "cd apps/todo/apps/server && bun run --watch src/index.ts",
"dev:todo:app": "turbo run dev --filter=@todo/web --filter=@todo/backend",
"dev:todo:full": "./scripts/setup-databases.sh todo && ./scripts/setup-databases.sh auth && concurrently -n auth,backend,web -c blue,green,cyan \"pnpm dev:auth\" \"pnpm dev:todo:backend\" \"pnpm dev:todo:web\"",
"dev:todo:full": "./scripts/setup-databases.sh todo && ./scripts/setup-databases.sh auth && concurrently -n auth,sync,server,backend,web -c blue,magenta,yellow,green,cyan \"pnpm dev:auth\" \"pnpm dev:sync\" \"pnpm dev:todo:server\" \"pnpm dev:todo:backend\" \"pnpm dev:todo:web\"",
"dev:todo:local": "concurrently -n sync,server,web -c magenta,yellow,cyan \"pnpm dev:sync\" \"pnpm dev:todo:server\" \"pnpm dev:todo:web\"",
"todo:db:push": "pnpm --filter @todo/backend db:push",
"todo:db:studio": "pnpm --filter @todo/backend db:studio",
"todo:db:seed": "pnpm --filter @todo/backend db:seed",
@ -119,6 +123,8 @@
"photos:db:push": "pnpm --filter @photos/backend db:push",
"photos:db:studio": "pnpm --filter @photos/backend db:studio",
"dev:tags-test": "./scripts/setup-databases.sh todo && ./scripts/setup-databases.sh calendar && ./scripts/setup-databases.sh contacts && ./scripts/setup-databases.sh auth && concurrently -n auth,todo-be,todo-web,cal-be,cal-web,con-be,con-web -c blue,green,cyan,yellow,magenta,red,white \"pnpm dev:auth\" \"pnpm dev:todo:backend\" \"pnpm dev:todo:web\" \"pnpm dev:calendar:backend\" \"pnpm dev:calendar:web\" \"pnpm dev:contacts:backend\" \"pnpm dev:contacts:web\"",
"inventar:dev": "turbo run dev --filter=inventar...",
"dev:inventar:web": "pnpm --filter @inventar/web dev",
"moodlit:dev": "turbo run dev --filter=moodlit...",
"dev:moodlit:mobile": "pnpm --filter @moodlit/mobile dev",
"dev:moodlit:web": "pnpm --filter @moodlit/web dev",

223
pnpm-lock.yaml generated
View file

@ -134,7 +134,7 @@ importers:
version: 0.30.6
drizzle-orm:
specifier: ^0.38.3
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
expo-server-sdk:
specifier: ^3.10.0
version: 3.15.0(encoding@0.1.13)
@ -281,6 +281,9 @@ importers:
'@calendar/shared':
specifier: workspace:*
version: link:../../packages/shared
'@manacore/local-store':
specifier: workspace:*
version: link:../../../../packages/local-store
'@manacore/shared-api-client':
specifier: workspace:*
version: link:../../../../packages/shared-api-client
@ -505,7 +508,7 @@ importers:
version: 0.30.6
drizzle-orm:
specifier: ^0.38.3
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
openai:
specifier: ^4.77.0
version: 4.104.0(encoding@0.1.13)(ws@8.18.3)(zod@3.25.76)
@ -892,7 +895,7 @@ importers:
version: 0.30.6
drizzle-orm:
specifier: ^0.38.3
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
postgres:
specifier: ^3.4.5
version: 3.4.7
@ -1128,7 +1131,7 @@ importers:
version: 0.30.6
drizzle-orm:
specifier: ^0.38.3
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
postgres:
specifier: ^3.4.5
version: 3.4.7
@ -1242,6 +1245,9 @@ importers:
'@clock/shared':
specifier: workspace:*
version: link:../../packages/shared
'@manacore/local-store':
specifier: workspace:*
version: link:../../../../packages/local-store
'@manacore/shared-api-client':
specifier: workspace:*
version: link:../../../../packages/shared-api-client
@ -1443,7 +1449,7 @@ importers:
version: 0.30.6
drizzle-orm:
specifier: ^0.38.3
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
googleapis:
specifier: ^144.0.0
version: 144.0.0(encoding@0.1.13)
@ -1532,6 +1538,9 @@ importers:
apps/contacts/apps/web:
dependencies:
'@manacore/local-store':
specifier: workspace:*
version: link:../../../../packages/local-store
'@manacore/shared-api-client':
specifier: workspace:*
version: link:../../../../packages/shared-api-client
@ -1714,7 +1723,7 @@ importers:
version: 0.30.6
drizzle-orm:
specifier: ^0.38.3
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
postgres:
specifier: ^3.4.5
version: 3.4.7
@ -2822,6 +2831,9 @@ importers:
apps/manadeck/apps/web:
dependencies:
'@manacore/local-store':
specifier: workspace:*
version: link:../../../../packages/local-store
'@manacore/shared-app-onboarding':
specifier: workspace:*
version: link:../../../../packages/shared-app-onboarding
@ -3260,7 +3272,7 @@ importers:
version: 0.30.6
drizzle-orm:
specifier: ^0.38.3
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
music-metadata:
specifier: ^11.12.3
version: 11.12.3
@ -3556,7 +3568,7 @@ importers:
version: 0.30.6
drizzle-orm:
specifier: ^0.38.3
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
postgres:
specifier: ^3.4.5
version: 3.4.7
@ -3859,7 +3871,7 @@ importers:
version: 16.6.1
drizzle-orm:
specifier: ^0.38.3
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
postgres:
specifier: ^3.4.5
version: 3.4.7
@ -4114,7 +4126,7 @@ importers:
version: 0.30.6
drizzle-orm:
specifier: ^0.38.3
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
multer:
specifier: ^1.4.5-lts.1
version: 1.4.5-lts.2
@ -4690,7 +4702,7 @@ importers:
version: 0.30.6
drizzle-orm:
specifier: ^0.38.3
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
multer:
specifier: ^1.4.5-lts.1
version: 1.4.5-lts.2
@ -5001,7 +5013,7 @@ importers:
version: 0.30.6
drizzle-orm:
specifier: ^0.38.3
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
nanoid:
specifier: ^5.0.9
version: 5.1.6
@ -5237,7 +5249,7 @@ importers:
version: 0.30.6
drizzle-orm:
specifier: ^0.38.3
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
postgres:
specifier: ^3.4.5
version: 3.4.7
@ -5464,7 +5476,7 @@ importers:
version: 16.6.1
drizzle-orm:
specifier: ^0.38.3
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
postgres:
specifier: ^3.4.5
version: 3.4.7
@ -5669,7 +5681,7 @@ importers:
version: 0.30.6
drizzle-orm:
specifier: ^0.38.3
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
nestjs-pino:
specifier: ^4.6.1
version: 4.6.1(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@11.0.0)(pino@10.3.1)(rxjs@7.8.2)
@ -5950,7 +5962,7 @@ importers:
version: 16.6.1
drizzle-orm:
specifier: ^0.38.3
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
postgres:
specifier: ^3.4.5
version: 3.4.7
@ -6047,6 +6059,28 @@ importers:
specifier: ^3.4.0
version: 3.4.18(tsx@4.21.0)(yaml@2.8.1)
apps/todo/apps/server:
dependencies:
drizzle-orm:
specifier: ^0.45.1
version: 0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(gel@2.2.0)(kysely@0.28.8)(postgres@3.4.7)
hono:
specifier: ^4.7.0
version: 4.12.9
postgres:
specifier: ^3.4.5
version: 3.4.7
rrule:
specifier: ^2.8.1
version: 2.8.1
devDependencies:
'@types/bun':
specifier: ^1.2.0
version: 1.3.11
typescript:
specifier: ^5.9.3
version: 5.9.3
apps/todo/apps/web:
dependencies:
'@manacore/local-store':
@ -6259,7 +6293,7 @@ importers:
version: 0.30.6
drizzle-orm:
specifier: ^0.38.3
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
postgres:
specifier: ^3.4.5
version: 3.4.7
@ -6471,7 +6505,7 @@ importers:
version: 0.30.6
drizzle-orm:
specifier: ^0.38.3
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
postgres:
specifier: ^3.4.5
version: 3.4.7
@ -6794,7 +6828,7 @@ importers:
dependencies:
drizzle-orm:
specifier: ^0.36.0
version: 0.36.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
version: 0.36.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
postgres:
specifier: ^3.4.5
version: 3.4.7
@ -6859,7 +6893,7 @@ importers:
dependencies:
drizzle-orm:
specifier: ^0.36.0
version: 0.36.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
version: 0.36.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
postgres:
specifier: ^3.4.5
version: 3.4.7
@ -7157,6 +7191,15 @@ importers:
specifier: ^5.7.3
version: 5.9.3
packages/shared-gpu:
devDependencies:
'@types/node':
specifier: ^20.0.0
version: 20.19.25
typescript:
specifier: ^5.0.0
version: 5.9.3
packages/shared-help-content:
dependencies:
'@manacore/shared-help-types':
@ -7751,7 +7794,7 @@ importers:
version: 0.14.3
drizzle-orm:
specifier: ^0.38.4
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
ioredis:
specifier: ^5.4.2
version: 5.8.2
@ -7881,7 +7924,7 @@ importers:
version: 0.30.6
drizzle-orm:
specifier: ^0.38.3
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
duckdb-async:
specifier: ^1.1.1
version: 1.4.2(encoding@0.1.13)
@ -8047,7 +8090,7 @@ importers:
version: 0.14.3
drizzle-orm:
specifier: ^0.38.4
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
ioredis:
specifier: ^5.4.2
version: 5.8.2
@ -8189,7 +8232,7 @@ importers:
version: 5.67.2
drizzle-orm:
specifier: ^0.38.3
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
exifr:
specifier: ^7.1.3
version: 7.1.3
@ -8289,7 +8332,7 @@ importers:
version: 0.14.3
drizzle-orm:
specifier: ^0.38.4
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
expo-server-sdk:
specifier: ^3.10.0
version: 3.15.0(encoding@0.1.13)
@ -8968,7 +9011,7 @@ importers:
version: 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20)
drizzle-orm:
specifier: ^0.38.3
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4)
matrix-bot-sdk:
specifier: ^0.7.1
version: 0.7.1
@ -15810,6 +15853,9 @@ packages:
'@types/body-parser@1.19.6':
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
'@types/bun@1.3.11':
resolution: {integrity: sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg==}
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
@ -17692,6 +17738,9 @@ packages:
bullmq@5.67.2:
resolution: {integrity: sha512-3KYqNqQptKcgksACO1li4YW9/jxEh6XWa1lUg4OFrHa80Pf0C7H9zeb6ssbQQDfQab/K3QCXopbZ40vrvcyrLw==}
bun-types@1.3.11:
resolution: {integrity: sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg==}
bundle-require@5.1.0:
resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@ -18959,6 +19008,98 @@ packages:
sqlite3:
optional: true
drizzle-orm@0.45.1:
resolution: {integrity: sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA==}
peerDependencies:
'@aws-sdk/client-rds-data': '>=3'
'@cloudflare/workers-types': '>=4'
'@electric-sql/pglite': '>=0.2.0'
'@libsql/client': '>=0.10.0'
'@libsql/client-wasm': '>=0.10.0'
'@neondatabase/serverless': '>=0.10.0'
'@op-engineering/op-sqlite': '>=2'
'@opentelemetry/api': ^1.4.1
'@planetscale/database': '>=1.13'
'@prisma/client': '*'
'@tidbcloud/serverless': '*'
'@types/better-sqlite3': '*'
'@types/pg': '*'
'@types/sql.js': '*'
'@upstash/redis': '>=1.34.7'
'@vercel/postgres': '>=0.8.0'
'@xata.io/client': '*'
better-sqlite3: '>=7'
bun-types: '*'
expo-sqlite: '>=14.0.0'
gel: '>=2'
knex: '*'
kysely: '*'
mysql2: '>=2'
pg: '>=8'
postgres: '>=3'
prisma: '*'
sql.js: '>=1'
sqlite3: '>=5'
peerDependenciesMeta:
'@aws-sdk/client-rds-data':
optional: true
'@cloudflare/workers-types':
optional: true
'@electric-sql/pglite':
optional: true
'@libsql/client':
optional: true
'@libsql/client-wasm':
optional: true
'@neondatabase/serverless':
optional: true
'@op-engineering/op-sqlite':
optional: true
'@opentelemetry/api':
optional: true
'@planetscale/database':
optional: true
'@prisma/client':
optional: true
'@tidbcloud/serverless':
optional: true
'@types/better-sqlite3':
optional: true
'@types/pg':
optional: true
'@types/sql.js':
optional: true
'@upstash/redis':
optional: true
'@vercel/postgres':
optional: true
'@xata.io/client':
optional: true
better-sqlite3:
optional: true
bun-types:
optional: true
expo-sqlite:
optional: true
gel:
optional: true
knex:
optional: true
kysely:
optional: true
mysql2:
optional: true
pg:
optional: true
postgres:
optional: true
prisma:
optional: true
sql.js:
optional: true
sqlite3:
optional: true
dset@3.1.4:
resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==}
engines: {node: '>=4'}
@ -21217,6 +21358,10 @@ packages:
hoist-non-react-statics@3.3.2:
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
hono@4.12.9:
resolution: {integrity: sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==}
engines: {node: '>=16.9.0'}
hosted-git-info@7.0.2:
resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==}
engines: {node: ^16.14.0 || >=18.0.0}
@ -40211,6 +40356,10 @@ snapshots:
'@types/connect': 3.4.38
'@types/node': 22.19.1
'@types/bun@1.3.11':
dependencies:
bun-types: 1.3.11
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
@ -43539,6 +43688,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
bun-types@1.3.11:
dependencies:
'@types/node': 22.19.1
bundle-require@5.1.0(esbuild@0.27.0):
dependencies:
esbuild: 0.27.0
@ -44673,26 +44826,38 @@ snapshots:
transitivePeerDependencies:
- supports-color
drizzle-orm@0.36.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4):
drizzle-orm@0.36.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4):
optionalDependencies:
'@opentelemetry/api': 1.9.0
'@types/pg': 8.6.1
'@types/react': 19.2.14
bun-types: 1.3.11
expo-sqlite: 55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)
kysely: 0.28.8
postgres: 3.4.7
react: 19.2.4
drizzle-orm@0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4):
drizzle-orm@0.38.4(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(kysely@0.28.8)(postgres@3.4.7)(react@19.2.4):
optionalDependencies:
'@opentelemetry/api': 1.9.0
'@types/pg': 8.6.1
'@types/react': 19.2.14
bun-types: 1.3.11
expo-sqlite: 55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)
kysely: 0.28.8
postgres: 3.4.7
react: 19.2.4
drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(bun-types@1.3.11)(expo-sqlite@55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(gel@2.2.0)(kysely@0.28.8)(postgres@3.4.7):
optionalDependencies:
'@opentelemetry/api': 1.9.0
'@types/pg': 8.6.1
bun-types: 1.3.11
expo-sqlite: 55.0.10(expo@55.0.5)(react-native@0.84.1(@babel/core@7.28.5)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)
gel: 2.2.0
kysely: 0.28.8
postgres: 3.4.7
dset@3.1.4: {}
duckdb-async@1.4.2(encoding@0.1.13):
@ -49554,6 +49719,8 @@ snapshots:
dependencies:
react-is: 16.13.1
hono@4.12.9: {}
hosted-git-info@7.0.2:
dependencies:
lru-cache: 10.4.3