feat(brain): add Domain Event Bus and emit events from 5 pilot modules

Phase 1 of the Companion Brain architecture. Introduces a typed,
synchronous event bus with microtask-scheduled handlers, an append-only
event store persisted to IndexedDB (_events table, v10 schema), and
semantic domain events emitted from module stores.

Pilot modules with emit() calls:
- Todo: TaskCreated, TaskCompleted, TaskUncompleted, TaskDeleted, SubtasksUpdated
- Calendar: CalendarEventCreated, CalendarEventUpdated, CalendarEventDeleted
- Drink: DrinkLogged, DrinkEntryDeleted, DrinkEntryUndone
- Nutriphi: MealLogged, MealFromPhotoLogged, MealDeleted
- Places: PlaceCreated, PlaceDeleted, PlaceVisited, LocationLogged,
  TrackingStarted, TrackingStopped

Also includes the full architecture plan at
docs/architecture/COMPANION_BRAIN_ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-13 20:25:46 +02:00
parent f5b9d0a31f
commit e927c1f10f
14 changed files with 2116 additions and 3 deletions

View file

@ -398,6 +398,36 @@ db.version(8).stores({
recipes: 'id, difficulty, isFavorite, *tags',
});
// Schema version 9 — adds the Stretch module (guided stretching routines
// with mobility assessments, session tracking, and reminders).
// Additive only; no prior tables touched.
//
// Index strategy:
// - stretchExercises indexes bodyRegion + difficulty for the exercise picker
// filter strip, isPreset to separate seeds from custom.
// - stretchRoutines indexes routineType for the type-based filter tabs,
// order for the user's custom sort.
// - stretchSessions indexes startedAt for the history timeline view
// (range scan descending).
// - stretchAssessments indexes assessedAt for the trend chart.
// - stretchReminders indexes isActive so the reminder engine can quickly
// find enabled reminders without scanning the full table.
db.version(9).stores({
stretchExercises: 'id, bodyRegion, difficulty, isPreset, isArchived, order',
stretchRoutines: 'id, routineType, order, isPinned, isPreset',
stretchSessions: 'id, routineId, startedAt, [startedAt]',
stretchAssessments: 'id, assessedAt',
stretchReminders: 'id, isActive',
});
// v10 — Domain Event Store for the Companion Brain.
// Append-only log of semantic events emitted by module stores.
// NOT synced (local intelligence only). Replaces _activity long-term.
db.version(10).stores({
_events:
'++seq, type, meta.appId, meta.timestamp, meta.recordId, [meta.appId+meta.timestamp], [type+meta.timestamp]',
});
// ─── 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

View file

