diff --git a/apps/todo/apps/web/package.json b/apps/todo/apps/web/package.json index d92c576ed..83fc1952f 100644 --- a/apps/todo/apps/web/package.json +++ b/apps/todo/apps/web/package.json @@ -40,6 +40,7 @@ }, "dependencies": { "@manacore/local-store": "workspace:*", + "@manacore/shared-links": "workspace:*", "@manacore/shared-api-client": "workspace:*", "@manacore/shared-app-onboarding": "workspace:*", "@manacore/shared-auth": "workspace:*", diff --git a/apps/todo/apps/web/src/lib/components/TaskEditModal.svelte b/apps/todo/apps/web/src/lib/components/TaskEditModal.svelte index 89f66cd78..9e34f379a 100644 --- a/apps/todo/apps/web/src/lib/components/TaskEditModal.svelte +++ b/apps/todo/apps/web/src/lib/components/TaskEditModal.svelte @@ -25,6 +25,7 @@ TagSelector, } from './form'; import { ContactSelector, focusTrap } from '@manacore/shared-ui'; + import { ManaLinkList } from '@manacore/shared-links/ui'; interface Props { task: Task; @@ -315,6 +316,12 @@ + +
+ + +
+
diff --git a/apps/todo/apps/web/src/routes/(app)/+layout.svelte b/apps/todo/apps/web/src/routes/(app)/+layout.svelte index 2acb29e21..64498d6fc 100644 --- a/apps/todo/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/todo/apps/web/src/routes/(app)/+layout.svelte @@ -31,6 +31,7 @@ tagMutations, useAllTags as useAllSharedTags, } from '@manacore/shared-stores'; + import { linkLocalStore, linkMutations } from '@manacore/shared-links'; import { theme } from '$lib/stores/theme'; import TaskFilters from '$lib/components/TaskFilters.svelte'; import { viewStore, type SortBy } from '$lib/stores/view.svelte'; @@ -317,18 +318,24 @@ async function handleLogout() { await authStore.signOut(); tagMutations.stopSync(); + linkMutations.stopSync(); goto('/login'); } async function handleAuthReady() { // Initialize local-first databases (opens IndexedDB, seeds guest data) - await Promise.all([todoStore.initialize(), tagLocalStore.initialize()]); + await Promise.all([ + todoStore.initialize(), + tagLocalStore.initialize(), + linkLocalStore.initialize(), + ]); // If authenticated, start syncing to server if (authStore.isAuthenticated) { const getToken = () => authStore.getValidToken(); todoStore.startSync(getToken); tagMutations.startSync(getToken); + linkMutations.startSync(getToken); } // Initialize split-panel from URL/localStorage diff --git a/packages/shared-links/package.json b/packages/shared-links/package.json new file mode 100644 index 000000000..0480b5bfe --- /dev/null +++ b/packages/shared-links/package.json @@ -0,0 +1,23 @@ +{ + "name": "@manacore/shared-links", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./ui": "./src/ui/index.ts" + }, + "dependencies": { + "@manacore/local-store": "workspace:*", + "@manacore/shared-branding": "workspace:*" + }, + "peerDependencies": { + "svelte": "^5.0.0" + }, + "devDependencies": { + "svelte": "^5.0.0", + "typescript": "^5.9.3" + } +} diff --git a/packages/shared-links/src/index.ts b/packages/shared-links/src/index.ts new file mode 100644 index 000000000..7cd6b12de --- /dev/null +++ b/packages/shared-links/src/index.ts @@ -0,0 +1,26 @@ +// Store +export { linkLocalStore, linkCollection } from './store.js'; + +// Types +export type { + LocalManaLink, + CreateManaLinkInput, + LinkCachedData, + ManaRecordRef, + ManaLinkType, +} from './types.js'; +export { LINK_TYPE_INVERSIONS } from './types.js'; + +// Mutations +export { linkMutations } from './mutations.svelte.js'; + +// Queries +export { + useLinksForRecord, + useLinksForApp, + useLinksOfType, + useLinkCount, +} from './queries.svelte.js'; + +// Resolvers +export { buildCachedData, isCacheStale } from './resolvers.js'; diff --git a/packages/shared-links/src/mutations.svelte.ts b/packages/shared-links/src/mutations.svelte.ts new file mode 100644 index 000000000..abbab58a1 --- /dev/null +++ b/packages/shared-links/src/mutations.svelte.ts @@ -0,0 +1,143 @@ +/** + * ManaLink — Mutation Service + * + * Handles creating, deleting, and updating cross-app links. + * Each link creates TWO records (forward + reverse) sharing a pairId + * for efficient bidirectional queries. + */ + +import { linkLocalStore, linkCollection } from './store.js'; +import type { LocalManaLink, CreateManaLinkInput, LinkCachedData } from './types.js'; +import { LINK_TYPE_INVERSIONS } from './types.js'; + +/** Invert a directional link type. Symmetric types return unchanged. */ +function invertLinkType(type: string): string { + return LINK_TYPE_INVERSIONS[type] ?? type; +} + +let error = $state(null); + +export const linkMutations = { + get error() { + return error; + }, + + // === Store Lifecycle === + + async initialize() { + await linkLocalStore.initialize(); + }, + + startSync(getToken: () => Promise) { + linkLocalStore.startSync(getToken); + }, + + stopSync() { + linkLocalStore.stopSync(); + }, + + // === Links === + + /** + * Create a bidirectional link. Inserts TWO records sharing a pairId: + * - Forward: source → target + * - Reverse: target → source (swapped) + */ + async createLink( + input: CreateManaLinkInput + ): Promise<{ forward: LocalManaLink; reverse: LocalManaLink }> { + error = null; + try { + const pairId = crypto.randomUUID(); + const linkType = input.linkType ?? 'related'; + + const forward: LocalManaLink = { + id: crypto.randomUUID(), + pairId, + direction: 'forward', + sourceApp: input.sourceApp, + sourceCollection: input.sourceCollection, + sourceId: input.sourceId, + targetApp: input.targetApp, + targetCollection: input.targetCollection, + targetId: input.targetId, + linkType, + cachedSource: input.cachedSource, + cachedTarget: input.cachedTarget, + }; + + const reverse: LocalManaLink = { + id: crypto.randomUUID(), + pairId, + direction: 'reverse', + sourceApp: input.targetApp, + sourceCollection: input.targetCollection, + sourceId: input.targetId, + targetApp: input.sourceApp, + targetCollection: input.sourceCollection, + targetId: input.sourceId, + linkType: invertLinkType(linkType), + cachedSource: input.cachedTarget, + cachedTarget: input.cachedSource, + }; + + const insertedForward = await linkCollection.insert(forward); + const insertedReverse = await linkCollection.insert(reverse); + + return { forward: insertedForward, reverse: insertedReverse }; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to create link'; + throw e; + } + }, + + /** + * Delete a link pair by pairId. Soft-deletes both forward and reverse records. + */ + async deleteLinkPair(pairId: string): Promise { + error = null; + try { + const links = await linkCollection.getAll({ + pairId, + } as Partial); + for (const link of links) { + await linkCollection.delete(link.id); + } + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to delete link pair'; + throw e; + } + }, + + /** + * Delete a link by its record ID (and its pair partner). + */ + async deleteLink(linkId: string): Promise { + error = null; + try { + const link = await linkCollection.get(linkId); + if (link) { + await this.deleteLinkPair(link.pairId); + } + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to delete link'; + throw e; + } + }, + + /** + * Update cached display data on a link record. + */ + async updateCache( + linkId: string, + cached: Partial<{ cachedSource: LinkCachedData; cachedTarget: LinkCachedData }> + ): Promise { + error = null; + try { + await linkCollection.update(linkId, cached as Partial); + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to update link cache'; + throw e; + } + }, +}; diff --git a/packages/shared-links/src/queries.svelte.ts b/packages/shared-links/src/queries.svelte.ts new file mode 100644 index 000000000..9ad45e350 --- /dev/null +++ b/packages/shared-links/src/queries.svelte.ts @@ -0,0 +1,64 @@ +/** + * ManaLink — Reactive Live Queries + * + * Svelte 5 reactive queries for cross-app links. + * Auto-update when IndexedDB changes (local writes, sync, other tabs). + */ + +import { useLiveQueryWithDefault } from '@manacore/local-store/svelte'; +import { linkCollection } from './store.js'; +import type { LocalManaLink, ManaRecordRef } from './types.js'; + +/** + * Get all links where this record is the source. + * Because we store reverse records, this returns links in BOTH directions. + */ +export function useLinksForRecord(ref: ManaRecordRef) { + return useLiveQueryWithDefault( + () => + linkCollection.getAll({ + sourceApp: ref.app, + sourceId: ref.id, + } as Partial), + [] as LocalManaLink[] + ); +} + +/** + * Get all links for an entire app. + */ +export function useLinksForApp(appId: string) { + return useLiveQueryWithDefault( + () => + linkCollection.getAll({ + sourceApp: appId, + } as Partial), + [] as LocalManaLink[] + ); +} + +/** + * Get links of a specific type for a record. + */ +export function useLinksOfType(ref: ManaRecordRef, linkType: string) { + return useLiveQueryWithDefault(async () => { + const all = await linkCollection.getAll({ + sourceApp: ref.app, + sourceId: ref.id, + } as Partial); + return all.filter((l) => l.linkType === linkType); + }, [] as LocalManaLink[]); +} + +/** + * Count links for a record. + */ +export function useLinkCount(ref: ManaRecordRef) { + return useLiveQueryWithDefault(async () => { + const all = await linkCollection.getAll({ + sourceApp: ref.app, + sourceId: ref.id, + } as Partial); + return all.length; + }, 0); +} diff --git a/packages/shared-links/src/resolvers.ts b/packages/shared-links/src/resolvers.ts new file mode 100644 index 000000000..f5ecffaa0 --- /dev/null +++ b/packages/shared-links/src/resolvers.ts @@ -0,0 +1,34 @@ +/** + * ManaLink — Display Data Resolvers + * + * Helpers to build cached display data from app metadata. + */ + +import { getManaApp } from '@manacore/shared-branding'; +import type { AppIconId } from '@manacore/shared-branding'; +import type { LinkCachedData } from './types.js'; + +/** + * Build LinkCachedData from app branding + a title. + * Called by the app creating the link to populate offline-friendly display data. + */ +export function buildCachedData(appId: string, title: string, subtitle?: string): LinkCachedData { + const app = getManaApp(appId as AppIconId); + return { + title, + subtitle, + icon: app?.icon, + color: app?.color, + appName: app?.name, + fetchedAt: new Date().toISOString(), + }; +} + +/** + * Check if cached data is stale (older than threshold). + * Default: 24 hours. + */ +export function isCacheStale(cached: LinkCachedData, maxAgeMs = 24 * 60 * 60 * 1000): boolean { + const age = Date.now() - new Date(cached.fetchedAt).getTime(); + return age > maxAgeMs; +} diff --git a/packages/shared-links/src/store.ts b/packages/shared-links/src/store.ts new file mode 100644 index 000000000..e06d2cf6d --- /dev/null +++ b/packages/shared-links/src/store.ts @@ -0,0 +1,40 @@ +/** + * ManaLink — Local-First Store + * + * Creates a shared IndexedDB database ('manacore-links') that all apps + * can read from. Links are synced to the server via mana-sync. + */ + +import { createLocalStore } from '@manacore/local-store'; +import type { LocalManaLink } from './types.js'; + +const SYNC_SERVER_URL = + (typeof window !== 'undefined' && + (window as unknown as { __PUBLIC_SYNC_SERVER_URL__?: string }).__PUBLIC_SYNC_SERVER_URL__) || + (typeof import.meta !== 'undefined' && import.meta.env?.PUBLIC_SYNC_SERVER_URL) || + 'http://localhost:3050'; + +export const linkLocalStore = createLocalStore({ + appId: 'links', + collections: [ + { + name: 'links', + indexes: [ + 'pairId', + 'direction', + 'sourceApp', + 'sourceId', + 'targetApp', + 'targetId', + 'linkType', + '[sourceApp+sourceId]', + '[sourceApp+sourceCollection+sourceId]', + ], + }, + ], + sync: { + serverUrl: SYNC_SERVER_URL, + }, +}); + +export const linkCollection = linkLocalStore.collection('links'); diff --git a/packages/shared-links/src/types.ts b/packages/shared-links/src/types.ts new file mode 100644 index 000000000..f97c012bd --- /dev/null +++ b/packages/shared-links/src/types.ts @@ -0,0 +1,92 @@ +/** + * ManaLink — Cross-App Linking Types + * + * Defines the data structures for bidirectional links between records + * across different apps in the Mana ecosystem. + */ + +import type { BaseRecord } from '@manacore/local-store'; + +/** Cached display data for a linked record (offline-friendly). */ +export interface LinkCachedData { + /** Display title of the linked record. */ + title: string; + /** Optional subtitle (e.g. date, status). */ + subtitle?: string; + /** App icon (data URL from shared-branding). */ + icon?: string; + /** App color hex (from shared-branding). */ + color?: string; + /** Human-readable app name. */ + appName?: string; + /** ISO timestamp when this cache was built. */ + fetchedAt: string; +} + +/** A single link record stored in IndexedDB (manacore-links). */ +export interface LocalManaLink extends BaseRecord { + /** UUID shared by the forward and reverse link records. */ + pairId: string; + /** Direction marker for this half of the pair. */ + direction: 'forward' | 'reverse'; + /** App ID of the source record (e.g. 'todo'). */ + sourceApp: string; + /** Collection name in the source app (e.g. 'tasks'). */ + sourceCollection: string; + /** Record ID in the source app. */ + sourceId: string; + /** App ID of the target record (e.g. 'calendar'). */ + targetApp: string; + /** Collection name in the target app (e.g. 'events'). */ + targetCollection: string; + /** Record ID in the target app. */ + targetId: string; + /** Semantic link type. */ + linkType: string; + /** Cached display data for the source record. */ + cachedSource?: LinkCachedData; + /** Cached display data for the target record. */ + cachedTarget?: LinkCachedData; + /** User ID (for sync scoping). */ + userId?: string; +} + +/** Input for creating a link (user-facing). */ +export interface CreateManaLinkInput { + sourceApp: string; + sourceCollection: string; + sourceId: string; + targetApp: string; + targetCollection: string; + targetId: string; + /** Link type. Default: 'related'. */ + linkType?: string; + cachedSource?: LinkCachedData; + cachedTarget?: LinkCachedData; +} + +/** Identifies a record across apps. */ +export interface ManaRecordRef { + app: string; + collection: string; + id: string; +} + +/** Built-in link types. */ +export type ManaLinkType = + | 'related' + | 'blocks' + | 'blocked-by' + | 'time-block' + | 'attachment' + | 'reference' + | 'parent' + | 'child'; + +/** Map of directional link type inversions. */ +export const LINK_TYPE_INVERSIONS: Record = { + blocks: 'blocked-by', + 'blocked-by': 'blocks', + parent: 'child', + child: 'parent', +}; diff --git a/packages/shared-links/src/ui/ManaLinkBadge.svelte b/packages/shared-links/src/ui/ManaLinkBadge.svelte new file mode 100644 index 000000000..3661de05a --- /dev/null +++ b/packages/shared-links/src/ui/ManaLinkBadge.svelte @@ -0,0 +1,119 @@ + + + + + {#if onRemove} + + {/if} + + + diff --git a/packages/shared-links/src/ui/ManaLinkList.svelte b/packages/shared-links/src/ui/ManaLinkList.svelte new file mode 100644 index 000000000..8af157e16 --- /dev/null +++ b/packages/shared-links/src/ui/ManaLinkList.svelte @@ -0,0 +1,36 @@ + + +{#if (links.value ?? []).length > 0} + +{/if} + + diff --git a/packages/shared-links/src/ui/index.ts b/packages/shared-links/src/ui/index.ts new file mode 100644 index 000000000..c0e359298 --- /dev/null +++ b/packages/shared-links/src/ui/index.ts @@ -0,0 +1,2 @@ +export { default as ManaLinkBadge } from './ManaLinkBadge.svelte'; +export { default as ManaLinkList } from './ManaLinkList.svelte'; diff --git a/packages/shared-links/tsconfig.json b/packages/shared-links/tsconfig.json new file mode 100644 index 000000000..199ad556d --- /dev/null +++ b/packages/shared-links/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "types": ["svelte"] + }, + "include": ["src/**/*"] +}