chore(mana/web): pre-launch module cleanup — schema collapse, dead code, lazy search

Six independent pre-launch tidy-ups bundled because they all touch
the same module-layer surface and the larger commit reads more
clearly than six adjacent two-line PRs.

1. database.ts schema v1–v10 collapsed into a single canonical
   db.version(1). The system has no live users yet, so dropping the
   versioned migration history is the cheapest moment to do it.
   The post-collapse Dexie table set is provably identical to the
   pre-collapse state (asserted by module-registry.test.ts).
   Removed: EMOJI_TO_ICON map + v2 upgrade, v3 timeBlocks data
   migration (~250 LOC of one-shot code), versions 4-10.
   Also dropped the @deprecated `setApplyingServerChanges()` shim
   (replaced by `beginApplyingTables()` weeks ago, no callers).

2. LocalLabel @deprecated alias renamed to TaskTag in the todo
   module and all 11 consumers (board-views, ListView, DetailView,
   QuickAddTask, +page.svelte). The alias was annotated @deprecated
   but had eleven live consumers — exactly the worst kind of dead
   code, the one that grows accidental new consumers via autocomplete
   the longer it stays. Renamed to TaskTag rather than `Tag` to
   avoid colliding with the `Tag` icon from `@mana/shared-icons`.

3. labelsStore backward-compat alias deleted from todo/stores —
   pure dead code with zero consumers.

4. EMOJI_TO_ICON_MAP fallback in habits/queries removed. The
   constant only existed as the in-memory equivalent of the v2
   schema migration that was just deleted; once no record can have
   the old `emoji` field, the fallback can never fire.

5. useAllEvents() in calendar/queries removed. JSDoc itself called
   it out as "for backward compatibility with calendar-specific
   views" — zero external consumers, only the barrel referenced it.

6. $lib/stores/tags.svelte.ts re-export shim deleted. It was a
   20-line pure re-export from @mana/shared-stores with the explicit
   header "for backward compatibility with existing imports".
   Thirteen importers (todo/calendar/contacts/places/zitare ListView
   + DetailView, plus +layout.svelte and the calendar/contacts/tags
   route +page.svelte files) rewritten to import directly.

7. SearchRegistry got `registerLazy(appId, loader)` and the eleven
   per-app providers now register via dynamic `import()`. Spotlight
   search is opened on demand, so the eleven provider chunks stay
   out of the initial JS bundle until the user actually searches.
   Sister benefit: a search filtered to a single appId only loads
   that one provider.

The structural backbone for all of this — the per-module
`module.config.ts` files plus `module-registry.{ts,test.ts}` — was
committed earlier in 5d4123d2b.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-07 22:31:08 +02:00
parent 3a473897ec
commit 171fbd18be
29 changed files with 162 additions and 451 deletions

View file