@ -0,0 +1,296 @@
/**
* Domain Event Catalog Typed event definitions for all modules.
*
* Each module section defines payload interfaces and a string-literal
* union of event types. The top-level ManaEvent union covers every
* possible event so the EventStore and Projection Engine can work
* with full type safety.
*
* Pilot modules: Todo, Calendar, Drink, Nutriphi, Places.
*/
import type { DomainEvent } from './types';
// ── Todo ────────────────────────────────────────────
export interface TaskCreatedPayload {
taskId: string;
title: string;
dueDate?: string;
priority?: number;
projectId?: string;
labelIds?: string[];
}
export interface TaskCompletedPayload {
taskId: string;
title: string;
projectId?: string;
wasOverdue: boolean;
}
export interface TaskUncompletedPayload {
taskId: string;
title: string;
}
export interface TaskUpdatedPayload {
taskId: string;
fields: string[];
}
export interface TaskDeletedPayload {
taskId: string;
title: string;
}
export interface TaskRescheduledPayload {
taskId: string;
title: string;
oldDate?: string;
newDate: string;
}
export interface SubtasksUpdatedPayload {
taskId: string;
total: number;
completed: number;
}
export interface ReminderSetPayload {
taskId: string;
reminderId: string;
minutesBefore: number;
type?: string;
}
export interface ReminderDeletedPayload {
taskId: string;
reminderId: string;
}
export type TodoEventType =
| 'TaskCreated'
| 'TaskCompleted'
| 'TaskUncompleted'
| 'TaskUpdated'
| 'TaskDeleted'
| 'TaskRescheduled'
| 'SubtasksUpdated'
| 'ReminderSet'
| 'ReminderDeleted';
// ── Calendar ────────────────────────────────────────
export interface CalendarEventCreatedPayload {
eventId: string;
title: string;
startTime: string;
endTime: string;
isAllDay: boolean;
isRecurring: boolean;
calendarId: string;
location?: string;
}
export interface CalendarEventUpdatedPayload {
eventId: string;
fields: string[];
}
export interface CalendarEventDeletedPayload {
eventId: string;
title: string;
wasRecurring: boolean;
}
export interface CalendarEventMovedPayload {
eventId: string;
title: string;
oldStart: string;
newStart: string;
}
export type CalendarEventType =
| 'CalendarEventCreated'
| 'CalendarEventUpdated'
| 'CalendarEventDeleted'
| 'CalendarEventMoved';
// ── Drink ───────────────────────────────────────────
export interface DrinkLoggedPayload {
entryId: string;
drinkType: string;
quantityMl: number;
name: string;
date: string;
time: string;
fromPreset: boolean;
}
export interface DrinkEntryDeletedPayload {
entryId: string;
drinkType: string;
quantityMl: number;
}
export interface DrinkEntryUndonePayload {
entryId: string;
}
export type DrinkEventType = 'DrinkLogged' | 'DrinkEntryDeleted' | 'DrinkEntryUndone';
// ── Nutriphi ────────────────────────────────────────
export interface MealLoggedPayload {
mealId: string;
mealType: string;
inputType: string;
description: string;
calories?: number;
protein?: number;
date: string;
}
export interface MealFromPhotoLoggedPayload {
mealId: string;
mealType: string;
photoMediaId: string;
confidence: number;
calories?: number;
}
export interface MealDeletedPayload {
mealId: string;
mealType: string;
}
export interface NutritionGoalSetPayload {
goalId: string;
dailyCalories: number;
dailyProtein?: number;
dailyCarbs?: number;
dailyFat?: number;
}
export type NutriphiEventType =
| 'MealLogged'
| 'MealFromPhotoLogged'
| 'MealDeleted'
| 'NutritionGoalSet';
// ── Places ──────────────────────────────────────────
export interface PlaceCreatedPayload {
placeId: string;
name: string;
category?: string;
lat: number;
lng: number;
}
export interface PlaceDeletedPayload {
placeId: string;
name: string;
}
export interface PlaceVisitedPayload {
placeId: string;
name: string;
visitCount: number;
}
export interface LocationLoggedPayload {
logId: string;
lat: number;
lng: number;
placeId?: string;
accuracy?: number;
}
export interface TrackingStartedPayload {
timestamp: string;
}
export interface TrackingStoppedPayload {
durationMs: number;
logCount: number;
}
export type PlacesEventType =
| 'PlaceCreated'
| 'PlaceDeleted'
| 'PlaceVisited'
| 'LocationLogged'
| 'TrackingStarted'
| 'TrackingStopped';
// ── System Events (Goals, Companion) ────────────────
export interface GoalReachedPayload {
goalId: string;
title: string;
value: number;
target: number;
period: string;
}
export interface GoalProgressPayload {
goalId: string;
title: string;
value: number;
target: number;
}
export type SystemEventType = 'GoalReached' | 'GoalProgress';
// ── Union of all event types ────────────────────────
export type ManaEventType =
| TodoEventType
| CalendarEventType
| DrinkEventType
| NutriphiEventType
| PlacesEventType
| SystemEventType;
/**
* Discriminated union of all domain events.
* Use this for the EventStore subscriber and Projection handlers.
*/
export type ManaEvent =
// Todo
| DomainEvent<'TaskCreated', TaskCreatedPayload>
| DomainEvent<'TaskCompleted', TaskCompletedPayload>
| DomainEvent<'TaskUncompleted', TaskUncompletedPayload>
| DomainEvent<'TaskUpdated', TaskUpdatedPayload>
| DomainEvent<'TaskDeleted', TaskDeletedPayload>
| DomainEvent<'TaskRescheduled', TaskRescheduledPayload>
| DomainEvent<'SubtasksUpdated', SubtasksUpdatedPayload>
| DomainEvent<'ReminderSet', ReminderSetPayload>
| DomainEvent<'ReminderDeleted', ReminderDeletedPayload>
// Calendar
| DomainEvent<'CalendarEventCreated', CalendarEventCreatedPayload>
| DomainEvent<'CalendarEventUpdated', CalendarEventUpdatedPayload>
| DomainEvent<'CalendarEventDeleted', CalendarEventDeletedPayload>
| DomainEvent<'CalendarEventMoved', CalendarEventMovedPayload>
// Drink
| DomainEvent<'DrinkLogged', DrinkLoggedPayload>
| DomainEvent<'DrinkEntryDeleted', DrinkEntryDeletedPayload>
| DomainEvent<'DrinkEntryUndone', DrinkEntryUndonePayload>
// Nutriphi
| DomainEvent<'MealLogged', MealLoggedPayload>
| DomainEvent<'MealFromPhotoLogged', MealFromPhotoLoggedPayload>
| DomainEvent<'MealDeleted', MealDeletedPayload>
| DomainEvent<'NutritionGoalSet', NutritionGoalSetPayload>
// Places
| DomainEvent<'PlaceCreated', PlaceCreatedPayload>
| DomainEvent<'PlaceDeleted', PlaceDeletedPayload>
| DomainEvent<'PlaceVisited', PlaceVisitedPayload>
| DomainEvent<'LocationLogged', LocationLoggedPayload>
| DomainEvent<'TrackingStarted', TrackingStartedPayload>
| DomainEvent<'TrackingStopped', TrackingStoppedPayload>
// System
| DomainEvent<'GoalReached', GoalReachedPayload>
| DomainEvent<'GoalProgress', GoalProgressPayload>;

View file

@ -0,0 +1,44 @@
/**
* Convenience helper for emitting domain events from module stores.
*
* Builds the EventMeta automatically so stores only need to specify
* the event type, routing info, and payload.
*/
import { eventBus } from './event-bus';
import { getEffectiveUserId } from '../current-user';
import type { DomainEvent } from './types';
/**
* Emit a domain event on the shared bus.
*
* @example
* ```ts
* emitDomainEvent('TaskCompleted', 'todo', 'tasks', id, {
* taskId: id, title: task.title, wasOverdue: true,
* });
* ```
*/
export function emitDomainEvent<P>(
type: string,
appId: string,
collection: string,
recordId: string,
payload: P,
causedBy?: string
): void {
const event: DomainEvent<string, P> = {
type,
payload,
meta: {
id: crypto.randomUUID(),
timestamp: new Date().toISOString(),
appId,
collection,
recordId,
userId: getEffectiveUserId(),
causedBy,
},
};
eventBus.emit(event);
}

View file

@ -0,0 +1,69 @@
/**
* EventBus Synchronous in-process event dispatcher.
*
* Handlers are invoked via queueMicrotask so they run after the current
* Dexie transaction commits but before the next frame no setTimeout
* delay, no transaction interference.
*
* A re-entrancy guard prevents infinite loops when a handler emits
* another event of the same type.
*/
import type { DomainEvent, EventBus, EventHandler } from './types';
export function createEventBus(): EventBus {
const handlers = new Map<string, Set<EventHandler>>();
const anyHandlers = new Set<EventHandler>();
const emitting = new Set<string>();
return {
emit(event: DomainEvent) {
queueMicrotask(() => {
if (emitting.has(event.type)) {
console.warn(`[event-bus] re-entrant emit blocked: ${event.type}`);
return;
}
emitting.add(event.type);
try {
const typeHandlers = handlers.get(event.type);
if (typeHandlers) {
for (const h of typeHandlers) {
try {
h(event);
} catch (err) {
console.error(`[event-bus] handler error for ${event.type}:`, err);
}
}
}
for (const h of anyHandlers) {
try {
h(event);
} catch (err) {
console.error(`[event-bus] anyHandler error for ${event.type}:`, err);
}
}
} finally {
emitting.delete(event.type);
}
});
},
on(type, handler) {
if (!handlers.has(type)) handlers.set(type, new Set());
handlers.get(type)!.add(handler);
return () => handlers.get(type)?.delete(handler);
},
onAny(handler) {
anyHandlers.add(handler);
return () => anyHandlers.delete(handler);
},
off(type, handler) {
handlers.get(type)?.delete(handler);
},
};
}
/** Singleton bus shared across the entire app. */
export const eventBus: EventBus = createEventBus();

View file