@ -44,10 +44,17 @@ export {
export const db = new Dexie('mana');
// Single canonical schema. The pre-launch cleanup collapsed historical
// versions 110 into this one block — see docs/PRE_LAUNCH_CLEANUP.md for
// rationale. After the system goes live, any further schema change MUST
// be added as a new `db.version(N)` block; never edit this one.
db.version(1).stores({
// ─── Sync Infrastructure ───
// ─── Sync Infrastructure (local-only, NOT in SYNC_APP_MAP) ───
_pendingChanges: '++id, appId, collection, recordId, createdAt',
_syncMeta: '[appId+collection]',
_eventsTombstones: 'id, token, attempts, createdAt',
_activity:
'++id, createdAt, appId, collection, recordId, op, [appId+createdAt], [collection+recordId], userId',
// ─── Core / Mana (appId: 'mana') ───
userSettings: 'id, key',
@ -55,16 +62,19 @@ db.version(1).stores({
automations: 'id, sourceApp, targetApp, enabled, [sourceApp+sourceCollection]',
// ─── Todo (appId: 'todo') ───
// `scheduledBlockId` is the link to the unified timeBlocks table.
tasks:
'id, dueDate, isCompleted, priority, order, projectId, [isCompleted+order], [projectId+order]',
'id, dueDate, isCompleted, priority, order, projectId, scheduledBlockId, [isCompleted+order], [projectId+order]',
todoProjects: 'id, order, isArchived, isDefault',
taskLabels: 'id, taskId, labelId', // junction to globalTags (labelId = tagId)
reminders: 'id, taskId',
boardViews: 'id, order, groupBy',
// ─── Calendar (appId: 'calendar') ───
// Scheduling fields (startDate / endDate / allDay) live on the linked
// timeBlocks row, not on `events` itself — see time-blocks/service.ts.
calendars: 'id, isDefault, isVisible',
events: 'id, calendarId, startDate, endDate, allDay, [calendarId+startDate]',
events: 'id, calendarId, timeBlockId',
eventTags: 'id, eventId, tagId, [eventId+tagId]',
// ─── Contacts (appId: 'contacts') ───
@ -72,13 +82,13 @@ db.version(1).stores({
contactTags: 'id, contactId, tagId, [contactId+tagId]',
// ─── Chat (appId: 'chat') ───
conversations: 'id, isArchived, isPinned, spaceId, templateId',
conversations: 'id, isArchived, isPinned, spaceId, templateId, updatedAt',
messages: 'id, conversationId, sender, [conversationId+sender]',
chatTemplates: 'id, isDefault',
conversationTags: 'id, conversationId, tagId, [conversationId+tagId]',
// ─── Picture (appId: 'picture') ───
images: 'id, isFavorite, isPublic, isArchived, prompt',
images: 'id, isFavorite, isPublic, isArchived, prompt, updatedAt',
boards: 'id, isPublic',
boardItems: 'id, boardId, itemType, zIndex, [boardId+zIndex]',
imageTags: 'id, imageId, tagId, [imageId+tagId]', // junction to globalTags
@ -94,8 +104,8 @@ db.version(1).stores({
zitareListTags: 'id, listId, tagId, [listId+tagId]',
// ─── Music (appId: 'music') ───
songs: 'id, artist, album, genre, favorite, title',
mukkePlaylists: 'id, name',
songs: 'id, artist, album, genre, favorite, title, updatedAt',
mukkePlaylists: 'id, name, updatedAt',
playlistSongs: 'id, playlistId, songId, sortOrder, [playlistId+sortOrder]',
mukkeProjects: 'id, title, songId',
markers: 'id, beatId, type, sortOrder',
@ -107,7 +117,7 @@ db.version(1).stores({
fileTags: 'id, fileId, tagId, [fileId+tagId]', // junction to globalTags
// ─── Presi (appId: 'presi') ───
presiDecks: 'id, isPublic',
presiDecks: 'id, isPublic, updatedAt',
slides: 'id, deckId, order, [deckId+order]',
presiDeckTags: 'id, deckId, tagId, [deckId+tagId]',
@ -137,10 +147,11 @@ db.version(1).stores({
ccLocationTags: 'id, locationId, tagId, [locationId+tagId]',
// ─── Times (appId: 'times') ───
// Like calendar events, time entries store their scheduling on the
// linked timeBlocks row, not on the row itself.
timeClients: 'id, order, isArchived, shortCode',
timeProjects: 'id, clientId, isArchived, isBillable, guildId, visibility, order',
timeEntries:
'id, projectId, clientId, date, isRunning, [date+projectId], [date+clientId], guildId, visibility',
timeEntries: 'id, projectId, clientId, timeBlockId, guildId, visibility',
timeTemplates: 'id, usageCount, lastUsedAt, projectId',
timeSettings: 'id',
timeAlarms: 'id, enabled, time',
@ -150,7 +161,7 @@ db.version(1).stores({
// ─── Context (appId: 'context') ───
contextSpaces: 'id, pinned, prefix',
documents: 'id, spaceId, type, pinned, title, [spaceId+type]',
documents: 'id, spaceId, type, pinned, title, [spaceId+type], updatedAt',
documentTags: 'id, documentId, tagId, [documentId+tagId]',
// ─── Questions (appId: 'questions') ───
@ -208,7 +219,27 @@ db.version(1).stores({
// ─── Habits (appId: 'habits') ───
habits: 'id, order, isArchived, color',
habitLogs: 'id, habitId, timestamp, [habitId+timestamp]',
habitLogs: 'id, habitId, timeBlockId, [habitId+timeBlockId]',
// ─── Dreams (appId: 'dreams') ───
dreams: 'id, dreamDate, mood, isLucid, isPinned, isArchived, updatedAt',
dreamSymbols: 'id, name, count, updatedAt',
dreamTags: 'id, dreamId, tagId, [dreamId+tagId]',
// ─── Cycles (appId: 'cycles') ───
cycles: 'id, startDate, endDate, isPredicted, isArchived, updatedAt',
cycleDayLogs: 'id, logDate, cycleId, flow, [cycleId+logDate]',
cycleSymptoms: 'id, name, category, count, updatedAt',
// ─── Social Events (appId: 'events') ───
// `socialEvents` is named distinctly to avoid colliding with calendar.events.
socialEvents: 'id, status, timeBlockId, hostContactId, isPublished, [status+createdAt]',
eventGuests: 'id, eventId, contactId, rsvpStatus, [eventId+rsvpStatus], [eventId+contactId]',
eventInvitations: 'id, eventId, guestId, channel, [eventId+guestId]',
// Bring-list ("wer bringt was?") — assignedGuestId points at a local
// guest the host picked manually; claimedByName is set by a public
// RSVP visitor who reserved the item from the share-link page.
eventItems: 'id, eventId, assignedGuestId, done, order, [eventId+order], [eventId+done]',
// ─── Notes (appId: 'notes') ───
notes: 'id, isPinned, isArchived, color, title, updatedAt',
@ -224,6 +255,14 @@ db.version(1).stores({
locationLogs: 'id, placeId, timestamp, [placeId+timestamp]',
placeTags: 'id, placeId, tagId, [placeId+tagId]',
// ─── TimeBlocks (appId: 'timeblocks') — unified time model ───
// Cross-cutting scheduling table that calendar events, time entries,
// habit logs and scheduled tasks all project into. See PROD_READINESS
// notes in time-blocks/service.ts for the design rationale.
timeBlocks:
'id, startDate, kind, type, sourceModule, sourceId, parentBlockId, [sourceModule+sourceId], [type+startDate], [kind+startDate], [parentBlockId+recurrenceDate]',
timeBlockTags: 'id, blockId, tagId, [blockId+tagId]',
// ─── Shared: Global Tags (appId: 'tags') ───
globalTags: 'id, name, groupId',
tagGroups: 'id',
@ -232,296 +271,6 @@ db.version(1).stores({
manaLinks: 'id, sourceAppId, sourceRecordId, targetAppId, targetRecordId',
});
// ─── Schema Migrations ────────────────────────────────────────
// Version 2: Habits emoji → icon field migration
const EMOJI_TO_ICON: Record<string, string> = {
'\u2615': 'coffee',
'\ud83d\udeb6': 'person-simple-walk',
'\ud83c\udfc3': 'person-simple-run',
'\ud83e\uddd8': 'person-simple-tai-chi',
'\ud83d\udca7': 'drop',
'\ud83c\udf4e': 'apple-logo',
'\ud83d\udcda': 'book-open',
'\ud83d\udcaa': 'barbell',
'\ud83d\udecc': 'bed',
'\ud83c\udfb5': 'music-note',
'\ud83d\udc8a': 'pill',
'\ud83c\udf7a': 'beer-stein',
'\ud83c\udf55': 'pizza',
'\ud83d\udeb4': 'bicycle',
'\ud83d\udcdd': 'pencil-simple',
'\ud83e\uddfc': 'tooth',
'\u2b50': 'star',
'\ud83d\ude2e\u200d\ud83d\udca8': 'wind',
};
db.version(2)
.stores({})
.upgrade((tx) => {
return tx
.table('habits')
.toCollection()
.modify((habit: Record<string, unknown>) => {
if (habit.emoji !== undefined && habit.icon === undefined) {
habit.icon = EMOJI_TO_ICON[habit.emoji as string] ?? 'star';
delete habit.emoji;
}
});
});
// ─── Version 3: Unified Time Model (timeBlocks) ─────────────
// Adds timeBlocks table, updates indexes on events/timeEntries/tasks/habitLogs,
// and migrates existing time data into timeBlocks.
db.version(3)
.stores({
// New tables
timeBlocks:
'id, startDate, kind, type, sourceModule, sourceId, [sourceModule+sourceId], [type+startDate], [kind+startDate]',
timeBlockTags: 'id, blockId, tagId, [blockId+tagId]',
// Updated indexes (timeBlockId / scheduledBlockId added)
events: 'id, calendarId, timeBlockId',
timeEntries: 'id, projectId, clientId, timeBlockId, guildId, visibility',
tasks:
'id, dueDate, isCompleted, priority, order, projectId, scheduledBlockId, [isCompleted+order], [projectId+order]',
habitLogs: 'id, habitId, timeBlockId, [habitId+timeBlockId]',
})
.upgrade(async (tx) => {
const timeBlocksTable = tx.table('timeBlocks');
// 1. Migrate calendar events → timeBlocks
const events = await tx.table('events').toArray();
for (const event of events) {
if (!event.startDate) continue;
const blockId = crypto.randomUUID();
await timeBlocksTable.add({
id: blockId,
startDate: event.startDate,
endDate: event.endDate ?? null,
allDay: event.allDay ?? false,
isLive: false,
timezone: null,
recurrenceRule: event.recurrenceRule ?? null,
kind: 'scheduled',
type: 'event',
sourceModule: 'calendar',
sourceId: event.id,
linkedBlockId: null,
title: event.title ?? '',
description: event.description ?? null,
color: event.color ?? null,
icon: null,
projectId: null,
createdAt: event.createdAt ?? new Date().toISOString(),
updatedAt: event.updatedAt ?? new Date().toISOString(),
deletedAt: event.deletedAt ?? null,
});
await tx.table('events').update(event.id, { timeBlockId: blockId });
}
// 2. Migrate time entries → timeBlocks
const entries = await tx.table('timeEntries').toArray();
for (const entry of entries) {
if (!entry.date && !entry.startTime) continue; // skip entries with no date at all
const blockId = crypto.randomUUID();
const startDate = entry.startTime ?? `${entry.date}T00:00:00.000Z`;
await timeBlocksTable.add({
id: blockId,
startDate,
endDate: entry.endTime ?? null,
allDay: false,
isLive: entry.isRunning ?? false,
timezone: null,
recurrenceRule: null,
kind: 'logged',
type: 'timeEntry',
sourceModule: 'times',
sourceId: entry.id,
linkedBlockId: null,
title: entry.description || 'Time Entry',
description: null,
color: null,
icon: null,
projectId: entry.projectId ?? null,
createdAt: entry.createdAt ?? new Date().toISOString(),
updatedAt: entry.updatedAt ?? new Date().toISOString(),
deletedAt: entry.deletedAt ?? null,
});
await tx.table('timeEntries').update(entry.id, { timeBlockId: blockId });
}
// 3. Migrate habit logs → timeBlocks
const logs = await tx.table('habitLogs').toArray();
const habitsById = new Map<string, Record<string, unknown>>();
const allHabits = await tx.table('habits').toArray();
for (const h of allHabits) habitsById.set(h.id as string, h);
for (const log of logs) {
if (!log.timestamp) continue;
const blockId = crypto.randomUUID();
const habit = habitsById.get(log.habitId as string);
await timeBlocksTable.add({
id: blockId,
startDate: log.timestamp,
endDate: null,
allDay: false,
isLive: false,
timezone: null,
recurrenceRule: null,
kind: 'logged',
type: 'habit',
sourceModule: 'habits',
sourceId: log.id,
linkedBlockId: null,
title: (habit?.title as string) ?? 'Habit',
description: null,
color: (habit?.color as string) ?? null,
icon: (habit?.icon as string) ?? null,
projectId: null,
createdAt: log.createdAt ?? new Date().toISOString(),
updatedAt: log.updatedAt ?? log.createdAt ?? new Date().toISOString(),
deletedAt: log.deletedAt ?? null,
});
await tx.table('habitLogs').update(log.id, { timeBlockId: blockId });
}
// 4. Migrate scheduled tasks → timeBlocks
const tasks = await tx.table('tasks').toArray();
for (const task of tasks) {
if (!task.scheduledDate) continue;
const blockId = crypto.randomUUID();
const startISO = task.scheduledStartTime
? `${task.scheduledDate}T${task.scheduledStartTime}:00`
: `${task.scheduledDate}T09:00:00`;
const durationMs = task.estimatedDuration ? task.estimatedDuration * 1000 : 3600000; // default 1h
const endISO = new Date(new Date(startISO).getTime() + durationMs).toISOString();
await timeBlocksTable.add({
id: blockId,
startDate: startISO,
endDate: endISO,
allDay: !task.scheduledStartTime,
isLive: false,
timezone: null,
recurrenceRule: null,
kind: 'scheduled',
type: 'task',
sourceModule: 'todo',
sourceId: task.id,
linkedBlockId: null,
title: task.title ?? '',
description: null,
color: null,
icon: null,
projectId: task.projectId ?? null,
createdAt: task.createdAt ?? new Date().toISOString(),
updatedAt: task.updatedAt ?? new Date().toISOString(),
deletedAt: task.deletedAt ?? null,
});
await tx.table('tasks').update(task.id, { scheduledBlockId: blockId });
}
});
// ─── Version 4: Recurrence instance fields on timeBlocks ──────
// Adds parentBlockId, recurrenceDate, isRecurrenceException indexes.
db.version(4).stores({
timeBlocks:
'id, startDate, kind, type, sourceModule, sourceId, parentBlockId, [sourceModule+sourceId], [type+startDate], [kind+startDate], [parentBlockId+recurrenceDate]',
});
// ─── Version 5: Dreams (Traumtagebuch) ────────────────────────
// Adds dreams, dreamSymbols, dreamTags tables.
db.version(5).stores({
dreams: 'id, dreamDate, mood, isLucid, isPinned, isArchived, updatedAt',
dreamSymbols: 'id, name, count, updatedAt',
dreamTags: 'id, dreamId, tagId, [dreamId+tagId]',
});
// ─── Version 6: Events (Social gatherings) ────────────────────
// Distinct from calendar's `events` table — these are gatherings with guests/RSVPs.
// Main table is `socialEvents` to avoid collision with calendar.events.
db.version(6).stores({
socialEvents: 'id, status, timeBlockId, hostContactId, isPublished, [status+createdAt]',
eventGuests: 'id, eventId, contactId, rsvpStatus, [eventId+rsvpStatus], [eventId+contactId]',
eventInvitations: 'id, eventId, guestId, channel, [eventId+guestId]',
});
// ─── Version 7: Cycles (Menstruationszyklus-Tracking) ────────
db.version(7).stores({
cycles: 'id, startDate, endDate, isPredicted, isArchived, updatedAt',
cycleDayLogs: 'id, logDate, cycleId, flow, [cycleId+logDate]',
cycleSymptoms: 'id, name, category, count, updatedAt',
});
// ─── Version 8: Events tombstones (orphaned snapshot cleanup) ─
// Local-only retry queue. When the events store fails to DELETE a
// server snapshot during unpublish/delete, the (eventId, token) is
// pushed here so a later drain attempt can clean it up. NOT synced.
db.version(8).stores({
_eventsTombstones: 'id, token, attempts, createdAt',
});
// ─── Version 9: Add updatedAt indexes for "recent X" dashboard widgets ─
//
// Several cross-app queries (`useRecentConversations`, `useRecentImages`,
// `useRecentDecks`, `useRecentDocuments`) used to load entire tables and
// JS-sort by `updatedAt`. With these indexes Dexie can walk the BTree in
// reverse and stop after N matches.
//
// `++` is NOT used — we are only adding secondary indexes to existing
// stores. The full `stores()` line is repeated because Dexie's upgrade
// API requires the complete schema for the version, even when most
// fields are unchanged.
//
// No data migration needed: indexes are built lazily by Dexie at upgrade
// time without touching record contents.
db.version(9).stores({
conversations: 'id, isArchived, isPinned, spaceId, templateId, updatedAt',
images: 'id, isFavorite, isPublic, isArchived, prompt, updatedAt',
presiDecks: 'id, isPublic, updatedAt',
documents: 'id, spaceId, type, pinned, title, [spaceId+type], updatedAt',
songs: 'id, artist, album, genre, favorite, title, updatedAt',
mukkePlaylists: 'id, name, updatedAt',
});
// ─── Version 10: Local activity log ───────────────────────────
//
// Capped, append-only feed of every local write across sync-tracked
// tables. Powers a future "what changed recently?" UI without leaking
// PII to the server (this table is intentionally NOT in SYNC_APP_MAP).
//
// Indexes:
// - createdAt: timeline view
// - [appId+createdAt]: per-app filter
// - [collection+recordId]: history of a single record
// - userId: multi-account isolation when that lands
//
// Schema is deliberately small (no field diffs, no payload) to keep
// the table cheap to write and bound the disk footprint.
db.version(10).stores({
_activity:
'++id, createdAt, appId, collection, recordId, op, [appId+createdAt], [collection+recordId], userId',
});
// ─── Version 11: Events bring-list (eventItems) ───────────────
// Adds the "wer bringt was?" table attached to social events.
// `assignedGuestId` points at a local guest the host picked manually;
// `claimedByName` is set by a public RSVP visitor who reserved the
// item from the share-link page.
db.version(11).stores({
eventItems: 'id, eventId, assignedGuestId, done, order, [eventId+order], [eventId+done]',
});
// ─── Sync Routing ──────────────────────────────────────────
// SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE,
// toSyncName() and fromSyncName() are now derived from per-module
@ -566,21 +315,6 @@ export function isApplyingTable(tableName: string): boolean {
return _applyingTables.has(tableName);
}
/**
* @deprecated Legacy single-flag API kept temporarily for any external
* caller. Prefer `beginApplyingTables` so per-table races stay impossible.
* When `v === true` it marks every sync-tracked table; `false` clears them.
*/
export function setApplyingServerChanges(v: boolean): void {
if (v) {
for (const tables of Object.values(SYNC_APP_MAP)) {
for (const t of tables) _applyingTables.add(t);
}
} else {
_applyingTables.clear();
}
}
const pendingChangesTable = db.table('_pendingChanges');
/**

View file

@ -13,7 +13,7 @@
import { ContextMenu, type ContextMenuItem } from '@mana/shared-ui';
import { dropTarget, dragSource } from '@mana/shared-ui/dnd';
import type { TagDragData } from '@mana/shared-ui/dnd';
import { useAllTags, getTagsByIds } from '$lib/stores/tags.svelte';
import { useAllTags, getTagsByIds } from '@mana/shared-stores';
let { navigate, goBack, params }: ViewProps = $props();

View file

@ -8,7 +8,6 @@ export { calendarViewStore } from './stores/view.svelte';
export {
useAllCalendars,
useAllCalendarItems,
useAllEvents,
toCalendar,
getVisibleCalendars,
getDefaultCalendar,

View file

@ -13,7 +13,7 @@
import { ContextMenu, type ContextMenuItem } from '@mana/shared-ui';
import { dropTarget, dragSource } from '@mana/shared-ui/dnd';
import type { TagDragData } from '@mana/shared-ui/dnd';
import { useAllTags, getTagsByIds } from '$lib/stores/tags.svelte';
import { useAllTags, getTagsByIds } from '@mana/shared-stores';
let { navigate, goBack, params }: ViewProps = $props();

View file

@ -18,7 +18,7 @@
} from '@mana/shared-icons';
import type { ViewProps } from '$lib/app-registry';
import type { LocalContact } from '../types';
import { useAllTags, getTagsByIds } from '$lib/stores/tags.svelte';
import { useAllTags, getTagsByIds } from '@mana/shared-stores';
import LinkedItems from '$lib/components/links/LinkedItems.svelte';
import { toastStore } from '@mana/shared-ui/toast';

View file

@ -26,5 +26,5 @@ export {
export { habitTable, habitLogTable, HABITS_GUEST_SEED } from './collections';
// ─── Types ───────────────────────────────────────────────
export { HABIT_COLORS, HABIT_ICONS, EMOJI_TO_ICON_MAP } from './types';
export { HABIT_COLORS, HABIT_ICONS } from './types';
export type { LocalHabit, LocalHabitLog, Habit, HabitLog } from './types';

View file

@ -8,7 +8,6 @@ import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import type { LocalHabit, LocalHabitLog, Habit, HabitLog } from './types';
import type { LocalTimeBlock } from '$lib/data/time-blocks/types';
import { EMOJI_TO_ICON_MAP } from './types';
// ─── Type Converters ───────────────────────────────────────
@ -16,10 +15,7 @@ export function toHabit(local: LocalHabit): Habit {
return {
id: local.id,
title: local.title,
icon:
local.icon ??
EMOJI_TO_ICON_MAP[(local as unknown as { emoji?: string }).emoji ?? ''] ??
'star',
icon: local.icon ?? 'star',
color: local.color,
targetPerDay: local.targetPerDay,
defaultDuration: local.defaultDuration ?? null,

View file

@ -101,25 +101,3 @@ export const HABIT_ICONS: string[] = [
'target',
'fire',
];
/** Maps legacy emoji values to icon names for data migration. */
export const EMOJI_TO_ICON_MAP: Record<string, string> = {
'\u2615': 'coffee',
'\ud83d\udeb6': 'person-simple-walk',
'\ud83c\udfc3': 'person-simple-run',
'\ud83e\uddd8': 'person-simple-tai-chi',
'\ud83d\udca7': 'drop',
'\ud83c\udf4e': 'apple-logo',
'\ud83d\udcda': 'book-open',
'\ud83d\udcaa': 'barbell',
'\ud83d\udecc': 'bed',
'\ud83c\udfb5': 'music-note',
'\ud83d\udc8a': 'pill',
'\ud83c\udf7a': 'beer-stein',
'\ud83c\udf55': 'pizza',
'\ud83d\udeb4': 'bicycle',
'\ud83d\udcdd': 'pencil-simple',
'\ud83e\uddfc': 'tooth',
'\u2b50': 'star',
'\ud83d\ude2e\u200d\ud83d\udca8': 'wind',
};

View file

@ -13,7 +13,7 @@
import { ContextMenu, type ContextMenuItem } from '@mana/shared-ui';
import { dropTarget, dragSource } from '@mana/shared-ui/dnd';
import type { TagDragData } from '@mana/shared-ui/dnd';
import { useAllTags, getTagsByIds } from '$lib/stores/tags.svelte';
import { useAllTags, getTagsByIds } from '@mana/shared-stores';
let { navigate, goBack, params }: ViewProps = $props();

View file

@ -9,7 +9,7 @@
import { Trash, Star, MapPin, X } from '@mana/shared-icons';
import type { ViewProps } from '$lib/app-registry';
import type { LocalPlace, PlaceCategory, LocalLocationLog } from '../types';
import { useAllTags, getTagsByIds } from '$lib/stores/tags.svelte';
import { useAllTags, getTagsByIds } from '@mana/shared-stores';
import LinkedItems from '$lib/components/links/LinkedItems.svelte';
let { navigate, goBack, params }: ViewProps = $props();

View file

@ -19,7 +19,7 @@
import { ContextMenu, type ContextMenuItem } from '@mana/shared-ui';
import { dropTarget, dragSource } from '@mana/shared-ui/dnd';
import type { TagDragData } from '@mana/shared-ui/dnd';
import { useAllTags, getTagsByIds } from '$lib/stores/tags.svelte';
import { useAllTags, getTagsByIds } from '@mana/shared-stores';
let { navigate, goBack, params }: ViewProps = $props();

View file

@ -8,12 +8,12 @@
formatDuration,
} from '../utils/task-parser';
import type { ParsedTask } from '../utils/task-parser';
import type { LocalLabel } from '../types';
import type { TaskTag } from '../types';
import { getPriorityColor } from '../queries';
import { Plus, CalendarBlank, Flag, ArrowsClockwise, Timer, Tag, Info } from '@mana/shared-icons';
interface Props {
labels?: LocalLabel[];
labels?: TaskTag[];
locale?: string;
onShowSyntaxHelp?: () => void;
}

View file

@ -1,5 +1,5 @@
<script lang="ts">
import type { Task, LocalLabel, LocalBoardView } from '../../types';
import type { Task, TaskTag, LocalBoardView } from '../../types';
import KanbanLayout from './KanbanLayout.svelte';
import GridLayout from './GridLayout.svelte';
import FokusLayout from './FokusLayout.svelte';
@ -7,7 +7,7 @@
interface Props {
view: LocalBoardView;
tasks: Task[];
labels: LocalLabel[];
labels: TaskTag[];
wipLimit?: number | null;
cardSize?: 'compact' | 'normal' | 'large';
onToggleComplete: (taskId: string) => void;

View file

@ -1,5 +1,5 @@
<script lang="ts">
import type { Task, LocalLabel, LocalBoardView } from '../../types';
import type { Task, TaskTag, LocalBoardView } from '../../types';
import { groupTasksByView } from '../../view-grouping';
import { Check, Circle, CaretRight } from '@mana/shared-icons';
import { getPriorityColor } from '../../queries';
@ -7,7 +7,7 @@
interface Props {
view: LocalBoardView;
tasks: Task[];
labels: LocalLabel[];
labels: TaskTag[];
onToggleComplete: (taskId: string) => void;
onOpenTask: (task: Task) => void;
}

View file

@ -1,12 +1,12 @@
<script lang="ts">
import type { Task, LocalLabel, LocalBoardView } from '../../types';
import type { Task, TaskTag, LocalBoardView } from '../../types';
import { groupTasksByView } from '../../view-grouping';
import KanbanTaskCard from '../kanban/KanbanTaskCard.svelte';
interface Props {
view: LocalBoardView;
tasks: Task[];
labels: LocalLabel[];
labels: TaskTag[];
onToggleComplete: (taskId: string) => void;
onSaveTask: (taskId: string, data: Partial<Task>) => void;
onDeleteTask: (taskId: string) => void;

View file

@ -1,5 +1,5 @@
<script lang="ts">
import type { Task, LocalLabel, LocalBoardView } from '../../types';
import type { Task, TaskTag, LocalBoardView } from '../../types';
import { groupTasksByView, getDropActionUpdate } from '../../view-grouping';
import { tasksStore } from '../../stores/tasks.svelte';
import ViewColumn from './ViewColumn.svelte';
@ -7,7 +7,7 @@
interface Props {
view: LocalBoardView;
tasks: Task[];
labels: LocalLabel[];
labels: TaskTag[];
wipLimit?: number | null;
cardSize?: 'compact' | 'normal' | 'large';
onToggleComplete: (taskId: string) => void;

View file

@ -1,5 +1,5 @@
<script lang="ts">
import type { Task, LocalLabel } from '../../types';
import type { Task, TaskTag } from '../../types';
import type { GroupedColumn } from '../../view-grouping';
import ViewColumnHeader from './ViewColumnHeader.svelte';
import KanbanTaskCard from '../kanban/KanbanTaskCard.svelte';
@ -9,7 +9,7 @@
interface Props {
column: GroupedColumn;
labels: LocalLabel[];
labels: TaskTag[];
wipLimit?: number | null;
cardSize?: 'compact' | 'normal' | 'large';
onToggleComplete: (taskId: string) => void;

View file

@ -5,7 +5,6 @@
// Stores
export { tasksStore } from './stores/tasks.svelte';
export { boardViewsStore } from './stores/board-views.svelte';
export { labelsStore } from './stores/labels.svelte';
export { remindersStore } from './stores/reminders.svelte';
export { todoSettings } from './stores/settings.svelte';
export { contactsStore } from './stores/contacts.svelte';
@ -62,7 +61,7 @@ export { useTaskForm } from './composables/useTaskForm.svelte';
// Types
export type {
LocalTask,
LocalLabel,
TaskTag,
LocalTaskTag,
LocalReminder,
LocalBoardView,

View file

@ -14,22 +14,6 @@ export {
} from '@mana/shared-stores';
export const taskTagOps = createTagLinkOps({
table: () => db.table('taskLabels'), // DB table still 'taskLabels' until schema migration
table: () => db.table('taskLabels'),
entityIdField: 'taskId',
});
// Backward-compat alias
export const labelsStore = {
createLabel: async (data: { name: string; color: string }) => {
const { tagMutations } = await import('@mana/shared-stores');
return tagMutations.createTag({ name: data.name, color: data.color });
},
updateLabel: async (id: string, data: { name?: string; color?: string }) => {
const { tagMutations } = await import('@mana/shared-stores');
return tagMutations.updateTag(id, data);
},
deleteLabel: async (id: string) => {
const { tagMutations } = await import('@mana/shared-stores');
return tagMutations.deleteTag(id);
},
};

View file

@ -5,8 +5,12 @@
import type { BaseRecord } from '@mana/local-store';
import type { Tag } from '@mana/shared-tags';
/** @deprecated Use Tag from @mana/shared-tags. Kept for backward compatibility. */
export type LocalLabel = Tag;
/**
* A tag attached to a task. Structurally identical to the shared `Tag`
* type the alias exists so todo code can read `TaskTag` without colliding
* with the `Tag` icon from `@mana/shared-icons` that several views import.
*/
export type TaskTag = Tag;
// ─── Local Types (IndexedDB) ──────────────────────────────

View file

@ -13,7 +13,7 @@
import { Heart } from '@mana/shared-icons';
import { dropTarget } from '@mana/shared-ui/dnd';
import type { TagDragData } from '@mana/shared-ui/dnd';
import { useAllTags, getTagsByIds } from '$lib/stores/tags.svelte';
import { useAllTags, getTagsByIds } from '@mana/shared-stores';
import type { ViewProps } from '$lib/app-registry';
import type { LocalFavorite } from './types';
import type { Quote } from '@zitare/content';

View file

@ -1,40 +1,29 @@
/**
* All cross-app search providers, registered lazily.
*
* Each provider lives in its own file and pulls in its module's tables /
* helpers. Registering them eagerly would balloon the initial JS bundle for
* a feature (spotlight search) that the user only opens on demand. The
* dynamic `import()` calls below let Vite split each provider into its own
* chunk that the registry awaits the first time `search()` runs.
*/
import type { SearchRegistry } from '../registry';
import { todoSearchProvider } from './todo';
import { calendarSearchProvider } from './calendar';
import { contactsSearchProvider } from './contacts';
import { chatSearchProvider } from './chat';
import { storageSearchProvider } from './storage';
import { cardsSearchProvider } from './cards';
import { pictureSearchProvider } from './picture';
import { presiSearchProvider } from './presi';
import { musicSearchProvider } from './music';
import { zitareSearchProvider } from './zitare';
import { clockSearchProvider } from './clock';
export function registerAllProviders(registry: SearchRegistry): void {
registry.register(todoSearchProvider);
registry.register(calendarSearchProvider);
registry.register(contactsSearchProvider);
registry.register(chatSearchProvider);
registry.register(storageSearchProvider);
registry.register(cardsSearchProvider);
registry.register(pictureSearchProvider);
registry.register(presiSearchProvider);
registry.register(musicSearchProvider);
registry.register(zitareSearchProvider);
registry.register(clockSearchProvider);
registry.registerLazy('todo', () => import('./todo').then((m) => m.todoSearchProvider));
registry.registerLazy('calendar', () =>
import('./calendar').then((m) => m.calendarSearchProvider)
);
registry.registerLazy('contacts', () =>
import('./contacts').then((m) => m.contactsSearchProvider)
);
registry.registerLazy('chat', () => import('./chat').then((m) => m.chatSearchProvider));
registry.registerLazy('storage', () => import('./storage').then((m) => m.storageSearchProvider));
registry.registerLazy('cards', () => import('./cards').then((m) => m.cardsSearchProvider));
registry.registerLazy('picture', () => import('./picture').then((m) => m.pictureSearchProvider));
registry.registerLazy('presi', () => import('./presi').then((m) => m.presiSearchProvider));
registry.registerLazy('music', () => import('./music').then((m) => m.musicSearchProvider));
registry.registerLazy('zitare', () => import('./zitare').then((m) => m.zitareSearchProvider));
registry.registerLazy('clock', () => import('./clock').then((m) => m.clockSearchProvider));
}
export {
todoSearchProvider,
calendarSearchProvider,
contactsSearchProvider,
chatSearchProvider,
storageSearchProvider,
cardsSearchProvider,
pictureSearchProvider,
presiSearchProvider,
musicSearchProvider,
zitareSearchProvider,
clockSearchProvider,
};

View file

@ -3,12 +3,20 @@
*
* Central registry that fans out search queries to all registered providers
* in parallel and merges results sorted by relevance.
*
* Providers can be registered eagerly (the synchronous `register()` API) or
* lazily (`registerLazy()`). Lazy providers are loaded the first time
* `search()` runs this lets the unified web app keep all per-module search
* code out of the initial JS bundle, since search is opened on demand.
*/
import type { SearchProvider, SearchResult, SearchOptions, GroupedSearchResults } from './types';
import type { SearchProvider, SearchOptions, GroupedSearchResults } from './types';
type LazyLoader = () => Promise<SearchProvider>;
export class SearchRegistry {
private providers: SearchProvider[] = [];
private lazyLoaders = new Map<string, LazyLoader>();
register(provider: SearchProvider): void {
// Avoid duplicate registration
@ -17,8 +25,45 @@ export class SearchRegistry {
}
}
getProviders(): SearchProvider[] {
return this.providers;
/**
* Register a provider that will be loaded on first search. The `appId` is
* required up front so the registry can resolve filter constraints
* (`options.appIds`) without ever loading providers the user filtered out.
*/
registerLazy(appId: string, loader: LazyLoader): void {
// If something already registered eagerly, prefer it.
if (this.providers.some((p) => p.appId === appId)) return;
this.lazyLoaders.set(appId, loader);
}
/**
* Resolves the lazy loaders relevant to a search call (all of them, or just
* the ones matching the appIds filter) and registers them. Each loader runs
* at most once across the lifetime of the registry.
*/
private async hydrate(appIdFilter?: string[]): Promise<void> {
if (this.lazyLoaders.size === 0) return;
const targets = appIdFilter
? appIdFilter.filter((id) => this.lazyLoaders.has(id))
: Array.from(this.lazyLoaders.keys());
if (targets.length === 0) return;
const loaded = await Promise.all(
targets.map(async (appId) => {
const loader = this.lazyLoaders.get(appId)!;
try {
return await loader();
} catch (err) {
console.error(`[search] failed to load provider "${appId}":`, err);
return null;
}
})
);
for (let i = 0; i < targets.length; i++) {
const provider = loaded[i];
this.lazyLoaders.delete(targets[i]);
if (provider) this.register(provider);
}
}
/**
@ -29,6 +74,8 @@ export class SearchRegistry {
const q = query.trim();
if (!q) return [];
await this.hydrate(options?.appIds);
const limit = options?.limit ?? 5;
const targetProviders = options?.appIds
? this.providers.filter((p) => options.appIds!.includes(p.appId))

View file

@ -1,19 +0,0 @@
/**
* Tag Store - Re-exports shared local-first tag store
*
* Tags use the shared IndexedDB ('mana-tags') across all apps.
* This module re-exports for backward compatibility with existing imports.
*/
export {
tagLocalStore,
tagMutations,
tagCollection,
tagGroupCollection,
useAllTags,
useAllTagGroups,
getTagById,
getTagsByIds,
getTagColor,
getTagsByGroup,
} from '@mana/shared-stores';

View file

@ -30,7 +30,7 @@
import { AuthGate, GuestWelcomeModal } from '@mana/shared-auth-ui';
import { createGuestMode, type GuestMode } from '$lib/stores/guest-mode.svelte';
import { NotificationBar } from '@mana/shared-ui';
import { tagLocalStore, tagMutations, useAllTags } from '$lib/stores/tags.svelte';
import { tagLocalStore, tagMutations, useAllTags } from '@mana/shared-stores';
import { linkLocalStore, linkMutations } from '@mana/shared-links';
import { manaStore } from '$lib/data/local-store';
import { createUnifiedSync } from '$lib/data/sync';

View file

@ -2,7 +2,7 @@
import { getContext, onMount } from 'svelte';
import { dropTarget } from '@mana/shared-ui/dnd';
import type { DragPayload, TagDragData } from '@mana/shared-ui/dnd';
import { useAllTags } from '$lib/stores/tags.svelte';
import { useAllTags } from '@mana/shared-stores';
import { calendarViewStore } from '$lib/modules/calendar/stores/view.svelte';
import { eventsStore } from '$lib/modules/calendar/stores/events.svelte';
import {

View file

@ -3,7 +3,7 @@
import { getContext, onMount } from 'svelte';
import type { Observable } from 'dexie';
import type { DragPayload, TagDragData } from '@mana/shared-ui/dnd';
import { useAllTags } from '$lib/stores/tags.svelte';
import { useAllTags } from '@mana/shared-stores';
import {
type Contact,
contactsStore,

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { useAllTags } from '$lib/stores/tags.svelte';
import { useAllTags } from '@mana/shared-stores';
const tags = useAllTags();
</script>

View file

@ -3,7 +3,7 @@
import { getContext, onMount } from 'svelte';
import type { Observable } from 'dexie';
import type { DragPayload } from '@mana/shared-ui/dnd';
import { type Task, type LocalLabel, tasksStore, taskTable } from '$lib/modules/todo';
import { type Task, type TaskTag, tasksStore, taskTable } from '$lib/modules/todo';
import { Gear } from '@mana/shared-icons';
import { ShareModal } from '@mana/shared-uload';
@ -22,10 +22,10 @@
// Get data from layout context
const allTasks$: Observable<Task[]> = getContext('tasks');
const allLabels$: Observable<LocalLabel[]> = getContext('labels');
const allLabels$: Observable<TaskTag[]> = getContext('labels');
let allTasks = $state<Task[]>([]);
let allLabels = $state<LocalLabel[]>([]);
let allLabels = $state<TaskTag[]>([]);
let isLoaded = $state(false);
$effect(() => {