@ -0,0 +1,156 @@
/**
* Event Store Persists all domain events to the _events Dexie table.
*
* Subscribes to eventBus.onAny() and writes each event as an append-only
* row. Provides query helpers for Projections and the Correlation Engine.
*
* Retention: 90 days / 50,000 events max (whichever is reached first).
*/
import { db } from '../database';
import { eventBus } from './event-bus';
import type { DomainEvent } from './types';
const EVENTS_TABLE = '_events';
const MAX_EVENTS = 50_000;
const TTL_MS = 90 * 24 * 60 * 60 * 1000; // 90 days
let unsubscribe: (() => void) | null = null;
/** Start persisting all domain events. Call once at app init. */
export function startEventStore(): void {
if (unsubscribe) return;
unsubscribe = eventBus.onAny((event: DomainEvent) => {
db.table(EVENTS_TABLE)
.add({
type: event.type,
payload: event.payload,
meta: event.meta,
})
.catch((err: unknown) => {
console.error('[event-store] failed to persist event:', err);
});
});
}
/** Stop persisting events (for cleanup / tests). */
export function stopEventStore(): void {
unsubscribe?.();
unsubscribe = null;
}
// ── Queries ─────────────────────────────────────────
export interface EventQuery {
appId?: string;
type?: string;
since?: string; // ISO timestamp
until?: string; // ISO timestamp
limit?: number; // default 100
}
interface StoredEvent {
seq?: number;
type: string;
payload: unknown;
meta: {
id: string;
timestamp: string;
appId: string;
collection: string;
recordId: string;
userId: string;
causedBy?: string;
};
}
/** Query persisted events. Most recent first. */
export async function queryEvents(query: EventQuery = {}): Promise<DomainEvent[]> {
const limit = Math.min(query.limit ?? 100, 1000);
let collection;
if (query.appId && query.type) {
// Use compound index for appId, filter type in JS
collection = db
.table(EVENTS_TABLE)
.where('[meta.appId+meta.timestamp]')
.between([query.appId, query.since ?? ''], [query.appId, query.until ?? '\uffff']);
} else if (query.appId) {
collection = db
.table(EVENTS_TABLE)
.where('[meta.appId+meta.timestamp]')
.between([query.appId, query.since ?? ''], [query.appId, query.until ?? '\uffff']);
} else if (query.type) {
collection = db
.table(EVENTS_TABLE)
.where('[type+meta.timestamp]')
.between([query.type, query.since ?? ''], [query.type, query.until ?? '\uffff']);
} else {
collection = db.table(EVENTS_TABLE).orderBy('seq');
}
let results: StoredEvent[] = await collection.reverse().limit(limit).toArray();
// Apply additional filters not covered by the index
if (query.type && query.appId) {
results = results.filter((e) => e.type === query.type);
}
if (query.since && !query.appId && !query.type) {
results = results.filter((e) => e.meta.timestamp >= query.since!);
}
if (query.until && !query.appId && !query.type) {
results = results.filter((e) => e.meta.timestamp <= query.until!);
}
return results.map((row) => ({
type: row.type,
payload: row.payload,
meta: row.meta,
}));
}
/** Count events by type within a date range. */
export async function countEventsByType(
since: string,
until: string
): Promise<Record<string, number>> {
const rows: StoredEvent[] = await db
.table(EVENTS_TABLE)
.where('meta.timestamp')
.between(since, until)
.toArray();
const counts: Record<string, number> = {};
for (const row of rows) {
counts[row.type] = (counts[row.type] ?? 0) + 1;
}
return counts;
}
// ── Pruning ─────────────────────────────────────────
/** Remove events older than TTL or exceeding max count. */
export async function pruneEventStore(): Promise<number> {
const cutoff = new Date(Date.now() - TTL_MS).toISOString();
let deleted = 0;
// Delete by age
const old = await db.table(EVENTS_TABLE).where('meta.timestamp').below(cutoff).primaryKeys();
if (old.length > 0) {
await db.table(EVENTS_TABLE).bulkDelete(old);
deleted += old.length;
}
// Delete overflow (keep newest MAX_EVENTS)
const total = await db.table(EVENTS_TABLE).count();
if (total > MAX_EVENTS) {
const overflow = total - MAX_EVENTS;
const oldest = await db.table(EVENTS_TABLE).orderBy('seq').limit(overflow).primaryKeys();
if (oldest.length > 0) {
await db.table(EVENTS_TABLE).bulkDelete(oldest);
deleted += oldest.length;
}
}
return deleted;
}

View file

@ -0,0 +1,4 @@
export { eventBus, createEventBus } from './event-bus';
export { emitDomainEvent } from './emit';
export type { DomainEvent, EventMeta, EventBus, EventHandler } from './types';
export type * from './catalog';

View file

@ -0,0 +1,48 @@
/**
* Domain Event types for the Mana Companion Brain.
*
* Every module mutation emits a typed DomainEvent via the EventBus.
* Events carry semantic meaning ("TaskCompleted") rather than raw CRUD
* operations ("tasks table updated"), enabling Projections, Rules, and
* the LLM Context Builder to work without reverse-engineering field diffs.
*/
// ── Core Event Shape ────────────────────────────────
export interface DomainEvent<T extends string = string, P = unknown> {
readonly type: T;
readonly payload: P;
readonly meta: EventMeta;
}
export interface EventMeta {
/** Unique event ID */
readonly id: string;
/** ISO timestamp */
readonly timestamp: string;
/** Source module (e.g. 'todo', 'drink') */
readonly appId: string;
/** Source Dexie table */
readonly collection: string;
/** Affected record ID */
readonly recordId: string;
/** User who triggered this */
readonly userId: string;
/** Parent event ID (for trigger chains / cascades) */
readonly causedBy?: string;
}
// ── Bus Interface ───────────────────────────────────
export type EventHandler<E extends DomainEvent = DomainEvent> = (event: E) => void;
export interface EventBus {
/** Emit a domain event. Handlers run asynchronously via queueMicrotask. */
emit(event: DomainEvent): void;
/** Subscribe to a specific event type. Returns unsubscribe function. */
on<T extends string>(type: T, handler: EventHandler): () => void;
/** Subscribe to all events. Returns unsubscribe function. */
onAny(handler: EventHandler): () => void;
/** Unsubscribe a handler from a specific event type. */
off(type: string, handler: EventHandler): void;
}

View file

@ -10,6 +10,7 @@
import { db } from '$lib/data/database';
import { encryptRecord } from '$lib/data/crypto';
import { emitDomainEvent } from '$lib/data/events';
import { createBlock, updateBlock, deleteBlock } from '$lib/data/time-blocks/service';
import { timeBlockTable } from '$lib/data/time-blocks/collections';
import {
@ -85,6 +86,16 @@ export const eventsStore = {
// reads go through queries.ts which decrypts on the way out.
await encryptRecord('events', newLocal);
await db.table<LocalEvent>('events').add(newLocal);
emitDomainEvent('CalendarEventCreated', 'calendar', 'events', eventId, {
eventId,
title: input.title,
startTime: input.startTime,
endTime: input.endTime,
isAllDay: input.isAllDay ?? false,
isRecurring: !!input.recurrenceRule,
calendarId: input.calendarId,
location: input.location,
});
CalendarEvents.eventCreated(!!input.recurrenceRule);
return { success: true, data: { id: eventId, timeBlockId } };
} catch (e) {
@ -142,6 +153,10 @@ export const eventsStore = {
await encryptRecord('events', localData);
await db.table('events').update(id, localData);
emitDomainEvent('CalendarEventUpdated', 'calendar', 'events', id, {
eventId: id,
fields: Object.keys(input).filter((k) => input[k as keyof typeof input] !== undefined),
});
CalendarEvents.eventUpdated();
return { success: true };
} catch (e) {
@ -342,6 +357,11 @@ export const eventsStore = {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
emitDomainEvent('CalendarEventDeleted', 'calendar', 'events', id, {
eventId: id,
title: event?.title ?? '',
wasRecurring: false,
});
CalendarEvents.eventDeleted();
return { success: true };
} catch (e) {

View file

@ -5,6 +5,7 @@
*/
import { encryptRecord } from '$lib/data/crypto';
import { emitDomainEvent } from '$lib/data/events';
import { drinkEntryTable, drinkPresetTable } from '../collections';
import { toDrinkEntry, toDrinkPreset, todayStr, nowTime } from '../queries';
import type { LocalDrinkEntry, LocalDrinkPreset, DrinkType } from '../types';
@ -34,6 +35,15 @@ export const drinkStore = {
const snapshot = toDrinkEntry({ ...newLocal });
await encryptRecord('drinkEntries', newLocal);
await drinkEntryTable.add(newLocal);
emitDomainEvent('DrinkLogged', 'drink', 'drinkEntries', newLocal.id, {
entryId: newLocal.id,
drinkType: input.drinkType,
quantityMl: input.quantityMl,
name: input.name,
date: newLocal.date,
time: newLocal.time,
fromPreset: !!input.presetId,
});
return snapshot;
},
@ -64,10 +74,18 @@ export const drinkStore = {
},
async deleteEntry(id: string) {
const entry = await drinkEntryTable.get(id);
await drinkEntryTable.update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
if (entry) {
emitDomainEvent('DrinkEntryDeleted', 'drink', 'drinkEntries', id, {
entryId: id,
drinkType: entry.drinkType,
quantityMl: entry.quantityMl,
});
}
},
async undoLastEntry() {
@ -76,10 +94,14 @@ export const drinkStore = {
.filter((e) => !e.deletedAt)
.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''));
if (active.length > 0) {
await drinkEntryTable.update(active[0].id, {
const entry = active[0];
await drinkEntryTable.update(entry.id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
emitDomainEvent('DrinkEntryUndone', 'drink', 'drinkEntries', entry.id, {
entryId: entry.id,
});
}
},

View file

@ -14,6 +14,7 @@
import { db } from '$lib/data/database';
import { encryptRecord, decryptRecord } from '$lib/data/crypto';
import { emitDomainEvent } from '$lib/data/events';
import {
uploadMealPhoto,
analyzeMealPhoto,
@ -74,6 +75,15 @@ export const mealMutations = {
const encrypted: Record<string, unknown> = { ...row };
await encryptRecord('meals', encrypted);
await db.table('meals').add(encrypted);
emitDomainEvent('MealLogged', 'nutriphi', 'meals', row.id, {
mealId: row.id,
mealType: dto.mealType,
inputType: 'text',
description: dto.description,
calories: dto.nutrition?.calories,
protein: dto.nutrition?.protein,
date: row.date,
});
return row;
},
@ -99,6 +109,13 @@ export const mealMutations = {
const encrypted: Record<string, unknown> = { ...row };
await encryptRecord('meals', encrypted);
await db.table('meals').add(encrypted);
emitDomainEvent('MealFromPhotoLogged', 'nutriphi', 'meals', row.id, {
mealId: row.id,
mealType: dto.mealType,
photoMediaId: dto.photoMediaId,
confidence: dto.confidence,
calories: dto.nutrition?.calories,
});
return row;
},
@ -131,8 +148,13 @@ export const mealMutations = {
},
async delete(id: string): Promise<void> {
const existing = await db.table<LocalMeal>('meals').get(id);
const now = new Date().toISOString();
await db.table('meals').update(id, { deletedAt: now, updatedAt: now });
emitDomainEvent('MealDeleted', 'nutriphi', 'meals', id, {
mealId: id,
mealType: existing?.mealType ?? '',
});
},
};

View file

@ -6,6 +6,7 @@
*/
import { encryptRecord, decryptRecord } from '$lib/data/crypto';
import { emitDomainEvent } from '$lib/data/events';
import { createBlock } from '$lib/data/time-blocks/service';
import { placeTable } from '../collections';
import { toPlace } from '../queries';
@ -41,6 +42,13 @@ export const placesStore = {
const plaintextSnapshot = toPlace({ ...newLocal });
await encryptRecord('places', newLocal);
await placeTable.add(newLocal);
emitDomainEvent('PlaceCreated', 'places', 'places', newLocal.id, {
placeId: newLocal.id,
name: data.name,
category: data.category,
lat: data.latitude,
lng: data.longitude,
});
return plaintextSnapshot;
},
@ -67,10 +75,16 @@ export const placesStore = {
},
async deletePlace(id: string) {
const local = await placeTable.get(id);
const decrypted = local ? await decryptRecord('places', { ...local }) : null;
await placeTable.update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
emitDomainEvent('PlaceDeleted', 'places', 'places', id, {
placeId: id,
name: (decrypted?.name as string) ?? '',
});
},
async toggleFavorite(id: string) {
@ -114,5 +128,10 @@ export const placesStore = {
title: placeName,
color: '#a855f7',
});
emitDomainEvent('PlaceVisited', 'places', 'places', id, {
placeId: id,
name: placeName,
visitCount: (local.visitCount ?? 0) + 1,
});
},
};

View file

@ -6,6 +6,7 @@
*/
import { decryptRecords, encryptRecord } from '$lib/data/crypto';
import { emitDomainEvent } from '$lib/data/events';
import { createBlock } from '$lib/data/time-blocks/service';
import { locationLogTable, placeTable } from '../collections';
import { getDistanceKm, findNearestPlace, toPlace } from '../queries';
@ -48,6 +49,9 @@ function startTracking() {
error = null;
isTracking = true;
emitDomainEvent('TrackingStarted', 'places', 'locationLogs', '', {
timestamp: new Date().toISOString(),
});
_watchId = navigator.geolocation.watchPosition(
async (pos) => {
@ -79,6 +83,10 @@ function stopTracking() {
_watchId = null;
}
isTracking = false;
emitDomainEvent('TrackingStopped', 'places', 'locationLogs', '', {
durationMs: 0,
logCount: 0,
});
}
async function getCurrentPosition(): Promise<GeolocationPosition | null> {
@ -135,6 +143,13 @@ async function logPosition(pos: GeolocationPosition) {
await encryptRecord('locationLogs', log);
await locationLogTable.add(log);
emitDomainEvent('LocationLogged', 'places', 'locationLogs', log.id, {
logId: log.id,
lat,
lng,
placeId: nearest?.id,
accuracy: pos.coords.accuracy,
});
// Update visit count on the matched place + create TimeBlock
if (nearest) {

View file

@ -10,6 +10,7 @@ import { toTask } from '../queries';
import type { LocalTask, TaskPriority, Subtask } from '../types';
import { createBlock, updateBlock, deleteBlock } from '$lib/data/time-blocks/service';
import { encryptRecord, decryptRecord } from '$lib/data/crypto';
import { emitDomainEvent } from '$lib/data/events';
import { transcribeAudio } from '$lib/voice/transcribe';
import { TodoEvents } from '@mana/shared-utils/analytics';
import { tagCollection, type LocalTag } from '@mana/shared-stores';
@ -153,6 +154,14 @@ export const tasksStore = {
const plaintextSnapshot = toTask({ ...newLocal });
await encryptRecord('tasks', newLocal);
await taskTable.add(newLocal);
emitDomainEvent('TaskCreated', 'todo', 'tasks', taskId, {
taskId,
title: plaintextSnapshot.title,
dueDate: data.dueDate,
priority: data.priority,
projectId: data.projectId,
labelIds: data.labelIds,
});
TodoEvents.taskCreated(!!data.dueDate);
return plaintextSnapshot;
},
@ -362,6 +371,7 @@ export const tasksStore = {
async deleteTask(id: string) {
const task = await taskTable.get(id);
const decrypted = task ? await decryptRecord('tasks', { ...task }) : null;
if (task?.scheduledBlockId) {
await deleteBlock(task.scheduledBlockId);
}
@ -370,24 +380,44 @@ export const tasksStore = {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
emitDomainEvent('TaskDeleted', 'todo', 'tasks', id, {
taskId: id,
title: (decrypted?.title as string) ?? '',
});
TodoEvents.taskDeleted();
},
async completeTask(id: string) {
const task = await taskTable.get(id);
const decrypted = task ? await decryptRecord('tasks', { ...task }) : null;
const now = new Date().toISOString();
const wasOverdue = task?.dueDate != null && (task.dueDate as string) < now.slice(0, 10);
await taskTable.update(id, {
isCompleted: true,
completedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
completedAt: now,
updatedAt: now,
});
emitDomainEvent('TaskCompleted', 'todo', 'tasks', id, {
taskId: id,
title: (decrypted?.title as string) ?? '',
projectId: task?.projectId,
wasOverdue,
});
TodoEvents.taskCompleted();
},
async uncompleteTask(id: string) {
const task = await taskTable.get(id);
const decrypted = task ? await decryptRecord('tasks', { ...task }) : null;
await taskTable.update(id, {
isCompleted: false,
completedAt: null,
updatedAt: new Date().toISOString(),
});
emitDomainEvent('TaskUncompleted', 'todo', 'tasks', id, {
taskId: id,
title: (decrypted?.title as string) ?? '',
});
},
async toggleComplete(id: string) {
@ -408,6 +438,11 @@ export const tasksStore = {
};
await encryptRecord('tasks', diff);
await taskTable.update(id, diff);
emitDomainEvent('SubtasksUpdated', 'todo', 'tasks', id, {
taskId: id,
total: subtasks.length,
completed: subtasks.filter((s) => s.isCompleted).length,
});
},
async updateLabels(id: string, labelIds: string[]) {

File diff suppressed because it is too large Load diff