mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
feat(sync): F3 — drop updatedAt as a synced data field
Removes `updatedAt` from the wire protocol and from every Local-prefixed
record type. Replaced by two orthogonal mechanisms — deriveUpdatedAt()
for read-side public-facing values, _updatedAtIndex shadow for indexed
sorts.
Local-side:
- New `_updatedAtIndex` shadow column. Stamped by the Dexie creating /
updating hook on every write. Stripped from the pending-change payload
so it never travels to mana-sync. Indexed in Dexie v53 on the 22 tables
that previously indexed `updatedAt`.
- `deriveUpdatedAt(record)` in sync.ts returns max(__fieldMeta[*].at) so
the public-facing Task / Note / etc. shape keeps an `updatedAt: string`
property without holding it as data.
- Type-converters across ~60 module/queries.ts and types.ts files now
call `deriveUpdatedAt(local)` instead of reading `local.updatedAt`.
Module-store sweep:
- Regex codemod removed `updatedAt: new Date().toISOString()` /
`: now` / `: now()` / `: nowIso()` stamping from 121 store files
(~382 call sites total). Single-property update calls
(`{ updatedAt: now }`) collapsed to `{}`; touch-only patterns
(writing/drafts, writing/generations) kept the call as a no-op
because the hook now stamps `_updatedAtIndex` automatically on
any Dexie modification.
- Local* interfaces stripped of `updatedAt: string` (43 types.ts files).
Public-facing types (Task, Note, Mission, Agent, …) keep
`updatedAt: string` as a computed read-side property.
- Companion's chat conversation now sorts on a real
`lastMessageAt` data field instead of touching `updatedAt`.
- Session-only stores (times/session-alarms, session-countdown-timers)
stamp `updatedAt: now` directly because they're not in Dexie and
have no field-meta layer to derive from.
Sync engine:
- applyServerChanges sets `_updatedAtIndex` itself when applying
server changes (max of server-field times for updates, recordTime
for inserts) so server-replays land orderable.
- Dropped the legacy `localUpdatedAt` fallback — every record now has
`__fieldMeta`, the per-field at is the canonical source.
- Soft-delete tombstone path stops stamping `updatedAt: serverTime`,
uses `_updatedAtIndex` instead.
Server-side:
- mana-ai iteration-writer no longer emits `updatedAt` in
sync_changes.data; receivers derive it from the field-meta map.
- mana-sync types: no change (the wire format already uses
`field_meta` / `at` from F1).
Out of scope: backend Drizzle schemas (mana-credits, mana-events, …)
keep their `updated_at` columns. Those are pure server-internal — not
part of the sync_changes / __fieldMeta mechanism F3 cleans up.
Tests + checks:
- 0 svelte-check errors over 7652 files.
- 29/29 sync.test.ts (vitest).
- 61 mana-ai bun tests.
- mana-sync go test ./... cached green.
Plan: docs/plans/sync-field-meta-overhaul.md F3.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e2676252d3
commit
6bb9d77be9
207 changed files with 1381 additions and 831 deletions
|
|
@ -12,6 +12,7 @@
|
|||
*/
|
||||
|
||||
import { db } from '$lib/data/database';
|
||||
import { deriveUpdatedAt } from '$lib/data/sync';
|
||||
import { wrapValue, unwrapValue } from '$lib/data/crypto/aes';
|
||||
import { getActiveKey } from '$lib/data/crypto/key-provider';
|
||||
import type { ByokProviderId } from '@mana/shared-llm';
|
||||
|
|
@ -48,7 +49,7 @@ async function recordToPlain(rec: ByokKeyRecord): Promise<ByokKeyPlain> {
|
|||
model: rec.model,
|
||||
isDefault: rec.isDefault,
|
||||
createdAt: rec.createdAt,
|
||||
updatedAt: rec.updatedAt,
|
||||
updatedAt: deriveUpdatedAt(rec),
|
||||
lastUsedAt: rec.lastUsedAt,
|
||||
usageCount: rec.usageCount,
|
||||
totalTokens: rec.totalTokens,
|
||||
|
|
@ -75,7 +76,7 @@ export const byokVault = {
|
|||
model: r.model,
|
||||
isDefault: r.isDefault,
|
||||
createdAt: r.createdAt,
|
||||
updatedAt: r.updatedAt,
|
||||
updatedAt: deriveUpdatedAt(r),
|
||||
lastUsedAt: r.lastUsedAt,
|
||||
usageCount: r.usageCount,
|
||||
totalTokens: r.totalTokens,
|
||||
|
|
|
|||
|
|
@ -141,7 +141,7 @@ export async function backfillMissionsAgentId(targetAgentId: string): Promise<nu
|
|||
const now = new Date().toISOString();
|
||||
await db.transaction('rw', table, async () => {
|
||||
for (const m of pending) {
|
||||
await table.update(m.id, { agentId: targetAgentId, updatedAt: now });
|
||||
await table.update(m.id, { agentId: targetAgentId });
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -54,7 +54,6 @@ export async function saveAgentKontext(agentId: string, content: string): Promis
|
|||
if (existing) {
|
||||
const diff: Partial<LocalAgentKontextDoc> = {
|
||||
content,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await encryptRecord(TABLE, diff);
|
||||
await db.table<LocalAgentKontextDoc>(TABLE).update(existing.id, diff);
|
||||
|
|
|
|||
|
|
@ -147,7 +147,6 @@ export async function updateAgent(id: string, patch: AgentPatch): Promise<void>
|
|||
}
|
||||
const mods: Partial<Agent> = {
|
||||
...deepClone(patch),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await encryptRecord(AGENTS_TABLE, mods);
|
||||
await table().update(id, mods);
|
||||
|
|
@ -156,15 +155,15 @@ export async function updateAgent(id: string, patch: AgentPatch): Promise<void>
|
|||
// ── Lifecycle ──────────────────────────────────────────────
|
||||
|
||||
export async function archiveAgent(id: string): Promise<void> {
|
||||
await table().update(id, { state: 'archived', updatedAt: new Date().toISOString() });
|
||||
await table().update(id, { state: 'archived' });
|
||||
}
|
||||
|
||||
export async function pauseAgent(id: string): Promise<void> {
|
||||
await table().update(id, { state: 'paused', updatedAt: new Date().toISOString() });
|
||||
await table().update(id, { state: 'paused' });
|
||||
}
|
||||
|
||||
export async function resumeAgent(id: string): Promise<void> {
|
||||
await table().update(id, { state: 'active', updatedAt: new Date().toISOString() });
|
||||
await table().update(id, { state: 'active' });
|
||||
}
|
||||
|
||||
/** Soft-delete. Missions owned by the agent keep running; the Workbench
|
||||
|
|
|
|||
|
|
@ -119,7 +119,6 @@ export async function updateMission(id: string, patch: MissionPatch): Promise<vo
|
|||
// Same Proxy-stripping reason as createMission.
|
||||
const mods: Partial<Mission> = {
|
||||
...deepClone(patch),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
if (patch.cadence) {
|
||||
mods.nextRunAt = nextRunForCadence(patch.cadence, new Date());
|
||||
|
|
@ -130,7 +129,7 @@ export async function updateMission(id: string, patch: MissionPatch): Promise<vo
|
|||
// ── Lifecycle ──────────────────────────────────────────────
|
||||
|
||||
export async function pauseMission(id: string): Promise<void> {
|
||||
await table().update(id, { state: 'paused', updatedAt: new Date().toISOString() });
|
||||
await table().update(id, { state: 'paused' });
|
||||
}
|
||||
|
||||
export async function resumeMission(id: string): Promise<void> {
|
||||
|
|
@ -139,7 +138,6 @@ export async function resumeMission(id: string): Promise<void> {
|
|||
await table().update(id, {
|
||||
state: 'active',
|
||||
nextRunAt: nextRunForCadence(mission.cadence, new Date()),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -147,12 +145,11 @@ export async function completeMission(id: string): Promise<void> {
|
|||
await table().update(id, {
|
||||
state: 'done',
|
||||
nextRunAt: undefined,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function archiveMission(id: string): Promise<void> {
|
||||
await table().update(id, { state: 'archived', updatedAt: new Date().toISOString() });
|
||||
await table().update(id, { state: 'archived' });
|
||||
}
|
||||
|
||||
export async function deleteMission(id: string): Promise<void> {
|
||||
|
|
@ -173,7 +170,6 @@ export async function setMissionGrant(
|
|||
// attached — matches the pattern used in createMission / updateMission.
|
||||
await table().update(id, {
|
||||
grant: deepClone(grant),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -183,7 +179,6 @@ export async function setMissionGrant(
|
|||
export async function revokeMissionGrant(id: string): Promise<void> {
|
||||
await table().update(id, {
|
||||
grant: undefined,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -213,7 +208,6 @@ export async function startIteration(
|
|||
};
|
||||
await table().update(missionId, {
|
||||
iterations: [...mission.iterations, iteration],
|
||||
updatedAt: now,
|
||||
});
|
||||
return iteration;
|
||||
}
|
||||
|
|
@ -242,7 +236,7 @@ export async function setIterationPhase(
|
|||
}
|
||||
: it
|
||||
);
|
||||
await table().update(missionId, { iterations: updated, updatedAt: now });
|
||||
await table().update(missionId, { iterations: updated });
|
||||
} catch (err) {
|
||||
console.warn('[mission-store] setIterationPhase failed:', err);
|
||||
}
|
||||
|
|
@ -263,7 +257,6 @@ export async function requestIterationCancel(
|
|||
);
|
||||
await table().update(missionId, {
|
||||
iterations: updated,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -317,7 +310,6 @@ export async function finishIteration(
|
|||
iterations: updatedIterations,
|
||||
// Advance nextRunAt now that this iteration is done
|
||||
nextRunAt: nextRunForCadence(mission.cadence, new Date()),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -334,6 +326,5 @@ export async function addIterationFeedback(
|
|||
);
|
||||
await table().update(missionId, {
|
||||
iterations: updatedIterations,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -153,7 +153,7 @@ async function restore(id: string): Promise<void> {
|
|||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const updates: Record<string, unknown> = { updatedAt: now };
|
||||
const updates: Record<string, unknown> = {};
|
||||
|
||||
for (const [field, info] of Object.entries(conflict.fields)) {
|
||||
updates[field] = info.wasLocal;
|
||||
|
|
|
|||
|
|
@ -1157,10 +1157,10 @@ db.version(48).upgrade(async (tx) => {
|
|||
}
|
||||
const survivorAppCount = Array.isArray(survivor.openApps) ? survivor.openApps.length : 0;
|
||||
if (merged.length !== survivorAppCount) {
|
||||
await tx.table('workbenchScenes').update(survivor.id, { openApps: merged, updatedAt: now });
|
||||
await tx.table('workbenchScenes').update(survivor.id, { openApps: merged });
|
||||
}
|
||||
for (const loser of losers) {
|
||||
await tx.table('workbenchScenes').update(loser.id, { deletedAt: now, updatedAt: now });
|
||||
await tx.table('workbenchScenes').update(loser.id, { deletedAt: now });
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -1260,6 +1260,97 @@ db.version(52).stores({
|
|||
lastsCooldown: 'id, refTable, dismissedAt, [refTable+refId]',
|
||||
});
|
||||
|
||||
// v53 — Sync Field-Meta Overhaul F3 (docs/plans/sync-field-meta-overhaul.md).
|
||||
// `updatedAt` is no longer a synced data field; replaced by a non-synced
|
||||
// shadow column `_updatedAtIndex` that the Dexie creating/updating hook
|
||||
// stamps on every write. All 22 tables that previously indexed `updatedAt`
|
||||
// now index `_updatedAtIndex` so module queries can keep using
|
||||
// `.orderBy(...)` for sort. The upgrade step copies the existing
|
||||
// updatedAt value into _updatedAtIndex so existing local rows stay
|
||||
// orderable across the version bump.
|
||||
db.version(53)
|
||||
.stores({
|
||||
conversations: 'id, isArchived, isPinned, spaceId, templateId, _updatedAtIndex',
|
||||
images:
|
||||
'id, isFavorite, isPublic, isArchived, prompt, _updatedAtIndex, wardrobeOutfitId, wardrobeGarmentId',
|
||||
songs: 'id, artist, album, genre, favorite, title, _updatedAtIndex',
|
||||
mukkePlaylists: 'id, name, _updatedAtIndex',
|
||||
presiDecks: 'id, isPublic, _updatedAtIndex',
|
||||
documents: 'id, contextSpaceId, type, pinned, title, [contextSpaceId+type], _updatedAtIndex',
|
||||
playgroundConversations: 'id, model, isPinned, _updatedAtIndex',
|
||||
journalEntries: 'id, entryDate, mood, isPinned, isArchived, isFavorite, _updatedAtIndex',
|
||||
dreams: 'id, dreamDate, mood, isLucid, isPinned, isArchived, _updatedAtIndex',
|
||||
dreamSymbols: 'id, name, count, _updatedAtIndex',
|
||||
periods: 'id, startDate, endDate, isPredicted, isArchived, _updatedAtIndex',
|
||||
periodSymptoms: 'id, name, category, count, _updatedAtIndex',
|
||||
notes: 'id, isPinned, isArchived, color, title, _updatedAtIndex',
|
||||
quizzes: 'id, isPinned, isArchived, _updatedAtIndex',
|
||||
userTagPresets: 'id, userId, isDefault, _updatedAtIndex',
|
||||
websites: 'id, slug, publishedVersion, _updatedAtIndex, deletedAt',
|
||||
websitePages: 'id, siteId, [siteId+order], [siteId+path], _updatedAtIndex, deletedAt',
|
||||
websiteBlocks:
|
||||
'id, pageId, parentBlockId, [pageId+order], [pageId+parentBlockId+order], type, _updatedAtIndex, deletedAt',
|
||||
writingDrafts: 'id, kind, status, _updatedAtIndex, isFavorite',
|
||||
writingStyles: 'id, source, isSpaceDefault, isFavorite, _updatedAtIndex',
|
||||
})
|
||||
.upgrade(async (tx) => {
|
||||
// Copy the existing `updatedAt` value into `_updatedAtIndex` for every
|
||||
// row in the affected tables so post-upgrade sorts continue to land
|
||||
// in the right order. Rows without an updatedAt fall back to
|
||||
// `__fieldMeta` argmax, then to createdAt, then to empty string.
|
||||
const tables = [
|
||||
'conversations',
|
||||
'images',
|
||||
'songs',
|
||||
'mukkePlaylists',
|
||||
'presiDecks',
|
||||
'documents',
|
||||
'playgroundConversations',
|
||||
'journalEntries',
|
||||
'dreams',
|
||||
'dreamSymbols',
|
||||
'periods',
|
||||
'periodSymptoms',
|
||||
'notes',
|
||||
'quizzes',
|
||||
'userTagPresets',
|
||||
'websites',
|
||||
'websitePages',
|
||||
'websiteBlocks',
|
||||
'writingDrafts',
|
||||
'writingStyles',
|
||||
];
|
||||
for (const tableName of tables) {
|
||||
await tx
|
||||
.table(tableName)
|
||||
.toCollection()
|
||||
.modify((row: Record<string, unknown>) => {
|
||||
const updatedAt =
|
||||
(typeof row.updatedAt === 'string' ? row.updatedAt : undefined) ??
|
||||
deriveFromFieldMeta(row) ??
|
||||
(typeof row.createdAt === 'string' ? row.createdAt : undefined) ??
|
||||
'';
|
||||
row._updatedAtIndex = updatedAt;
|
||||
// Keep `updatedAt` on the row for now — the F3 store/type
|
||||
// sweep below renames every read. The next-version
|
||||
// upgrade (or a follow-up cleanup) can drop it once all
|
||||
// modules read via the type-converter helper.
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/** Local helper for the v53 upgrade — read `__fieldMeta` argmax `at`
|
||||
* without importing the sync layer (which would create a cycle here). */
|
||||
function deriveFromFieldMeta(row: Record<string, unknown>): string | undefined {
|
||||
const meta = row[FIELD_META_KEY];
|
||||
if (!meta || typeof meta !== 'object') return undefined;
|
||||
let max = '';
|
||||
for (const fm of Object.values(meta as Record<string, { at?: string }>)) {
|
||||
if (fm && typeof fm.at === 'string' && fm.at > max) max = fm.at;
|
||||
}
|
||||
return max || undefined;
|
||||
}
|
||||
|
||||
// ─── 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
|
||||
|
|
@ -1403,8 +1494,17 @@ function trackActivity(
|
|||
*/
|
||||
export const FIELD_META_KEY = '__fieldMeta';
|
||||
|
||||
/**
|
||||
* Local-only shadow column the Dexie hook stamps on every create + update
|
||||
* write. Holds the latest `now` ISO so module queries can `.orderBy(...)`
|
||||
* by it instead of the (now derived) `updatedAt`. Stripped from
|
||||
* pending-change payloads so it never travels to mana-sync — it is rebuilt
|
||||
* locally on each write, including server-replays via applyServerChanges.
|
||||
*/
|
||||
export const UPDATED_AT_INDEX_KEY = '_updatedAtIndex';
|
||||
|
||||
function isInternalKey(key: string): boolean {
|
||||
return key === 'id' || key === FIELD_META_KEY;
|
||||
return key === 'id' || key === FIELD_META_KEY || key === UPDATED_AT_INDEX_KEY;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1513,9 +1613,16 @@ for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) {
|
|||
fieldMeta[key] = makeFieldMeta(now, actor, origin);
|
||||
}
|
||||
objRecord[FIELD_META_KEY] = fieldMeta;
|
||||
// Stamp the local-only shadow column for indexed sorts. Stripped
|
||||
// from `dataForSync` below so it never lands in pending-changes.
|
||||
objRecord[UPDATED_AT_INDEX_KEY] = now;
|
||||
|
||||
// Build payload for pending-change WITHOUT the internal bookkeeping field.
|
||||
const { [FIELD_META_KEY]: _fm, ...dataForSync } = obj as Record<string, unknown>;
|
||||
// Build payload for pending-change WITHOUT the internal bookkeeping fields.
|
||||
const {
|
||||
[FIELD_META_KEY]: _fm,
|
||||
[UPDATED_AT_INDEX_KEY]: _uai,
|
||||
...dataForSync
|
||||
} = obj as Record<string, unknown>;
|
||||
|
||||
trackPendingChange(tableName, {
|
||||
appId,
|
||||
|
|
@ -1604,9 +1711,12 @@ for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) {
|
|||
|
||||
// Returning an object from a Dexie 'updating' hook merges it into the
|
||||
// modifications applied to the record — use this to persist the merged
|
||||
// __fieldMeta alongside the user's data update.
|
||||
// __fieldMeta alongside the user's data update, plus refresh the
|
||||
// local-only `_updatedAtIndex` shadow so indexed sorts see the
|
||||
// latest write.
|
||||
return {
|
||||
[FIELD_META_KEY]: newMeta,
|
||||
[UPDATED_AT_INDEX_KEY]: now,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -175,7 +175,6 @@ export function useDetailEntity<T extends { id?: string }>(
|
|||
toastStore.undo(label, () => {
|
||||
db.table(opts.table!).update(id, {
|
||||
deletedAt: undefined,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -74,21 +74,18 @@ function createCollectionWrapper<T extends BaseRecord>(tableName: string) {
|
|||
await table.put({
|
||||
...record,
|
||||
createdAt: record.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
});
|
||||
},
|
||||
|
||||
async update(id: string, changes: Partial<T>): Promise<void> {
|
||||
await table.update(id, {
|
||||
...changes,
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as any);
|
||||
},
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await table.update(id, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as any);
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -65,7 +65,6 @@ export async function seedWorkbenchHomeOn(
|
|||
openApps: DEFAULT_HOME_APPS,
|
||||
order: 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
spaceId,
|
||||
};
|
||||
await table.add(row);
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
fromSyncName,
|
||||
beginApplyingTables,
|
||||
FIELD_META_KEY,
|
||||
UPDATED_AT_INDEX_KEY,
|
||||
setPendingChangeListener,
|
||||
} from './database';
|
||||
import { isQuotaError, cleanupTombstones, notifyQuotaExceeded } from './quota';
|
||||
|
|
@ -44,6 +45,30 @@ export function readFieldMeta(record: unknown): Record<string, FieldMeta> {
|
|||
return fm && typeof fm === 'object' ? (fm as Record<string, FieldMeta>) : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a record's "last modified" timestamp from its `__fieldMeta`
|
||||
* map. Returns the max `at` across all per-field entries — empty
|
||||
* string when the record has no field-meta yet (only seen on records
|
||||
* mid-write or pre-hook).
|
||||
*
|
||||
* Replaces the older synced `updatedAt` data field. F3 of the
|
||||
* sync-field-meta overhaul moved updatedAt from a wire-protocol
|
||||
* property to a read-side computed property — type converters call
|
||||
* this to populate the public-facing `updatedAt` from the merged
|
||||
* field-meta map.
|
||||
*
|
||||
* For Dexie indexed sorting, prefer the non-synced `_updatedAtIndex`
|
||||
* shadow column the creating/updating hook stamps on every write.
|
||||
*/
|
||||
export function deriveUpdatedAt(record: unknown): string {
|
||||
const meta = readFieldMeta(record);
|
||||
let max = '';
|
||||
for (const fm of Object.values(meta)) {
|
||||
if (fm.at > max) max = fm.at;
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────
|
||||
|
||||
/** Operations the sync protocol supports. */
|
||||
|
|
@ -370,12 +395,11 @@ export async function applyServerChanges(
|
|||
const tombActor: Actor = change.actor ?? USER_ACTOR;
|
||||
await table.update(recordId, {
|
||||
deletedAt: serverTime,
|
||||
updatedAt: serverTime,
|
||||
[FIELD_META_KEY]: {
|
||||
...localMeta,
|
||||
deletedAt: makeFieldMeta(serverTime, tombActor, replayOrigin),
|
||||
updatedAt: makeFieldMeta(serverTime, tombActor, replayOrigin),
|
||||
},
|
||||
[UPDATED_AT_INDEX_KEY]: serverTime,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
|
|
@ -405,17 +429,16 @@ export async function applyServerChanges(
|
|||
...changeData,
|
||||
id: recordId,
|
||||
[FIELD_META_KEY]: fieldMeta,
|
||||
[UPDATED_AT_INDEX_KEY]: recordTime,
|
||||
});
|
||||
} else {
|
||||
const localMeta = readFieldMeta(existing);
|
||||
const localUpdatedAt =
|
||||
((existing as Record<string, unknown>).updatedAt as string | undefined) ?? '';
|
||||
const updates: Record<string, unknown> = {};
|
||||
const newMeta: Record<string, FieldMeta> = { ...localMeta };
|
||||
|
||||
for (const [key, val] of Object.entries(changeData)) {
|
||||
if (key === 'id' || key === FIELD_META_KEY) continue;
|
||||
const localFieldTime = localMeta[key]?.at ?? localUpdatedAt;
|
||||
const localFieldTime = localMeta[key]?.at ?? '';
|
||||
if (recordTime >= localFieldTime) {
|
||||
// Conflict signal: server STRICTLY wins (>), the local
|
||||
// field had a non-empty value that differs from the new
|
||||
|
|
@ -448,6 +471,7 @@ export async function applyServerChanges(
|
|||
}
|
||||
if (Object.keys(updates).length > 0) {
|
||||
updates[FIELD_META_KEY] = newMeta;
|
||||
updates[UPDATED_AT_INDEX_KEY] = recordTime;
|
||||
await table.update(recordId, updates);
|
||||
}
|
||||
}
|
||||
|
|
@ -464,23 +488,26 @@ export async function applyServerChanges(
|
|||
const record: Record<string, unknown> = { id: recordId };
|
||||
const fieldMeta: Record<string, FieldMeta> = {};
|
||||
const fallback = new Date().toISOString();
|
||||
let maxAt = '';
|
||||
for (const [key, fc] of Object.entries(serverFields)) {
|
||||
record[key] = fc.value;
|
||||
fieldMeta[key] = makeFieldMeta(fc.at ?? fallback, changeActor, replayOrigin);
|
||||
const at = fc.at ?? fallback;
|
||||
fieldMeta[key] = makeFieldMeta(at, changeActor, replayOrigin);
|
||||
if (at > maxAt) maxAt = at;
|
||||
}
|
||||
record[FIELD_META_KEY] = fieldMeta;
|
||||
record[UPDATED_AT_INDEX_KEY] = maxAt || fallback;
|
||||
await table.put(record);
|
||||
} else {
|
||||
// Per-field comparison.
|
||||
const localMeta = readFieldMeta(existing);
|
||||
const localUpdatedAt =
|
||||
((existing as Record<string, unknown>).updatedAt as string | undefined) ?? '';
|
||||
const updates: Record<string, unknown> = {};
|
||||
const newMeta: Record<string, FieldMeta> = { ...localMeta };
|
||||
let maxApplied = '';
|
||||
|
||||
for (const [key, fc] of Object.entries(serverFields)) {
|
||||
const serverTime = fc.at ?? '';
|
||||
const localFieldTime = localMeta[key]?.at ?? localUpdatedAt;
|
||||
const localFieldTime = localMeta[key]?.at ?? '';
|
||||
if (serverTime >= localFieldTime) {
|
||||
// Same conflict criteria as the insert-as-update path:
|
||||
// strictly newer + non-empty local + actually different
|
||||
|
|
@ -506,10 +533,12 @@ export async function applyServerChanges(
|
|||
}
|
||||
updates[key] = fc.value;
|
||||
newMeta[key] = makeFieldMeta(serverTime, changeActor, replayOrigin);
|
||||
if (serverTime > maxApplied) maxApplied = serverTime;
|
||||
}
|
||||
}
|
||||
if (Object.keys(updates).length > 0) {
|
||||
updates[FIELD_META_KEY] = newMeta;
|
||||
if (maxApplied) updates[UPDATED_AT_INDEX_KEY] = maxApplied;
|
||||
await table.update(recordId, updates);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ async function clearDefaultFlag(userId: string, exceptId?: string): Promise<void
|
|||
.and((p) => p.isDefault && p.id !== exceptId)
|
||||
.toArray();
|
||||
for (const row of rows) {
|
||||
await table.update(row.id, { isDefault: false, updatedAt: now() });
|
||||
await table.update(row.id, { isDefault: false });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -85,7 +85,6 @@ export const tagPresetsStore = {
|
|||
isDefault: input.isDefault ?? false,
|
||||
tags: input.tags ?? [],
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
|
||||
if (newLocal.isDefault) await clearDefaultFlag(userId, newLocal.id);
|
||||
|
|
@ -108,21 +107,20 @@ export const tagPresetsStore = {
|
|||
...(input.name !== undefined && { name: input.name }),
|
||||
...(input.tags !== undefined && { tags: input.tags }),
|
||||
...(input.isDefault !== undefined && { isDefault: input.isDefault }),
|
||||
updatedAt: now(),
|
||||
};
|
||||
await encryptRecord('userTagPresets', diff);
|
||||
await table.update(id, diff);
|
||||
},
|
||||
|
||||
async deletePreset(id: string): Promise<void> {
|
||||
await table.update(id, { deletedAt: now(), updatedAt: now() });
|
||||
await table.update(id, { deletedAt: now() });
|
||||
},
|
||||
|
||||
async setDefault(id: string): Promise<void> {
|
||||
const existing = await table.get(id);
|
||||
if (!existing) throw new Error(`Preset ${id} not found`);
|
||||
await clearDefaultFlag(existing.userId, id);
|
||||
await table.update(id, { isDefault: true, updatedAt: now() });
|
||||
await table.update(id, { isDefault: true });
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { deriveUpdatedAt } from '$lib/data/sync';
|
||||
/**
|
||||
* User-level tag presets.
|
||||
*
|
||||
|
|
@ -33,11 +34,15 @@ export interface LocalUserTagPreset {
|
|||
isDefault: boolean;
|
||||
tags: TagPresetEntry[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt?: string;
|
||||
}
|
||||
|
||||
export type UserTagPreset = Omit<LocalUserTagPreset, 'deletedAt'>;
|
||||
export type UserTagPreset = Omit<LocalUserTagPreset, 'deletedAt'> & {
|
||||
/** Computed read-side property: max(__fieldMeta[*].at). The Local
|
||||
* record stamps `_updatedAtIndex` for sort indexes; consumers see
|
||||
* `updatedAt` here via {@link toUserTagPreset}. */
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export function toUserTagPreset(local: LocalUserTagPreset): UserTagPreset {
|
||||
return {
|
||||
|
|
@ -47,7 +52,7 @@ export function toUserTagPreset(local: LocalUserTagPreset): UserTagPreset {
|
|||
isDefault: local.isDefault,
|
||||
tags: local.tags ?? [],
|
||||
createdAt: local.createdAt,
|
||||
updatedAt: local.updatedAt,
|
||||
updatedAt: deriveUpdatedAt(local),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -58,7 +58,6 @@ export const TIME_BLOCK_GUEST_SEED = {
|
|||
icon: null,
|
||||
projectId: null,
|
||||
createdAt: nowISO,
|
||||
updatedAt: nowISO,
|
||||
},
|
||||
{
|
||||
id: 'sample-tb-event-2',
|
||||
|
|
@ -79,7 +78,6 @@ export const TIME_BLOCK_GUEST_SEED = {
|
|||
icon: null,
|
||||
projectId: null,
|
||||
createdAt: nowISO,
|
||||
updatedAt: nowISO,
|
||||
},
|
||||
] satisfies LocalTimeBlock[];
|
||||
})(),
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
*/
|
||||
|
||||
import { liveQuery } from 'dexie';
|
||||
import { deriveUpdatedAt } from '$lib/data/sync';
|
||||
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
|
||||
import { db } from '$lib/data/database';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
|
|
@ -46,7 +47,7 @@ export function toTimeBlock(local: LocalTimeBlock): TimeBlock {
|
|||
icon: local.icon ?? null,
|
||||
projectId: local.projectId ?? null,
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: local.updatedAt ?? new Date().toISOString(),
|
||||
updatedAt: deriveUpdatedAt(local),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
*/
|
||||
|
||||
import { RRule } from 'rrule';
|
||||
import { deriveUpdatedAt } from '$lib/data/sync';
|
||||
import { db } from '$lib/data/database';
|
||||
import { timeBlockTable } from './collections';
|
||||
import { createBlock, deleteBlock } from './service';
|
||||
|
|
@ -311,7 +312,7 @@ export function expandTemplatesVirtually(
|
|||
linkedBlockId: null,
|
||||
isVirtual: true,
|
||||
createdAt: template.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: template.updatedAt ?? new Date().toISOString(),
|
||||
updatedAt: deriveUpdatedAt(template),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,6 @@ export async function createBlock(input: CreateTimeBlockInput): Promise<string>
|
|||
icon: input.icon ?? null,
|
||||
projectId: input.projectId ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
// Encrypt configured fields (title + description) before write.
|
||||
|
|
@ -60,7 +59,6 @@ export async function updateBlock(id: string, input: UpdateTimeBlockInput): Prom
|
|||
const now = new Date().toISOString();
|
||||
const diff: Partial<LocalTimeBlock> = {
|
||||
...input,
|
||||
updatedAt: now,
|
||||
};
|
||||
await encryptRecord('timeBlocks', diff);
|
||||
await timeBlockTable.update(id, diff);
|
||||
|
|
@ -71,7 +69,6 @@ export async function deleteBlock(id: string): Promise<void> {
|
|||
const now = new Date().toISOString();
|
||||
await timeBlockTable.update(id, {
|
||||
deletedAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -85,8 +82,8 @@ export async function linkBlocks(scheduledId: string, loggedId: string): Promise
|
|||
if (scheduled.kind !== 'scheduled') throw new Error('First block must be scheduled');
|
||||
if (logged.kind !== 'logged') throw new Error('Second block must be logged');
|
||||
|
||||
await timeBlockTable.update(scheduledId, { linkedBlockId: loggedId, updatedAt: now });
|
||||
await timeBlockTable.update(loggedId, { linkedBlockId: scheduledId, updatedAt: now });
|
||||
await timeBlockTable.update(scheduledId, { linkedBlockId: loggedId });
|
||||
await timeBlockTable.update(loggedId, { linkedBlockId: scheduledId });
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -125,7 +122,7 @@ export async function startFromScheduled(
|
|||
});
|
||||
|
||||
// Link back from scheduled → logged
|
||||
await timeBlockTable.update(scheduledId, { linkedBlockId: loggedId, updatedAt: now });
|
||||
await timeBlockTable.update(scheduledId, { linkedBlockId: loggedId });
|
||||
|
||||
return loggedId;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@ export async function runArticlesFromNewsMigration(): Promise<number> {
|
|||
// the server + other devices. Keep it in the local table so
|
||||
// if someone later rolls back the migration they can still
|
||||
// see what was there.
|
||||
await newsTable.update(row.id, { deletedAt: now, updatedAt: now });
|
||||
await newsTable.update(row.id, { deletedAt: now });
|
||||
moved++;
|
||||
} catch (rowErr) {
|
||||
console.warn(`[articles/from-news] skipping row ${row.id} — ${(rowErr as Error).message}`);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
*/
|
||||
|
||||
import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte';
|
||||
import { deriveUpdatedAt } from '$lib/data/sync';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import { scopedForModule, scopedGet } from '$lib/data/scope';
|
||||
import { articleTagOps } from './stores/tags.svelte';
|
||||
|
|
@ -37,7 +38,7 @@ export function toArticle(local: LocalArticle): Article {
|
|||
userNote: local.userNote ?? null,
|
||||
extractedVersion: local.extractedVersion ?? 1,
|
||||
createdAt: local.createdAt ?? now,
|
||||
updatedAt: local.updatedAt ?? now,
|
||||
updatedAt: deriveUpdatedAt(local),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -54,7 +55,7 @@ export function toHighlight(local: LocalHighlight): Highlight {
|
|||
contextBefore: local.contextBefore ?? null,
|
||||
contextAfter: local.contextAfter ?? null,
|
||||
createdAt: local.createdAt ?? now,
|
||||
updatedAt: local.updatedAt ?? now,
|
||||
updatedAt: deriveUpdatedAt(local),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ export const articlesStore = {
|
|||
async setStatus(id: string, status: ArticleStatus): Promise<void> {
|
||||
const diff: Partial<LocalArticle> = {
|
||||
status,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
if (status === 'finished') {
|
||||
const existing = await articleTable.get(id);
|
||||
|
|
@ -34,7 +33,6 @@ export const articlesStore = {
|
|||
if (!existing) return;
|
||||
await articleTable.update(id, {
|
||||
isFavorite: !existing.isFavorite,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -42,14 +40,12 @@ export const articlesStore = {
|
|||
const clamped = Math.max(0, Math.min(1, progress));
|
||||
await articleTable.update(id, {
|
||||
readingProgress: clamped,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
async updateNote(id: string, note: string | null): Promise<void> {
|
||||
const diff: Partial<LocalArticle> = {
|
||||
userNote: note,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await encryptRecord('articles', diff as LocalArticle);
|
||||
await articleTable.update(id, diff);
|
||||
|
|
@ -58,7 +54,6 @@ export const articlesStore = {
|
|||
async deleteArticle(id: string): Promise<void> {
|
||||
await articleTable.update(id, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -44,14 +44,12 @@ export const highlightsStore = {
|
|||
async setColor(id: string, color: HighlightColor): Promise<void> {
|
||||
await articleHighlightTable.update(id, {
|
||||
color,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
async setNote(id: string, note: string | null): Promise<void> {
|
||||
const diff: Partial<LocalHighlight> = {
|
||||
note,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await encryptRecord('articleHighlights', diff as LocalHighlight);
|
||||
await articleHighlightTable.update(id, diff);
|
||||
|
|
@ -60,7 +58,6 @@ export const highlightsStore = {
|
|||
async deleteHighlight(id: string): Promise<void> {
|
||||
await articleHighlightTable.update(id, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte';
|
||||
import { deriveUpdatedAt } from '$lib/data/sync';
|
||||
import { scopedForModule } from '$lib/data/scope';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import { isDue } from './lib/reminders';
|
||||
|
|
@ -32,7 +33,7 @@ export function toAugurEntry(local: LocalAugurEntry): AugurEntry {
|
|||
unlistedToken: local.unlistedToken ?? '',
|
||||
unlistedExpiresAt: local.unlistedExpiresAt ?? null,
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: local.updatedAt ?? new Date().toISOString(),
|
||||
updatedAt: deriveUpdatedAt(local),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -95,7 +95,6 @@ export const augurStore = {
|
|||
) {
|
||||
const diff: Partial<LocalAugurEntry> = {
|
||||
...data,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await encryptRecord('augurEntries', diff);
|
||||
await augurEntriesTable.update(id, diff);
|
||||
|
|
@ -106,7 +105,6 @@ export const augurStore = {
|
|||
outcome,
|
||||
outcomeNote: note ?? null,
|
||||
resolvedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await encryptRecord('augurEntries', diff);
|
||||
await augurEntriesTable.update(id, diff);
|
||||
|
|
@ -115,7 +113,6 @@ export const augurStore = {
|
|||
async archiveEntry(id: string) {
|
||||
await augurEntriesTable.update(id, {
|
||||
isArchived: true,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -141,7 +138,6 @@ export const augurStore = {
|
|||
|
||||
await augurEntriesTable.update(id, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -161,7 +157,6 @@ export const augurStore = {
|
|||
visibility: next,
|
||||
visibilityChangedAt: now,
|
||||
visibilityChangedBy: getEffectiveUserId() ?? undefined,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
if (next === 'unlisted') {
|
||||
|
|
@ -230,7 +225,6 @@ export const augurStore = {
|
|||
});
|
||||
await augurEntriesTable.update(id, {
|
||||
unlistedToken: token,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
return token;
|
||||
} catch (e) {
|
||||
|
|
@ -264,7 +258,6 @@ export const augurStore = {
|
|||
});
|
||||
await augurEntriesTable.update(id, {
|
||||
unlistedExpiresAt: expiresAt ? expiresAt.toISOString() : null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[augur] setUnlistedExpiry failed', e);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
*/
|
||||
|
||||
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
|
||||
import { deriveUpdatedAt } from '$lib/data/sync';
|
||||
import { automationTable } from './collections';
|
||||
import type { LocalAutomation } from './types';
|
||||
|
||||
|
|
@ -42,7 +43,7 @@ export function toAutomation(local: LocalAutomation): Automation {
|
|||
targetAction: local.targetAction,
|
||||
targetParams: local.targetParams,
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: local.updatedAt ?? new Date().toISOString(),
|
||||
updatedAt: deriveUpdatedAt(local),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -37,7 +37,6 @@ export const automationsStore = {
|
|||
targetAction: data.targetAction,
|
||||
targetParams: data.targetParams,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
await automationTable.add(auto);
|
||||
await loadAutomations();
|
||||
|
|
@ -47,7 +46,6 @@ export const automationsStore = {
|
|||
async update(id: string, data: Partial<LocalAutomation>) {
|
||||
await automationTable.update(id, {
|
||||
...data,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
await loadAutomations();
|
||||
},
|
||||
|
|
@ -57,7 +55,6 @@ export const automationsStore = {
|
|||
if (!auto) return;
|
||||
await automationTable.update(id, {
|
||||
enabled: !auto.enabled,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
await loadAutomations();
|
||||
},
|
||||
|
|
@ -65,7 +62,6 @@ export const automationsStore = {
|
|||
async remove(id: string) {
|
||||
await automationTable.update(id, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
await loadAutomations();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
*/
|
||||
|
||||
import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte';
|
||||
import { deriveUpdatedAt } from '$lib/data/sync';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import { db } from '$lib/data/database';
|
||||
import { scopedForModule } from '$lib/data/scope';
|
||||
|
|
@ -39,7 +40,7 @@ export function toBodyExercise(local: LocalBodyExercise): BodyExercise {
|
|||
isArchived: local.isArchived,
|
||||
isPreset: local.isPreset,
|
||||
createdAt: local.createdAt ?? now,
|
||||
updatedAt: local.updatedAt ?? now,
|
||||
updatedAt: deriveUpdatedAt(local),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -53,7 +54,7 @@ export function toBodyRoutine(local: LocalBodyRoutine): BodyRoutine {
|
|||
order: local.order,
|
||||
isArchived: local.isArchived,
|
||||
createdAt: local.createdAt ?? now,
|
||||
updatedAt: local.updatedAt ?? now,
|
||||
updatedAt: deriveUpdatedAt(local),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -68,7 +69,7 @@ export function toBodyWorkout(local: LocalBodyWorkout): BodyWorkout {
|
|||
notes: local.notes ?? null,
|
||||
rpe: local.rpe ?? null,
|
||||
createdAt: local.createdAt ?? now,
|
||||
updatedAt: local.updatedAt ?? now,
|
||||
updatedAt: deriveUpdatedAt(local),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -124,7 +125,7 @@ export function toBodyPhase(local: LocalBodyPhase): BodyPhase {
|
|||
targetWeight: local.targetWeight ?? null,
|
||||
notes: local.notes ?? null,
|
||||
createdAt: local.createdAt ?? now,
|
||||
updatedAt: local.updatedAt ?? now,
|
||||
updatedAt: deriveUpdatedAt(local),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -82,7 +82,6 @@ export const bodyStore = {
|
|||
const wrapped = await encryptRecord('bodyExercises', { ...patch });
|
||||
await bodyExerciseTable.update(id, {
|
||||
...wrapped,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -95,7 +94,6 @@ export const bodyStore = {
|
|||
if (!exercise || exercise.isPreset) return;
|
||||
await bodyExerciseTable.update(id, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -128,14 +126,12 @@ export const bodyStore = {
|
|||
const wrapped = await encryptRecord('bodyRoutines', { ...patch });
|
||||
await bodyRoutineTable.update(id, {
|
||||
...wrapped,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
async deleteRoutine(id: string) {
|
||||
await bodyRoutineTable.update(id, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -202,7 +198,6 @@ export const bodyStore = {
|
|||
const wrapped = await encryptRecord('bodyWorkouts', { ...update });
|
||||
await bodyWorkoutTable.update(id, {
|
||||
...wrapped,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
// Stamp the TimeBlock's endDate so the calendar shows duration.
|
||||
|
|
@ -229,7 +224,6 @@ export const bodyStore = {
|
|||
const wrapped = await encryptRecord('bodyWorkouts', { ...patch });
|
||||
await bodyWorkoutTable.update(id, {
|
||||
...wrapped,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -238,7 +232,7 @@ export const bodyStore = {
|
|||
// stop counting them. Also remove the linked TimeBlock.
|
||||
const workout = await bodyWorkoutTable.get(id);
|
||||
const now = new Date().toISOString();
|
||||
await bodyWorkoutTable.update(id, { deletedAt: now, updatedAt: now });
|
||||
await bodyWorkoutTable.update(id, { deletedAt: now });
|
||||
const sets = await bodySetTable.where('workoutId').equals(id).toArray();
|
||||
for (const s of sets) {
|
||||
await bodySetTable.update(s.id, { deletedAt: now });
|
||||
|
|
@ -373,7 +367,6 @@ export const bodyStore = {
|
|||
const wrapped = await encryptRecord('bodyChecks', { ...patch });
|
||||
await bodyCheckTable.update(existing.id, {
|
||||
...wrapped,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
return toBodyCheck({ ...existing, ...patch });
|
||||
}
|
||||
|
|
@ -437,7 +430,6 @@ export const bodyStore = {
|
|||
async endPhase(id: string) {
|
||||
await bodyPhaseTable.update(id, {
|
||||
endDate: new Date().toISOString().split('T')[0],
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -453,14 +445,12 @@ export const bodyStore = {
|
|||
const wrapped = await encryptRecord('bodyPhases', { ...patch });
|
||||
await bodyPhaseTable.update(id, {
|
||||
...wrapped,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
async deletePhase(id: string) {
|
||||
await bodyPhaseTable.update(id, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
*/
|
||||
|
||||
import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte';
|
||||
import { deriveUpdatedAt } from '$lib/data/sync';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import { scopedForModule } from '$lib/data/scope';
|
||||
import { campaignTable, templateTable, settingsTable } from './collections';
|
||||
|
|
@ -42,7 +43,7 @@ export function toCampaign(local: LocalCampaign): Campaign {
|
|||
serverJobId: local.serverJobId ?? null,
|
||||
stats: local.stats ?? null,
|
||||
createdAt: local.createdAt ?? now,
|
||||
updatedAt: local.updatedAt ?? now,
|
||||
updatedAt: deriveUpdatedAt(local),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -66,7 +66,6 @@ export const broadcastCampaignsStore = {
|
|||
serverJobId: null,
|
||||
stats: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await encryptRecord('broadcastCampaigns', newLocal);
|
||||
|
|
@ -99,10 +98,7 @@ export const broadcastCampaignsStore = {
|
|||
}
|
||||
const wrapped = { ...patch } as Record<string, unknown>;
|
||||
await encryptRecord('broadcastCampaigns', wrapped);
|
||||
await campaignTable.update(id, {
|
||||
...wrapped,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
await campaignTable.update(id, wrapped as never);
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
@ -119,10 +115,7 @@ export const broadcastCampaignsStore = {
|
|||
}
|
||||
const patch = { content } as Record<string, unknown>;
|
||||
await encryptRecord('broadcastCampaigns', patch);
|
||||
await campaignTable.update(id, {
|
||||
...patch,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
await campaignTable.update(id, patch as never);
|
||||
},
|
||||
|
||||
async updateAudience(id: string, audience: AudienceDefinition) {
|
||||
|
|
@ -133,10 +126,7 @@ export const broadcastCampaignsStore = {
|
|||
}
|
||||
const patch = { audience } as Record<string, unknown>;
|
||||
await encryptRecord('broadcastCampaigns', patch);
|
||||
await campaignTable.update(id, {
|
||||
...patch,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
await campaignTable.update(id, patch as never);
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
@ -151,7 +141,6 @@ export const broadcastCampaignsStore = {
|
|||
await campaignTable.update(id, {
|
||||
status: 'scheduled' as CampaignStatus,
|
||||
scheduledAt,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
emitDomainEvent('BroadcastCampaignScheduled', 'broadcast', 'broadcastCampaigns', id, {
|
||||
campaignId: id,
|
||||
|
|
@ -169,7 +158,6 @@ export const broadcastCampaignsStore = {
|
|||
await campaignTable.update(id, {
|
||||
status: 'cancelled' as CampaignStatus,
|
||||
scheduledAt: null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
emitDomainEvent('BroadcastCampaignCancelled', 'broadcast', 'broadcastCampaigns', id, {
|
||||
campaignId: id,
|
||||
|
|
@ -209,7 +197,6 @@ export const broadcastCampaignsStore = {
|
|||
}
|
||||
await campaignTable.update(id, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
emitDomainEvent('BroadcastCampaignDeleted', 'broadcast', 'broadcastCampaigns', id, {
|
||||
campaignId: id,
|
||||
|
|
@ -232,7 +219,6 @@ export const broadcastCampaignsStore = {
|
|||
) {
|
||||
await campaignTable.update(id, {
|
||||
...patch,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -50,7 +50,6 @@ export const broadcastSettingsStore = {
|
|||
await encryptRecord('broadcastSettings', wrapped);
|
||||
await settingsTable.update(BROADCAST_SETTINGS_ID, {
|
||||
...wrapped,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
emitDomainEvent(
|
||||
'BroadcastSettingsUpdated',
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
*/
|
||||
|
||||
import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte';
|
||||
import { deriveUpdatedAt } from '$lib/data/sync';
|
||||
import { db } from '$lib/data/database';
|
||||
import { scopedForModule } from '$lib/data/scope';
|
||||
import type { LocalCalculation, LocalSavedFormula } from './types';
|
||||
|
|
@ -31,7 +32,7 @@ export function toSavedFormula(local: LocalSavedFormula): SavedFormula {
|
|||
description: local.description ?? undefined,
|
||||
mode: local.mode,
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: local.updatedAt ?? new Date().toISOString(),
|
||||
updatedAt: deriveUpdatedAt(local),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ export const calculationsStore = {
|
|||
result: input.result,
|
||||
skin: input.skin,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
CalcEvents.calculationAdded();
|
||||
},
|
||||
|
|
@ -24,7 +23,6 @@ export const calculationsStore = {
|
|||
async deleteCalculation(id: string) {
|
||||
await db.table('calculations').update(id, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -32,9 +30,7 @@ export const calculationsStore = {
|
|||
const now = new Date().toISOString();
|
||||
const all = await db.table<LocalCalculation>('calculations').toArray();
|
||||
const active = all.filter((c) => !c.deletedAt);
|
||||
await Promise.all(
|
||||
active.map((c) => db.table('calculations').update(c.id, { deletedAt: now, updatedAt: now }))
|
||||
);
|
||||
await Promise.all(active.map((c) => db.table('calculations').update(c.id, { deletedAt: now })));
|
||||
CalcEvents.historyCleared();
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ export const savedFormulasStore = {
|
|||
description: input.description ?? null,
|
||||
mode: input.mode,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
CalcEvents.formulaSaved();
|
||||
},
|
||||
|
|
@ -24,14 +23,12 @@ export const savedFormulasStore = {
|
|||
async updateFormula(id: string, input: UpdateFormulaInput) {
|
||||
await db.table('savedFormulas').update(id, {
|
||||
...input,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
async deleteFormula(id: string) {
|
||||
await db.table('savedFormulas').update(id, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
CalcEvents.formulaDeleted();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
*/
|
||||
|
||||
import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte';
|
||||
import { deriveUpdatedAt } from '$lib/data/sync';
|
||||
import { db } from '$lib/data/database';
|
||||
import { scopedForModule, applyVisibility } from '$lib/data/scope';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
|
|
@ -33,7 +34,7 @@ export function toCalendar(local: LocalCalendar): Calendar {
|
|||
isVisible: local.isVisible,
|
||||
timezone: local.timezone,
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: local.updatedAt ?? new Date().toISOString(),
|
||||
updatedAt: deriveUpdatedAt(local),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -38,7 +38,6 @@ export const calendarsStore = {
|
|||
isVisible: input.isVisible ?? true,
|
||||
timezone: input.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await db.table<LocalCalendar>('calendars').add(newLocal);
|
||||
|
|
@ -57,7 +56,6 @@ export const calendarsStore = {
|
|||
try {
|
||||
await db.table('calendars').update(id, {
|
||||
...input,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
const updated = await db.table<LocalCalendar>('calendars').get(id);
|
||||
if (updated) {
|
||||
|
|
@ -78,7 +76,6 @@ export const calendarsStore = {
|
|||
try {
|
||||
await db.table('calendars').update(id, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
|
|
@ -106,13 +103,11 @@ export const calendarsStore = {
|
|||
if (cal.isDefault && cal.id !== id) {
|
||||
await db.table('calendars').update(cal.id, {
|
||||
isDefault: false,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
await db.table('calendars').update(id, {
|
||||
isDefault: true,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
const updated = await db.table<LocalCalendar>('calendars').get(id);
|
||||
return { success: true, data: updated ? toCalendar(updated) : null };
|
||||
|
|
|
|||
|
|
@ -89,7 +89,6 @@ export const eventsStore = {
|
|||
reminders: null,
|
||||
visibility: defaultVisibilityFor(getActiveSpace()?.type),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// title/description/location are encrypted at rest. createBlock
|
||||
|
|
@ -154,9 +153,7 @@ export const eventsStore = {
|
|||
}
|
||||
|
||||
// Update LocalEvent for domain fields
|
||||
const localData: Partial<LocalEvent> = {
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
const localData: Partial<LocalEvent> = {};
|
||||
if (input.title !== undefined) localData.title = input.title;
|
||||
if (input.description !== undefined) localData.description = input.description;
|
||||
if (input.location !== undefined) localData.location = input.location;
|
||||
|
|
@ -213,7 +210,7 @@ export const eventsStore = {
|
|||
await updateBlock(event.timeBlockId, blockUpdates);
|
||||
|
||||
// Update LocalEvent
|
||||
const localData: Partial<LocalEvent> = { updatedAt: new Date().toISOString() };
|
||||
const localData: Partial<LocalEvent> = {};
|
||||
if (input.title !== undefined) localData.title = input.title;
|
||||
if (input.description !== undefined) localData.description = input.description;
|
||||
if (input.location !== undefined) localData.location = input.location;
|
||||
|
|
@ -277,7 +274,7 @@ export const eventsStore = {
|
|||
.equals(templateBlockId)
|
||||
.first();
|
||||
if (templateEvent) {
|
||||
const localData: Partial<LocalEvent> = { updatedAt: new Date().toISOString() };
|
||||
const localData: Partial<LocalEvent> = {};
|
||||
if (input.title !== undefined) localData.title = input.title;
|
||||
if (input.description !== undefined) localData.description = input.description;
|
||||
if (input.location !== undefined) localData.location = input.location;
|
||||
|
|
@ -308,7 +305,6 @@ export const eventsStore = {
|
|||
}
|
||||
await db.table('events').update(id, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
CalendarEvents.eventDeleted();
|
||||
return { success: true };
|
||||
|
|
@ -343,7 +339,7 @@ export const eventsStore = {
|
|||
const now = new Date().toISOString();
|
||||
for (const ev of allEvents) {
|
||||
if (blockIds.has(ev.timeBlockId) && !ev.deletedAt) {
|
||||
await db.table('events').update(ev.id, { deletedAt: now, updatedAt: now });
|
||||
await db.table('events').update(ev.id, { deletedAt: now });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -391,7 +387,6 @@ export const eventsStore = {
|
|||
|
||||
await db.table('events').update(id, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
emitDomainEvent('CalendarEventDeleted', 'calendar', 'events', id, {
|
||||
eventId: id,
|
||||
|
|
@ -412,7 +407,6 @@ export const eventsStore = {
|
|||
async updateTagIds(id: string, tagIds: string[]) {
|
||||
await db.table('events').update(id, {
|
||||
tagIds,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -503,7 +497,6 @@ export const eventsStore = {
|
|||
visibility: next,
|
||||
visibilityChangedAt: now,
|
||||
visibilityChangedBy: getEffectiveUserId(),
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
// Server-authoritative token. Publish first; local update only
|
||||
|
|
@ -590,7 +583,6 @@ export const eventsStore = {
|
|||
});
|
||||
await db.table('events').update(id, {
|
||||
unlistedToken: token,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
return { success: true, token };
|
||||
} catch (e) {
|
||||
|
|
@ -625,7 +617,6 @@ export const eventsStore = {
|
|||
});
|
||||
await db.table('events').update(id, {
|
||||
unlistedExpiresAt: expiresAt ? expiresAt.toISOString() : undefined,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[calendar/events] setUnlistedExpiry failed', e);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
*/
|
||||
|
||||
import type { BaseRecord } from '@mana/local-store';
|
||||
import { deriveUpdatedAt } from '$lib/data/sync';
|
||||
import type { VisibilityLevel } from '@mana/shared-privacy';
|
||||
import type { TimeBlock, TimeBlockType } from '$lib/data/time-blocks/types';
|
||||
|
||||
|
|
@ -120,7 +121,7 @@ export function timeBlockToCalendarEvent(
|
|||
unlistedToken: eventData?.unlistedToken ?? '',
|
||||
unlistedExpiresAt: eventData?.unlistedExpiresAt ?? null,
|
||||
createdAt: block.createdAt,
|
||||
updatedAt: block.updatedAt,
|
||||
updatedAt: deriveUpdatedAt(block),
|
||||
blockType: block.type,
|
||||
sourceModule: block.sourceModule,
|
||||
sourceId: block.sourceId,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
*/
|
||||
|
||||
import { liveQuery } from 'dexie';
|
||||
import { deriveUpdatedAt } from '$lib/data/sync';
|
||||
import { db } from '$lib/data/database';
|
||||
import { scopedForModule } from '$lib/data/scope';
|
||||
import { decryptRecord, decryptRecords } from '$lib/data/crypto';
|
||||
|
|
@ -22,7 +23,7 @@ export function toDeck(local: LocalDeck): Deck {
|
|||
tags: [],
|
||||
cardCount: local.cardCount,
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: local.updatedAt ?? new Date().toISOString(),
|
||||
updatedAt: deriveUpdatedAt(local),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -37,7 +38,7 @@ export function toCard(local: LocalCard): Card {
|
|||
reviewCount: local.reviewCount,
|
||||
order: local.order,
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: local.updatedAt ?? new Date().toISOString(),
|
||||
updatedAt: deriveUpdatedAt(local),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -41,7 +41,6 @@ export const cardStore = {
|
|||
if (deck) {
|
||||
await cardDeckTable.update(input.deckId, {
|
||||
cardCount: (deck.cardCount || 0) + 1,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -69,7 +68,6 @@ export const cardStore = {
|
|||
|
||||
const diff: Partial<LocalCard> = {
|
||||
...localUpdates,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await encryptRecord('cards', diff);
|
||||
await cardTable.update(id, diff);
|
||||
|
|
@ -83,7 +81,7 @@ export const cardStore = {
|
|||
error = null;
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
await cardTable.update(id, { deletedAt: now, updatedAt: now });
|
||||
await cardTable.update(id, { deletedAt: now });
|
||||
CardsEvents.cardDeleted();
|
||||
|
||||
// Update deck card count
|
||||
|
|
@ -92,7 +90,6 @@ export const cardStore = {
|
|||
if (deck) {
|
||||
await cardDeckTable.update(deckId, {
|
||||
cardCount: Math.max(0, (deck.cardCount || 0) - 1),
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -107,7 +104,7 @@ export const cardStore = {
|
|||
try {
|
||||
const now = new Date().toISOString();
|
||||
for (let i = 0; i < cardIds.length; i++) {
|
||||
await cardTable.update(cardIds[i], { order: i, updatedAt: now });
|
||||
await cardTable.update(cardIds[i], { order: i });
|
||||
}
|
||||
} catch (err: any) {
|
||||
error = err.message || 'Failed to reorder cards';
|
||||
|
|
|
|||
|
|
@ -58,7 +58,6 @@ export const deckStore = {
|
|||
|
||||
const diff: Partial<LocalDeck> = {
|
||||
...localUpdates,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await encryptRecord('cardDecks', diff);
|
||||
await cardDeckTable.update(id, diff);
|
||||
|
|
@ -105,9 +104,9 @@ export const deckStore = {
|
|||
await db.transaction('rw', cardDeckTable, cardTable, async () => {
|
||||
const cards = await cardTable.where('deckId').equals(id).toArray();
|
||||
for (const card of cards) {
|
||||
await cardTable.update(card.id, { deletedAt: now, updatedAt: now });
|
||||
await cardTable.update(card.id, { deletedAt: now });
|
||||
}
|
||||
await cardDeckTable.update(id, { deletedAt: now, updatedAt: now });
|
||||
await cardDeckTable.update(id, { deletedAt: now });
|
||||
});
|
||||
CardsEvents.deckDeleted();
|
||||
} catch (err: any) {
|
||||
|
|
@ -142,7 +141,6 @@ export const deckStore = {
|
|||
await cardDeckTable.update(deckId, {
|
||||
activeStudyBlockId: timeBlockId,
|
||||
lastStudied: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
return timeBlockId;
|
||||
|
|
@ -160,7 +158,6 @@ export const deckStore = {
|
|||
|
||||
await cardDeckTable.update(deckId, {
|
||||
activeStudyBlockId: null,
|
||||
updatedAt: now,
|
||||
});
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -54,7 +54,6 @@
|
|||
// Color is not in UpdateDeckInput, update directly
|
||||
await db.table('decks').update(deckId, {
|
||||
color: editColor,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
*/
|
||||
|
||||
import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte';
|
||||
import { deriveUpdatedAt } from '$lib/data/sync';
|
||||
import { db } from '$lib/data/database';
|
||||
import { scopedForModule } from '$lib/data/scope';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
|
|
@ -34,7 +35,7 @@ export function toConversation(local: LocalConversation): Conversation {
|
|||
isArchived: local.isArchived,
|
||||
isPinned: local.isPinned,
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: local.updatedAt ?? new Date().toISOString(),
|
||||
updatedAt: deriveUpdatedAt(local),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -50,7 +51,7 @@ export function toTemplate(local: LocalTemplate): Template {
|
|||
isDefault: local.isDefault,
|
||||
documentMode: local.documentMode,
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: local.updatedAt ?? new Date().toISOString(),
|
||||
updatedAt: deriveUpdatedAt(local),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -61,7 +62,7 @@ export function toMessage(local: LocalMessage): Message {
|
|||
sender: local.sender,
|
||||
messageText: local.messageText,
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: local.updatedAt ?? undefined,
|
||||
updatedAt: deriveUpdatedAt(local),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -55,7 +55,6 @@ export const conversationsStore = {
|
|||
async update(id: string, updates: Partial<LocalConversation>) {
|
||||
const diff: Partial<LocalConversation> = {
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await encryptRecord('conversations', diff);
|
||||
await conversationTable.update(id, diff);
|
||||
|
|
@ -65,7 +64,6 @@ export const conversationsStore = {
|
|||
async updateTitle(id: string, title: string) {
|
||||
const diff: Partial<LocalConversation> = {
|
||||
title,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await encryptRecord('conversations', diff);
|
||||
await conversationTable.update(id, diff);
|
||||
|
|
@ -79,7 +77,6 @@ export const conversationsStore = {
|
|||
async pin(id: string) {
|
||||
await conversationTable.update(id, {
|
||||
isPinned: true,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -87,7 +84,6 @@ export const conversationsStore = {
|
|||
async unpin(id: string) {
|
||||
await conversationTable.update(id, {
|
||||
isPinned: false,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -97,10 +93,10 @@ export const conversationsStore = {
|
|||
// Atomic cascade: conversation + all messages in one Dexie transaction.
|
||||
// Aborts as a unit on failure to avoid orphaned messages.
|
||||
await db.transaction('rw', conversationTable, messageTable, async () => {
|
||||
await conversationTable.update(id, { deletedAt: now, updatedAt: now });
|
||||
await conversationTable.update(id, { deletedAt: now });
|
||||
const msgs = await messageTable.where('conversationId').equals(id).toArray();
|
||||
for (const msg of msgs) {
|
||||
await messageTable.update(msg.id, { deletedAt: now, updatedAt: now });
|
||||
await messageTable.update(msg.id, { deletedAt: now });
|
||||
}
|
||||
});
|
||||
ChatEvents.conversationDeleted();
|
||||
|
|
|
|||
|
|
@ -33,9 +33,7 @@ export const messagesStore = {
|
|||
await encryptRecord('messages', newLocal);
|
||||
await messageTable.add(newLocal);
|
||||
// Touch the conversation's updatedAt
|
||||
await conversationTable.update(conversationId, {
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
await conversationTable.update(conversationId, {});
|
||||
emitDomainEvent('ChatMessageSent', 'chat', 'messages', newLocal.id, {
|
||||
messageId: newLocal.id,
|
||||
conversationId,
|
||||
|
|
@ -55,9 +53,7 @@ export const messagesStore = {
|
|||
const plaintextSnapshot = toMessage(newLocal);
|
||||
await encryptRecord('messages', newLocal);
|
||||
await messageTable.add(newLocal);
|
||||
await conversationTable.update(conversationId, {
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
await conversationTable.update(conversationId, {});
|
||||
return plaintextSnapshot;
|
||||
},
|
||||
|
||||
|
|
@ -65,7 +61,6 @@ export const messagesStore = {
|
|||
async updateText(id: string, text: string) {
|
||||
const diff: Partial<LocalMessage> = {
|
||||
messageText: text,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await encryptRecord('messages', diff);
|
||||
await messageTable.update(id, diff);
|
||||
|
|
@ -74,6 +69,6 @@ export const messagesStore = {
|
|||
/** Soft-delete a message. */
|
||||
async delete(id: string) {
|
||||
const now = new Date().toISOString();
|
||||
await messageTable.update(id, { deletedAt: now, updatedAt: now });
|
||||
await messageTable.update(id, { deletedAt: now });
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -58,7 +58,6 @@ export const templatesStore = {
|
|||
) {
|
||||
const diff: Partial<LocalTemplate> = {
|
||||
...data,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await encryptRecord('chatTemplates', diff);
|
||||
await chatTemplateTable.update(id, diff);
|
||||
|
|
@ -67,7 +66,7 @@ export const templatesStore = {
|
|||
/** Soft-delete a template. */
|
||||
async delete(id: string) {
|
||||
const now = new Date().toISOString();
|
||||
await chatTemplateTable.update(id, { deletedAt: now, updatedAt: now });
|
||||
await chatTemplateTable.update(id, { deletedAt: now });
|
||||
},
|
||||
|
||||
/** Set a template as default (unset all others). */
|
||||
|
|
@ -77,13 +76,11 @@ export const templatesStore = {
|
|||
if (t.isDefault && t.id !== templateId) {
|
||||
await chatTemplateTable.update(t.id, {
|
||||
isDefault: false,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
await chatTemplateTable.update(templateId, {
|
||||
isDefault: true,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ export const favoritesStore = {
|
|||
if (existing) {
|
||||
await db.table('ccFavorites').update(existing.id, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
CityCornersEvents.favoriteToggled(false);
|
||||
} else {
|
||||
|
|
@ -37,7 +36,6 @@ export const favoritesStore = {
|
|||
id: crypto.randomUUID(),
|
||||
locationId,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await db.table('ccFavorites').add(newFav);
|
||||
CityCornersEvents.favoriteToggled(true);
|
||||
|
|
|
|||
|
|
@ -51,14 +51,12 @@
|
|||
category: editCategory,
|
||||
description: editDescription.trim() || undefined,
|
||||
address: editAddress.trim() || undefined,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
async function handleCategoryChange() {
|
||||
await db.table('ccLocations').update(locationId, {
|
||||
category: editCategory,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -69,7 +67,6 @@
|
|||
async function deleteLocation() {
|
||||
await db.table('ccLocations').update(locationId, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -187,7 +187,6 @@ export async function runCharacterGenerate(
|
|||
referenceImageIds: referenceMediaIds,
|
||||
comicCharacterId: character.id,
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
});
|
||||
|
||||
await comicCharactersStore.appendVariant(character.id, localImageId);
|
||||
|
|
|
|||
|
|
@ -220,7 +220,6 @@ export async function runPanelGenerate(
|
|||
comicStoryId: story.id,
|
||||
comicPanelIndex: panelIndex,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
await comicStoriesStore.appendPanel(story.id, localImageId, {
|
||||
|
|
|
|||
|
|
@ -77,14 +77,13 @@ export const comicCharactersStore = {
|
|||
const nextIds = [...(existing.variantMediaIds ?? []), variantMediaId];
|
||||
const patch: Partial<LocalComicCharacter> = {
|
||||
variantMediaIds: nextIds,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
// Auto-pin the first variant so the cover isn't blank during
|
||||
// build. User can re-pin afterwards.
|
||||
if (!existing.pinnedVariantId) {
|
||||
patch.pinnedVariantId = variantMediaId;
|
||||
}
|
||||
await comicCharactersTable.update(characterId, patch);
|
||||
await comicCharactersTable.update(characterId, patch as never);
|
||||
emitDomainEvent('ComicCharacterVariantAdded', 'comic', 'comicCharacters', characterId, {
|
||||
characterId,
|
||||
variantMediaId,
|
||||
|
|
@ -103,7 +102,6 @@ export const comicCharactersStore = {
|
|||
}
|
||||
await comicCharactersTable.update(characterId, {
|
||||
pinnedVariantId: variantMediaId,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
emitDomainEvent('ComicCharacterVariantPinned', 'comic', 'comicCharacters', characterId, {
|
||||
characterId,
|
||||
|
|
@ -121,27 +119,23 @@ export const comicCharactersStore = {
|
|||
const nextIds = (existing.variantMediaIds ?? []).filter((id) => id !== variantMediaId);
|
||||
const patch: Partial<LocalComicCharacter> = {
|
||||
variantMediaIds: nextIds,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
if (existing.pinnedVariantId === variantMediaId) {
|
||||
patch.pinnedVariantId = nextIds[0] ?? null;
|
||||
}
|
||||
await comicCharactersTable.update(characterId, patch);
|
||||
await comicCharactersTable.update(characterId, patch as never);
|
||||
},
|
||||
|
||||
async updateCharacter(
|
||||
id: string,
|
||||
patch: Partial<Pick<LocalComicCharacter, 'name' | 'description' | 'addPrompt' | 'tags'>>
|
||||
): Promise<void> {
|
||||
const wrapped: Record<string, unknown> = { ...patch };
|
||||
const wrapped: Partial<LocalComicCharacter> = { ...patch };
|
||||
if (Array.isArray(wrapped.tags)) {
|
||||
wrapped.tags = [...(wrapped.tags as string[])];
|
||||
wrapped.tags = [...wrapped.tags];
|
||||
}
|
||||
await encryptRecord('comicCharacters', wrapped);
|
||||
await comicCharactersTable.update(id, {
|
||||
...wrapped,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
await encryptRecord('comicCharacters', wrapped as Record<string, unknown>);
|
||||
await comicCharactersTable.update(id, wrapped as never);
|
||||
},
|
||||
|
||||
async toggleFavorite(id: string): Promise<void> {
|
||||
|
|
@ -149,14 +143,12 @@ export const comicCharactersStore = {
|
|||
if (!existing) return;
|
||||
await comicCharactersTable.update(id, {
|
||||
isFavorite: !existing.isFavorite,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
async archiveCharacter(id: string, archived: boolean): Promise<void> {
|
||||
await comicCharactersTable.update(id, {
|
||||
isArchived: archived,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -164,7 +156,6 @@ export const comicCharactersStore = {
|
|||
const nowIso = new Date().toISOString();
|
||||
await comicCharactersTable.update(id, {
|
||||
deletedAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
});
|
||||
emitDomainEvent('ComicCharacterDeleted', 'comic', 'comicCharacters', id, {
|
||||
characterId: id,
|
||||
|
|
|
|||
|
|
@ -76,18 +76,15 @@ export const comicStoriesStore = {
|
|||
): Promise<void> {
|
||||
// Same proxy-breaking copy as createStory: any array on the patch
|
||||
// might be a $state proxy if the caller is a Svelte 5 component.
|
||||
const wrapped: Record<string, unknown> = { ...patch };
|
||||
const wrapped: Partial<LocalComicStory> = { ...patch };
|
||||
if (Array.isArray(wrapped.characterMediaIds)) {
|
||||
wrapped.characterMediaIds = [...(wrapped.characterMediaIds as string[])];
|
||||
wrapped.characterMediaIds = [...wrapped.characterMediaIds];
|
||||
}
|
||||
if (Array.isArray(wrapped.tags)) {
|
||||
wrapped.tags = [...(wrapped.tags as string[])];
|
||||
wrapped.tags = [...wrapped.tags];
|
||||
}
|
||||
await encryptRecord('comicStories', wrapped);
|
||||
await comicStoriesTable.update(id, {
|
||||
...wrapped,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
await encryptRecord('comicStories', wrapped as Record<string, unknown>);
|
||||
await comicStoriesTable.update(id, wrapped as never);
|
||||
},
|
||||
|
||||
async toggleFavorite(id: string): Promise<void> {
|
||||
|
|
@ -95,14 +92,12 @@ export const comicStoriesStore = {
|
|||
if (!existing) return;
|
||||
await comicStoriesTable.update(id, {
|
||||
isFavorite: !existing.isFavorite,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
async archiveStory(id: string, archived: boolean): Promise<void> {
|
||||
await comicStoriesTable.update(id, {
|
||||
isArchived: archived,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -110,7 +105,6 @@ export const comicStoriesStore = {
|
|||
const nowIso = new Date().toISOString();
|
||||
await comicStoriesTable.update(id, {
|
||||
deletedAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
});
|
||||
emitDomainEvent('ComicStoryDeleted', 'comic', 'comicStories', id, {
|
||||
storyId: id,
|
||||
|
|
@ -133,14 +127,13 @@ export const comicStoriesStore = {
|
|||
visibility: next,
|
||||
visibilityChangedAt: now,
|
||||
visibilityChangedBy: getEffectiveUserId(),
|
||||
updatedAt: now,
|
||||
};
|
||||
if (next === 'unlisted' && !existing.unlistedToken) {
|
||||
patch.unlistedToken = generateUnlistedToken();
|
||||
} else if (next !== 'unlisted' && existing.unlistedToken) {
|
||||
patch.unlistedToken = undefined;
|
||||
}
|
||||
await comicStoriesTable.update(id, patch);
|
||||
await comicStoriesTable.update(id, patch as never);
|
||||
|
||||
emitDomainEvent('VisibilityChanged', 'comic', 'comicStories', id, {
|
||||
recordId: id,
|
||||
|
|
@ -170,10 +163,7 @@ export const comicStoriesStore = {
|
|||
panelMeta: nextMeta,
|
||||
} as Record<string, unknown>;
|
||||
await encryptRecord('comicStories', patch);
|
||||
await comicStoriesTable.update(storyId, {
|
||||
...patch,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
await comicStoriesTable.update(storyId, patch as never);
|
||||
emitDomainEvent('ComicPanelAppended', 'comic', 'comicStories', storyId, {
|
||||
storyId,
|
||||
panelImageId,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
*/
|
||||
|
||||
import type { BaseRecord } from '@mana/local-store';
|
||||
import { deriveUpdatedAt } from '$lib/data/sync';
|
||||
import type { VisibilityLevel } from '@mana/shared-privacy';
|
||||
|
||||
// ─── Style ────────────────────────────────────────────────────────
|
||||
|
|
@ -148,7 +149,7 @@ export function toStory(local: LocalComicStory): ComicStory {
|
|||
isArchived: local.isArchived,
|
||||
visibility: local.visibility ?? 'space',
|
||||
createdAt: local.createdAt ?? '',
|
||||
updatedAt: local.updatedAt ?? '',
|
||||
updatedAt: deriveUpdatedAt(local),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -235,7 +236,7 @@ export function toCharacter(local: LocalComicCharacter): ComicCharacter {
|
|||
isFavorite: local.isFavorite,
|
||||
isArchived: local.isArchived,
|
||||
createdAt: local.createdAt ?? '',
|
||||
updatedAt: local.updatedAt ?? '',
|
||||
updatedAt: deriveUpdatedAt(local),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -81,12 +81,8 @@
|
|||
panelImageIds: nextIds,
|
||||
panelMeta: nextMeta,
|
||||
} as Partial<LocalComicStory>;
|
||||
const wrapped = { ...patch } as Record<string, unknown>;
|
||||
await encryptRecord('comicStories', wrapped);
|
||||
await comicStoriesTable.update(story.id, {
|
||||
...wrapped,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
await encryptRecord('comicStories', patch as Record<string, unknown>);
|
||||
await comicStoriesTable.update(story.id, patch as never);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
19
apps/mana/apps/web/src/lib/modules/community/ListView.svelte
Normal file
19
apps/mana/apps/web/src/lib/modules/community/ListView.svelte
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<!--
|
||||
Community Workbench-Card view — wraps the main ListView for the
|
||||
Workbench/Homepage carousel. Renders the public feed in the card.
|
||||
Power-user views (Roadmap, single item) live as own routes.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import ListView from './views/ListView.svelte';
|
||||
</script>
|
||||
|
||||
<div class="community-card">
|
||||
<ListView />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.community-card {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,217 @@
|
|||
<!--
|
||||
ItemCard — Single public feedback item, used in ListView, RoadmapView,
|
||||
and DetailView (as the parent display). Reactions row is editable when
|
||||
the user is logged in; in read-only mode (anonymous SSR) it's static.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import {
|
||||
ReactionBar,
|
||||
FEEDBACK_CATEGORY_LABELS,
|
||||
FEEDBACK_STATUS_CONFIG,
|
||||
type PublicFeedbackItem,
|
||||
type ReactionEmoji,
|
||||
} from '@mana/feedback';
|
||||
|
||||
interface Props {
|
||||
item: PublicFeedbackItem;
|
||||
readOnly?: boolean;
|
||||
onReact?: (id: string, emoji: ReactionEmoji) => void | Promise<void>;
|
||||
onClick?: (id: string) => void;
|
||||
showAdminResponse?: boolean;
|
||||
}
|
||||
|
||||
let { item, readOnly = false, onReact, onClick, showAdminResponse = true }: Props = $props();
|
||||
|
||||
let statusConfig = $derived(FEEDBACK_STATUS_CONFIG[item.status]);
|
||||
let categoryLabel = $derived(FEEDBACK_CATEGORY_LABELS[item.category] ?? item.category);
|
||||
|
||||
function handleClick() {
|
||||
if (onClick) onClick(item.id);
|
||||
}
|
||||
|
||||
function formatDate(s: string): string {
|
||||
try {
|
||||
return new Date(s).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: '2-digit',
|
||||
});
|
||||
} catch {
|
||||
return s.slice(0, 10);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReact(emoji: ReactionEmoji) {
|
||||
if (onReact) await onReact(item.id, emoji);
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<article
|
||||
class="item-card"
|
||||
class:clickable={!!onClick}
|
||||
onclick={handleClick}
|
||||
role={onClick ? 'button' : undefined}
|
||||
tabindex={onClick ? 0 : undefined}
|
||||
>
|
||||
<header class="item-header">
|
||||
<div class="meta-row">
|
||||
<span class="badge category">{categoryLabel}</span>
|
||||
{#if item.moduleContext}
|
||||
<span class="badge module">{item.moduleContext}</span>
|
||||
{/if}
|
||||
<span class="badge status" style:color={statusConfig.color}>
|
||||
{statusConfig.label}
|
||||
</span>
|
||||
<span class="muted">{formatDate(item.createdAt)}</span>
|
||||
</div>
|
||||
{#if item.title}
|
||||
<h3 class="title">{item.title}</h3>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<p class="text">{item.feedbackText}</p>
|
||||
|
||||
{#if showAdminResponse && item.adminResponse}
|
||||
<div class="admin-response">
|
||||
<div class="admin-label">Antwort vom Team</div>
|
||||
<p>{item.adminResponse}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<footer class="item-footer">
|
||||
<span class="author">{item.displayName}</span>
|
||||
<ReactionBar
|
||||
reactions={item.reactions}
|
||||
myReactions={item.myReactions ?? []}
|
||||
{readOnly}
|
||||
onToggle={handleReact}
|
||||
size="sm"
|
||||
/>
|
||||
</footer>
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.item-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.875rem 1rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
background: hsl(var(--color-card));
|
||||
border-radius: 0.875rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.item-card.clickable {
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
transform 0.15s,
|
||||
box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.item-card.clickable:hover {
|
||||
border-color: hsl(var(--color-primary) / 0.4);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px hsl(0 0% 0% / 0.08);
|
||||
}
|
||||
|
||||
.item-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.125rem 0.4375rem;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.badge.category {
|
||||
background: hsl(var(--color-muted) / 0.45);
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.badge.module {
|
||||
background: hsl(var(--color-primary) / 0.12);
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.badge.status {
|
||||
background: transparent;
|
||||
border: 1px solid currentColor;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.text {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
color: hsl(var(--color-foreground));
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.admin-response {
|
||||
margin-top: 0.25rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-left: 3px solid hsl(var(--color-primary));
|
||||
background: hsl(var(--color-primary) / 0.06);
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.admin-response p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.admin-label {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: hsl(var(--color-primary));
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
|
||||
.item-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
.author {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
/**
|
||||
* Community module — server-only feedback hub. No Dexie tables, so the
|
||||
* module-config is mostly nominal (registers the appId for module-context
|
||||
* tagging on inline FeedbackHook submissions).
|
||||
*/
|
||||
|
||||
import type { ModuleConfig } from '$lib/data/module-registry';
|
||||
|
||||
export const communityModuleConfig: ModuleConfig = {
|
||||
appId: 'community',
|
||||
tables: [],
|
||||
};
|
||||
132
apps/mana/apps/web/src/lib/modules/community/queries.ts
Normal file
132
apps/mana/apps/web/src/lib/modules/community/queries.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
/**
|
||||
* Community module — SWR-style queries.
|
||||
*
|
||||
* Feedback lives server-only (no Dexie / mana-sync), so we expose
|
||||
* Svelte 5 reactive stores that pull from the feedback service and
|
||||
* cache results across mounts within a session.
|
||||
*/
|
||||
|
||||
import type { PublicFeedbackItem, FeedbackQueryParams } from '@mana/feedback';
|
||||
import { feedbackService, publicFeedbackService } from '$lib/api/feedback';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
/**
|
||||
* Stateful query for the public community feed.
|
||||
*
|
||||
* Reads via the auth-enriched endpoint when a user is logged in (so each
|
||||
* item carries `myReactions` for highlight-state); falls back to the
|
||||
* anonymous endpoint when guest. Polls on demand via `reload()`.
|
||||
*/
|
||||
export function useCommunityFeed(initial: FeedbackQueryParams = {}) {
|
||||
let items = $state<PublicFeedbackItem[]>([]);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let lastQuery = $state<FeedbackQueryParams>(initial);
|
||||
|
||||
async function reload(query?: FeedbackQueryParams) {
|
||||
const q = query ?? lastQuery;
|
||||
lastQuery = q;
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
if (authStore.user) {
|
||||
items = await feedbackService.getPublicFeed(q);
|
||||
} else {
|
||||
items = await publicFeedbackService.getFeed(q);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[community/queries] reload failed:', err);
|
||||
error = err instanceof Error ? err.message : 'Laden fehlgeschlagen';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// kick off initial load
|
||||
void reload(initial);
|
||||
|
||||
return {
|
||||
get items() {
|
||||
return items;
|
||||
},
|
||||
get loading() {
|
||||
return loading;
|
||||
},
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
reload,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-item query plus its replies.
|
||||
*/
|
||||
export function useCommunityItem(id: string) {
|
||||
let item = $state<PublicFeedbackItem | null>(null);
|
||||
let replies = $state<PublicFeedbackItem[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
async function reload() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const data = await publicFeedbackService.getItem(id);
|
||||
item = data.item;
|
||||
replies = data.replies;
|
||||
} catch (err) {
|
||||
console.error('[community/queries] item fetch failed:', err);
|
||||
error = err instanceof Error ? err.message : 'Laden fehlgeschlagen';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
void reload();
|
||||
|
||||
return {
|
||||
get item() {
|
||||
return item;
|
||||
},
|
||||
get replies() {
|
||||
return replies;
|
||||
},
|
||||
get loading() {
|
||||
return loading;
|
||||
},
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
reload,
|
||||
};
|
||||
}
|
||||
|
||||
/** Toggle a reaction; on success patches `items` in place. */
|
||||
export async function toggleReactionOnItem(
|
||||
itemsRef: { items: PublicFeedbackItem[] },
|
||||
id: string,
|
||||
emoji: string
|
||||
) {
|
||||
if (!authStore.user) return;
|
||||
try {
|
||||
const res = await feedbackService.toggleReaction(id, emoji as never);
|
||||
// Patch in place — caller manages reactivity by re-assigning the array
|
||||
// or relying on $state-deep reactivity in calling component.
|
||||
const idx = itemsRef.items.findIndex((i) => i.id === id);
|
||||
if (idx >= 0) {
|
||||
const old = itemsRef.items[idx];
|
||||
const myReactions = res.userHasReacted
|
||||
? Array.from(new Set([...(old.myReactions ?? []), emoji]))
|
||||
: (old.myReactions ?? []).filter((e) => e !== emoji);
|
||||
itemsRef.items[idx] = {
|
||||
...old,
|
||||
reactions: res.reactions,
|
||||
score: res.score,
|
||||
myReactions,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[community/queries] toggleReaction failed:', err);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,217 @@
|
|||
<!--
|
||||
DetailView — Single feedback item with its replies. Replies are
|
||||
1-level (no nesting). Auth-required to post a reply; guests see the
|
||||
thread read-only.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { PublicFeedbackItem, ReactionEmoji } from '@mana/feedback';
|
||||
import { useCommunityItem } from '../queries';
|
||||
import ItemCard from '../components/ItemCard.svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { feedbackService } from '$lib/api/feedback';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
}
|
||||
|
||||
let { id }: Props = $props();
|
||||
|
||||
let view = useCommunityItem(id);
|
||||
|
||||
let replyText = $state('');
|
||||
let saving = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
async function postReply() {
|
||||
const trimmed = replyText.trim();
|
||||
if (!trimmed || saving) return;
|
||||
saving = true;
|
||||
error = null;
|
||||
try {
|
||||
await feedbackService.createFeedback({
|
||||
feedbackText: trimmed,
|
||||
parentId: id,
|
||||
isPublic: true,
|
||||
category: 'other',
|
||||
});
|
||||
replyText = '';
|
||||
await view.reload();
|
||||
} catch (err) {
|
||||
console.error('[community/detail] reply failed:', err);
|
||||
error = err instanceof Error ? err.message : 'Senden fehlgeschlagen.';
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReact(_id: string, emoji: ReactionEmoji) {
|
||||
if (!authStore.user) return;
|
||||
try {
|
||||
const res = await feedbackService.toggleReaction(_id, emoji);
|
||||
// Patch parent item in place
|
||||
if (view.item && view.item.id === _id) {
|
||||
const myReactions = res.userHasReacted
|
||||
? Array.from(new Set([...(view.item.myReactions ?? []), emoji]))
|
||||
: (view.item.myReactions ?? []).filter((e) => e !== emoji);
|
||||
view.item.reactions = res.reactions;
|
||||
view.item.score = res.score;
|
||||
view.item.myReactions = myReactions;
|
||||
} else {
|
||||
// Patch in replies
|
||||
const idx = view.replies.findIndex((r: PublicFeedbackItem) => r.id === _id);
|
||||
if (idx >= 0) {
|
||||
const old = view.replies[idx];
|
||||
const myReactions = res.userHasReacted
|
||||
? Array.from(new Set([...(old.myReactions ?? []), emoji]))
|
||||
: (old.myReactions ?? []).filter((e) => e !== emoji);
|
||||
view.replies[idx] = {
|
||||
...old,
|
||||
reactions: res.reactions,
|
||||
score: res.score,
|
||||
myReactions,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[community/detail] react failed:', err);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="detail">
|
||||
{#if view.loading}
|
||||
<div class="state">Lade…</div>
|
||||
{:else if view.error || !view.item}
|
||||
<div class="state error">{view.error ?? 'Nicht gefunden'}</div>
|
||||
{:else}
|
||||
<ItemCard item={view.item} readOnly={!authStore.user} onReact={handleReact} />
|
||||
|
||||
<section class="replies">
|
||||
<h3 class="replies-header">
|
||||
{view.replies.length} Antwort{view.replies.length === 1 ? '' : 'en'}
|
||||
</h3>
|
||||
|
||||
{#each view.replies as reply (reply.id)}
|
||||
<ItemCard
|
||||
item={reply}
|
||||
readOnly={!authStore.user}
|
||||
onReact={handleReact}
|
||||
showAdminResponse={false}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
{#if authStore.user}
|
||||
<div class="reply-form">
|
||||
<textarea bind:value={replyText} placeholder="Deine Antwort…" maxlength={2000} rows="3"
|
||||
></textarea>
|
||||
{#if error}
|
||||
<p class="error">{error}</p>
|
||||
{/if}
|
||||
<button
|
||||
class="btn-primary"
|
||||
disabled={saving || replyText.trim().length < 3}
|
||||
onclick={postReply}
|
||||
>
|
||||
{saving ? 'Sende…' : 'Antworten'}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="login-hint">
|
||||
<a href="/login" class="link">Login</a>, um zu antworten.
|
||||
</p>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.replies {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.replies-header {
|
||||
margin: 0 0 0.25rem 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.reply-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.reply-form textarea {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-surface, var(--color-background)));
|
||||
color: hsl(var(--color-foreground));
|
||||
font: inherit;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.reply-form textarea:focus {
|
||||
outline: none;
|
||||
border-color: hsl(var(--color-primary));
|
||||
box-shadow: 0 0 0 3px hsl(var(--color-primary) / 0.15);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
align-self: flex-end;
|
||||
padding: 0.5rem 0.875rem;
|
||||
border: none;
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground, 0 0% 100%));
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.login-hint {
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.link {
|
||||
color: hsl(var(--color-primary));
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.state {
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.state.error {
|
||||
color: hsl(var(--color-error, 0 84% 60%));
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-error, 0 84% 60%));
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,153 @@
|
|||
<!--
|
||||
Community ListView — main feed of public, anonymous feedback.
|
||||
Default sort: by score (weighted reactions) descending.
|
||||
Filters: category + module-context. Click an item to navigate to
|
||||
the detail page; click a reaction to toggle (auth-only).
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { FEEDBACK_CATEGORY_LABELS, type FeedbackCategory } from '@mana/feedback';
|
||||
import { useCommunityFeed, toggleReactionOnItem } from '../queries';
|
||||
import ItemCard from '../components/ItemCard.svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
interface Props {
|
||||
/** Optional initial moduleContext filter — passed by the
|
||||
* workbench-card variant to scope to a single module. */
|
||||
moduleContext?: string;
|
||||
}
|
||||
|
||||
let { moduleContext }: Props = $props();
|
||||
|
||||
let categoryFilter = $state<FeedbackCategory | ''>('');
|
||||
let modulePill = $state(moduleContext ?? '');
|
||||
|
||||
let feed = useCommunityFeed({ moduleContext, limit: 50 });
|
||||
|
||||
function applyFilters() {
|
||||
feed.reload({
|
||||
category: categoryFilter || undefined,
|
||||
moduleContext: modulePill || undefined,
|
||||
limit: 50,
|
||||
});
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void [categoryFilter, modulePill];
|
||||
applyFilters();
|
||||
});
|
||||
|
||||
const CATEGORY_OPTIONS: { value: FeedbackCategory | ''; label: string }[] = [
|
||||
{ value: '', label: 'Alle Kategorien' },
|
||||
{ value: 'feature', label: FEEDBACK_CATEGORY_LABELS.feature },
|
||||
{ value: 'improvement', label: FEEDBACK_CATEGORY_LABELS.improvement },
|
||||
{ value: 'bug', label: FEEDBACK_CATEGORY_LABELS.bug },
|
||||
{ value: 'praise', label: FEEDBACK_CATEGORY_LABELS.praise },
|
||||
{ value: 'question', label: FEEDBACK_CATEGORY_LABELS.question },
|
||||
{ value: 'onboarding-wish', label: FEEDBACK_CATEGORY_LABELS['onboarding-wish'] },
|
||||
];
|
||||
|
||||
let openItemId = $state<string | null>(null);
|
||||
|
||||
function handleClickItem(id: string) {
|
||||
openItemId = id;
|
||||
void goto(`/community/${id}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="community-list">
|
||||
<header class="filter-row">
|
||||
<select bind:value={categoryFilter} aria-label="Nach Kategorie filtern">
|
||||
{#each CATEGORY_OPTIONS as opt (opt.value)}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Modul (z. B. todo)"
|
||||
bind:value={modulePill}
|
||||
aria-label="Nach Modul filtern"
|
||||
/>
|
||||
</header>
|
||||
|
||||
{#if feed.loading && feed.items.length === 0}
|
||||
<div class="state">Lade…</div>
|
||||
{:else if feed.error}
|
||||
<div class="state error">{feed.error}</div>
|
||||
{:else if feed.items.length === 0}
|
||||
<div class="state">
|
||||
Noch keine Stimmen — sei der erste, der was reinwirft.
|
||||
{#if !authStore.user}
|
||||
<br />
|
||||
<a href="/login" class="link">Login</a>, um mitzumachen.
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid">
|
||||
{#each feed.items as item (item.id)}
|
||||
<ItemCard
|
||||
{item}
|
||||
readOnly={!authStore.user}
|
||||
onReact={(id, emoji) => toggleReactionOnItem(feed, id, emoji)}
|
||||
onClick={handleClickItem}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.community-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.filter-row select,
|
||||
.filter-row input {
|
||||
padding: 0.375rem 0.625rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-surface, var(--color-background)));
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.filter-row select:focus,
|
||||
.filter-row input:focus {
|
||||
outline: none;
|
||||
border-color: hsl(var(--color-primary));
|
||||
box-shadow: 0 0 0 3px hsl(var(--color-primary) / 0.15);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
.state {
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.state.error {
|
||||
color: hsl(var(--color-error, 0 84% 60%));
|
||||
}
|
||||
|
||||
.link {
|
||||
color: hsl(var(--color-primary));
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
<!--
|
||||
RoadmapView — 4-column Kanban over the public feed:
|
||||
Submitted | Planned | In Progress | Shipped (= completed)
|
||||
|
||||
Uses 4 separate feed-queries (one per status) so each column can be
|
||||
paginated independently later. Read-only for guests; clicking an item
|
||||
navigates to the detail page.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import type { FeedbackStatus, PublicFeedbackItem } from '@mana/feedback';
|
||||
import { useCommunityFeed, toggleReactionOnItem } from '../queries';
|
||||
import ItemCard from '../components/ItemCard.svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
const COLUMNS: { status: FeedbackStatus; label: string; tone: string }[] = [
|
||||
{ status: 'submitted', label: 'Eingereicht', tone: '#6B7280' },
|
||||
{ status: 'planned', label: 'Geplant', tone: '#9B59B6' },
|
||||
{ status: 'in_progress', label: 'In Arbeit', tone: '#F39C12' },
|
||||
{ status: 'completed', label: 'Geliefert', tone: '#27AE60' },
|
||||
];
|
||||
|
||||
const queries = COLUMNS.map((col) => ({
|
||||
col,
|
||||
feed: useCommunityFeed({ status: col.status, limit: 25 }),
|
||||
}));
|
||||
|
||||
function handleClick(id: string) {
|
||||
void goto(`/community/${id}`);
|
||||
}
|
||||
|
||||
async function handleReact(feedRef: { items: PublicFeedbackItem[] }, id: string, emoji: string) {
|
||||
await toggleReactionOnItem(feedRef, id, emoji);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="roadmap">
|
||||
{#each queries as { col, feed } (col.status)}
|
||||
<section class="column" style:border-top-color={col.tone}>
|
||||
<header class="col-header">
|
||||
<h3 style:color={col.tone}>{col.label}</h3>
|
||||
<span class="count">{feed.items.length}</span>
|
||||
</header>
|
||||
|
||||
<div class="col-body">
|
||||
{#if feed.loading && feed.items.length === 0}
|
||||
<div class="state">Lade…</div>
|
||||
{:else if feed.items.length === 0}
|
||||
<div class="state">Nichts hier.</div>
|
||||
{:else}
|
||||
{#each feed.items as item (item.id)}
|
||||
<ItemCard
|
||||
{item}
|
||||
readOnly={!authStore.user}
|
||||
onReact={(id, emoji) => handleReact(feed, id, emoji)}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.roadmap {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.625rem;
|
||||
padding: 0.75rem;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.roadmap {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 540px) {
|
||||
.roadmap {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background: hsl(var(--color-muted) / 0.25);
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-top: 3px solid;
|
||||
border-radius: 0.75rem;
|
||||
min-height: 12rem;
|
||||
}
|
||||
|
||||
.col-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.25rem 0.375rem 0.5rem;
|
||||
}
|
||||
|
||||
.col-header h3 {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: 0.6875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.col-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.state {
|
||||
padding: 1rem 0.5rem;
|
||||
text-align: center;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -14,7 +14,11 @@ export function useConversations() {
|
|||
'companion',
|
||||
'companionConversations'
|
||||
).toArray();
|
||||
return all.filter((c) => !c.deletedAt).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
return all
|
||||
.filter((c) => !c.deletedAt)
|
||||
.sort((a, b) =>
|
||||
(b.lastMessageAt ?? b.createdAt).localeCompare(a.lastMessageAt ?? a.createdAt)
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ export const chatStore = {
|
|||
id: crypto.randomUUID(),
|
||||
title: title ?? 'Neues Gespraech',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
await db.table<LocalConversation>(CONV_TABLE).add(conv);
|
||||
emitDomainEvent('CompanionConversationStarted', 'companion', CONV_TABLE, conv.id, {
|
||||
|
|
@ -35,14 +34,12 @@ export const chatStore = {
|
|||
async renameConversation(id: string, title: string): Promise<void> {
|
||||
await db.table<LocalConversation>(CONV_TABLE).update(id, {
|
||||
title,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
async deleteConversation(id: string): Promise<void> {
|
||||
await db.table<LocalConversation>(CONV_TABLE).update(id, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -68,9 +65,10 @@ export const chatStore = {
|
|||
};
|
||||
await db.table<LocalMessage>(MSG_TABLE).add(msg);
|
||||
|
||||
// Touch conversation updatedAt
|
||||
// Touch conversation so the chat list re-sorts with the most
|
||||
// recently active conversation at the top.
|
||||
await db.table<LocalConversation>(CONV_TABLE).update(conversationId, {
|
||||
updatedAt: msg.createdAt,
|
||||
lastMessageAt: msg.createdAt,
|
||||
});
|
||||
|
||||
// Emit event only for actual user/assistant messages, not tool plumbing
|
||||
|
|
|
|||
|
|
@ -6,7 +6,11 @@ export interface LocalConversation {
|
|||
id: string;
|
||||
title: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
/** Real data field touched on every new message. Used as the sort key
|
||||
* in the conversation list so the most recently active chat floats
|
||||
* to the top. Replaces the older `updatedAt` reliance — F3 of the
|
||||
* sync-field-meta overhaul moved updatedAt off Local records. */
|
||||
lastMessageAt?: string;
|
||||
deletedAt?: string;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
*/
|
||||
|
||||
import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte';
|
||||
import { deriveUpdatedAt } from '$lib/data/sync';
|
||||
import { db } from '$lib/data/database';
|
||||
import { scopedForModule, applyVisibility } from '$lib/data/scope';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
|
|
@ -46,7 +47,7 @@ export function toContact(local: LocalContact): Contact {
|
|||
isFavorite: local.isFavorite ?? false,
|
||||
isArchived: local.isArchived ?? false,
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: local.updatedAt ?? new Date().toISOString(),
|
||||
updatedAt: deriveUpdatedAt(local),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -87,7 +87,6 @@ export const contactsStore = {
|
|||
|
||||
const diff: Partial<LocalContact> = {
|
||||
...updateData,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await encryptRecord('contacts', diff);
|
||||
await contactTable.update(id, diff);
|
||||
|
|
@ -99,7 +98,6 @@ export const contactsStore = {
|
|||
const decrypted = local ? await decryptRecord('contacts', { ...local }) : null;
|
||||
await contactTable.update(id, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
emitDomainEvent('ContactDeleted', 'contacts', 'contacts', id, {
|
||||
contactId: id,
|
||||
|
|
@ -114,7 +112,6 @@ export const contactsStore = {
|
|||
|
||||
await contactTable.update(id, {
|
||||
isFavorite: !local.isFavorite,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
ContactsEvents.contactFavorited();
|
||||
},
|
||||
|
|
@ -122,7 +119,6 @@ export const contactsStore = {
|
|||
async updateTagIds(id: string, tagIds: string[]) {
|
||||
await contactTable.update(id, {
|
||||
tagIds,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -153,7 +149,6 @@ export const contactsStore = {
|
|||
isFavorite: true,
|
||||
isArchived: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await encryptRecord('contacts', self);
|
||||
await contactTable.add(self);
|
||||
|
|
@ -178,7 +173,6 @@ export const contactsStore = {
|
|||
lastName,
|
||||
email: profile.email || undefined,
|
||||
photoUrl: profile.image || undefined,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await encryptRecord('contacts', diff);
|
||||
await contactTable.update(SELF_CONTACT_ID, diff);
|
||||
|
|
|
|||
|
|
@ -76,7 +76,6 @@
|
|||
await db.table('tasks').update(task.id, {
|
||||
isCompleted: true,
|
||||
completedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { formatDate } from '$lib/i18n/format';
|
||||
import { deriveUpdatedAt } from '$lib/data/sync';
|
||||
/**
|
||||
* Reactive Queries & Pure Helpers for Dreams module.
|
||||
*
|
||||
|
|
@ -44,7 +45,7 @@ export function toDream(local: LocalDream): Dream {
|
|||
isPinned: local.isPinned,
|
||||
isArchived: local.isArchived,
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: local.updatedAt ?? new Date().toISOString(),
|
||||
updatedAt: deriveUpdatedAt(local),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -56,7 +57,7 @@ export function toDreamSymbol(local: LocalDreamSymbol): DreamSymbol {
|
|||
color: local.color,
|
||||
count: local.count ?? 0,
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: local.updatedAt ?? new Date().toISOString(),
|
||||
updatedAt: deriveUpdatedAt(local),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -198,7 +198,6 @@ export const dreamsStore = {
|
|||
|
||||
const diff: Partial<LocalDream> = {
|
||||
...data,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await encryptRecord('dreams', diff);
|
||||
await dreamTable.update(id, diff);
|
||||
|
|
@ -256,7 +255,6 @@ export const dreamsStore = {
|
|||
await dreamTable.update(id, {
|
||||
processingStatus: status,
|
||||
processingError: error,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -285,7 +283,6 @@ export const dreamsStore = {
|
|||
content: decryptedExisting.content?.trim() ? decryptedExisting.content : transcript,
|
||||
processingStatus: 'idle',
|
||||
processingError: null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await encryptRecord('dreams', diff);
|
||||
await dreamTable.update(dreamId, diff);
|
||||
|
|
@ -294,7 +291,6 @@ export const dreamsStore = {
|
|||
await dreamTable.update(dreamId, {
|
||||
processingStatus: 'failed',
|
||||
processingError: msg,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
|
@ -309,7 +305,6 @@ export const dreamsStore = {
|
|||
}
|
||||
await dreamTable.update(id, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
emitDomainEvent('DreamDeleted', 'dreams', 'dreams', id, { dreamId: id });
|
||||
},
|
||||
|
|
@ -319,7 +314,6 @@ export const dreamsStore = {
|
|||
if (!dream) return;
|
||||
await dreamTable.update(id, {
|
||||
isPinned: !dream.isPinned,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -328,21 +322,18 @@ export const dreamsStore = {
|
|||
if (!dream) return;
|
||||
await dreamTable.update(id, {
|
||||
isLucid: !dream.isLucid,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
async setMood(id: string, mood: DreamMood | null) {
|
||||
await dreamTable.update(id, {
|
||||
mood,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
async setSleepQuality(id: string, quality: SleepQuality | null) {
|
||||
await dreamTable.update(id, {
|
||||
sleepQuality: quality,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -372,7 +363,6 @@ export const dreamsStore = {
|
|||
const updated = dream.symbols.map((s) => (s === existing.name ? newName : s));
|
||||
await dreamTable.update(dream.id, {
|
||||
symbols: updated,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -380,7 +370,6 @@ export const dreamsStore = {
|
|||
const symbolDiff: Record<string, unknown> = {
|
||||
...data,
|
||||
...(data.name ? { name: data.name.trim() } : {}),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await encryptRecord('dreamSymbols', symbolDiff);
|
||||
await dreamSymbolTable.update(id, symbolDiff);
|
||||
|
|
@ -396,13 +385,11 @@ export const dreamsStore = {
|
|||
if (dream.deletedAt || !dream.symbols?.includes(symbol.name)) continue;
|
||||
await dreamTable.update(dream.id, {
|
||||
symbols: dream.symbols.filter((s) => s !== symbol.name),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
await dreamSymbolTable.update(id, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -421,17 +408,14 @@ export const dreamsStore = {
|
|||
set.add(target.name);
|
||||
await dreamTable.update(dream.id, {
|
||||
symbols: Array.from(set),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
await dreamSymbolTable.update(targetId, {
|
||||
count: (target.count ?? 0) + (source.count ?? 0),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
await dreamSymbolTable.update(sourceId, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -445,7 +429,6 @@ export const dreamsStore = {
|
|||
const next = Math.max(0, (existing.count ?? 0) + delta);
|
||||
await dreamSymbolTable.update(existing.id, {
|
||||
count: next,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
} else if (delta > 0) {
|
||||
await dreamSymbolTable.add({
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
*/
|
||||
|
||||
import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte';
|
||||
import { deriveUpdatedAt } from '$lib/data/sync';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import { db } from '$lib/data/database';
|
||||
import { scopedForModule } from '$lib/data/scope';
|
||||
|
|
@ -22,7 +23,7 @@ export function toDrinkEntry(local: LocalDrinkEntry): DrinkEntry {
|
|||
note: local.note ?? null,
|
||||
presetId: local.presetId ?? null,
|
||||
createdAt: local.createdAt ?? now,
|
||||
updatedAt: local.updatedAt ?? now,
|
||||
updatedAt: deriveUpdatedAt(local),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -38,7 +39,7 @@ export function toDrinkPreset(local: LocalDrinkPreset): DrinkPreset {
|
|||
order: local.order,
|
||||
isArchived: local.isArchived,
|
||||
createdAt: local.createdAt ?? now,
|
||||
updatedAt: local.updatedAt ?? now,
|
||||
updatedAt: deriveUpdatedAt(local),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -69,7 +69,6 @@ export const drinkStore = {
|
|||
await encryptRecord('drinkEntries', wrapped);
|
||||
await drinkEntryTable.update(id, {
|
||||
...wrapped,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -77,7 +76,6 @@ export const drinkStore = {
|
|||
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, {
|
||||
|
|
@ -97,7 +95,6 @@ export const drinkStore = {
|
|||
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,
|
||||
|
|
@ -146,14 +143,12 @@ export const drinkStore = {
|
|||
await encryptRecord('drinkPresets', wrapped);
|
||||
await drinkPresetTable.update(id, {
|
||||
...wrapped,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
async deletePreset(id: string) {
|
||||
await drinkPresetTable.update(id, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -162,7 +157,6 @@ export const drinkStore = {
|
|||
for (let i = 0; i < presetIds.length; i++) {
|
||||
await drinkPresetTable.update(presetIds[i], {
|
||||
order: i,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@ export interface DiscoverySource {
|
|||
lastError: string | null;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface DiscoveredEvent {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
*/
|
||||
|
||||
import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte';
|
||||
import { deriveUpdatedAt } from '$lib/data/sync';
|
||||
import { db } from '$lib/data/database';
|
||||
import { scopedForModule } from '$lib/data/scope';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
|
|
@ -47,7 +48,7 @@ export function toSocialEvent(local: LocalSocialEvent, block: LocalTimeBlock | n
|
|||
endTime: block?.endDate ?? block?.startDate ?? now,
|
||||
isAllDay: block?.allDay ?? false,
|
||||
createdAt: local.createdAt ?? now,
|
||||
updatedAt: local.updatedAt ?? now,
|
||||
updatedAt: deriveUpdatedAt(local),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -64,7 +65,7 @@ export function toEventItem(local: LocalEventItem): EventItem {
|
|||
claimedByName: local.claimedByName ?? null,
|
||||
claimedAt: local.claimedAt ?? null,
|
||||
createdAt: local.createdAt ?? now,
|
||||
updatedAt: local.updatedAt ?? now,
|
||||
updatedAt: deriveUpdatedAt(local),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -82,7 +83,7 @@ export function toEventGuest(local: LocalEventGuest): EventGuest {
|
|||
plusOnes: local.plusOnes ?? 0,
|
||||
note: local.note ?? null,
|
||||
createdAt: local.createdAt ?? now,
|
||||
updatedAt: local.updatedAt ?? now,
|
||||
updatedAt: deriveUpdatedAt(local),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -71,7 +71,6 @@ export const eventsStore = {
|
|||
status: input.status ?? 'draft',
|
||||
visibility: defaultVisibilityFor(getActiveSpace()?.type),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// title / description / location are encrypted at rest. The
|
||||
|
|
@ -125,9 +124,7 @@ export const eventsStore = {
|
|||
await updateBlock(event.timeBlockId, blockUpdates);
|
||||
}
|
||||
|
||||
const localData: Partial<LocalSocialEvent> = {
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
const localData: Partial<LocalSocialEvent> = {};
|
||||
if (input.title !== undefined) localData.title = input.title;
|
||||
if (input.description !== undefined) localData.description = input.description;
|
||||
if (input.location !== undefined) localData.location = input.location;
|
||||
|
|
@ -170,7 +167,6 @@ export const eventsStore = {
|
|||
}
|
||||
await db.table('socialEvents').update(id, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
emitDomainEvent('SocialEventDeleted', 'events', 'socialEvents', id, { eventId: id });
|
||||
return { success: true as const };
|
||||
|
|
@ -199,7 +195,6 @@ export const eventsStore = {
|
|||
visibility: next,
|
||||
visibilityChangedAt: now,
|
||||
visibilityChangedBy: getEffectiveUserId(),
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
emitDomainEvent('VisibilityChanged', 'events', 'socialEvents', id, {
|
||||
|
|
@ -246,7 +241,6 @@ export const eventsStore = {
|
|||
isPublished: true,
|
||||
publicToken: token,
|
||||
status: 'published' satisfies EventStatus,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
// Push any pre-existing bring-list items right away so the
|
||||
// public page shows them on first open.
|
||||
|
|
@ -276,7 +270,6 @@ export const eventsStore = {
|
|||
isPublished: false,
|
||||
publicToken: null,
|
||||
status: 'draft' satisfies EventStatus,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
return { success: true as const };
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -38,7 +38,6 @@ export const eventGuestsStore = {
|
|||
plusOnes: input.plusOnes ?? 0,
|
||||
note: input.note ?? null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
// name / email / phone / note are encrypted at rest. Guest
|
||||
// records stay local-only — they're never pushed to the
|
||||
|
|
@ -68,7 +67,6 @@ export const eventGuestsStore = {
|
|||
try {
|
||||
const data: Partial<LocalEventGuest> = {
|
||||
...input,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
if (input.rsvpStatus !== undefined) {
|
||||
data.rsvpAt = new Date().toISOString();
|
||||
|
|
@ -91,7 +89,6 @@ export const eventGuestsStore = {
|
|||
try {
|
||||
await db.table('eventGuests').update(id, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
return { success: true as const };
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -45,7 +45,6 @@ export const eventItemsStore = {
|
|||
claimedByName: null,
|
||||
claimedAt: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await db.table<LocalEventItem>('eventItems').add(newItem);
|
||||
void eventsStore.syncItems(input.eventId);
|
||||
|
|
@ -71,7 +70,6 @@ export const eventItemsStore = {
|
|||
try {
|
||||
await db.table('eventItems').update(id, {
|
||||
...input,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
// Push the updated bring list to the server. We need the
|
||||
// parent eventId, so re-read the row first.
|
||||
|
|
@ -103,7 +101,6 @@ export const eventItemsStore = {
|
|||
const item = await db.table<LocalEventItem>('eventItems').get(id);
|
||||
await db.table('eventItems').update(id, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
if (item) void eventsStore.syncItems(item.eventId);
|
||||
return { success: true as const };
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
*/
|
||||
|
||||
import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte';
|
||||
import { deriveUpdatedAt } from '$lib/data/sync';
|
||||
import { db } from '$lib/data/database';
|
||||
import { scopedForModule } from '$lib/data/scope';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
|
|
@ -26,7 +27,7 @@ export function toTransaction(local: LocalTransaction): Transaction {
|
|||
date: local.date,
|
||||
note: local.note,
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: local.updatedAt ?? new Date().toISOString(),
|
||||
updatedAt: deriveUpdatedAt(local),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -52,7 +52,6 @@ export const financeStore = {
|
|||
) {
|
||||
const diff: Partial<LocalTransaction> = {
|
||||
...data,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await encryptRecord('transactions', diff);
|
||||
await transactionTable.update(id, diff);
|
||||
|
|
@ -61,7 +60,6 @@ export const financeStore = {
|
|||
async deleteTransaction(id: string) {
|
||||
await transactionTable.update(id, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
emitDomainEvent('TransactionDeleted', 'finance', 'transactions', id, { transactionId: id });
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte';
|
||||
import { deriveUpdatedAt } from '$lib/data/sync';
|
||||
import { db } from '$lib/data/database';
|
||||
import { scopedForModule } from '$lib/data/scope';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
|
|
@ -28,7 +29,7 @@ export function toFirst(local: LocalFirst): First {
|
|||
isPinned: local.isPinned,
|
||||
isArchived: local.isArchived,
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: local.updatedAt ?? new Date().toISOString(),
|
||||
updatedAt: deriveUpdatedAt(local),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -126,7 +126,6 @@ export const firstsStore = {
|
|||
reality: data.reality ?? null,
|
||||
rating: data.rating ?? null,
|
||||
wouldRepeat: data.wouldRepeat ?? null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
if (data.personIds) diff.personIds = data.personIds;
|
||||
if (data.sharedWith !== undefined) diff.sharedWith = data.sharedWith;
|
||||
|
|
@ -164,7 +163,6 @@ export const firstsStore = {
|
|||
) {
|
||||
const diff: Partial<LocalFirst> = {
|
||||
...data,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await encryptRecord('firsts', diff);
|
||||
await firstTable.update(id, diff);
|
||||
|
|
@ -173,7 +171,6 @@ export const firstsStore = {
|
|||
async deleteFirst(id: string) {
|
||||
await firstTable.update(id, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -182,14 +179,12 @@ export const firstsStore = {
|
|||
if (!first) return;
|
||||
await firstTable.update(id, {
|
||||
isPinned: !first.isPinned,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
async archiveFirst(id: string) {
|
||||
await firstTable.update(id, {
|
||||
isArchived: true,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -70,7 +70,6 @@ export const mealMutations = {
|
|||
photoThumbnailUrl: null,
|
||||
foods: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
const encrypted: Record<string, unknown> = { ...row };
|
||||
await encryptRecord('meals', encrypted);
|
||||
|
|
@ -104,7 +103,6 @@ export const mealMutations = {
|
|||
photoThumbnailUrl: dto.photoThumbnailUrl ?? null,
|
||||
foods: dto.foods ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
const encrypted: Record<string, unknown> = { ...row };
|
||||
await encryptRecord('meals', encrypted);
|
||||
|
|
@ -130,9 +128,7 @@ export const mealMutations = {
|
|||
* and decrypts it for the caller.
|
||||
*/
|
||||
async update(id: string, dto: UpdateMealDto): Promise<LocalMeal> {
|
||||
const updateData: Record<string, unknown> = {
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (dto.mealType !== undefined) updateData.mealType = dto.mealType;
|
||||
if (dto.description !== undefined) updateData.description = dto.description.trim();
|
||||
if (dto.nutrition !== undefined) updateData.nutrition = dto.nutrition;
|
||||
|
|
@ -150,7 +146,7 @@ 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 });
|
||||
await db.table('meals').update(id, { deletedAt: now });
|
||||
emitDomainEvent('MealDeleted', 'food', 'meals', id, {
|
||||
mealId: id,
|
||||
mealType: existing?.mealType ?? '',
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
*/
|
||||
|
||||
import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte';
|
||||
import { deriveUpdatedAt } from '$lib/data/sync';
|
||||
import { db } from '$lib/data/database';
|
||||
import { scopedForModule } from '$lib/data/scope';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
|
|
@ -34,7 +35,7 @@ export function toGuide(local: LocalGuide): Guide {
|
|||
isPublished: local.isPublished,
|
||||
order: local.order,
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: local.updatedAt ?? new Date().toISOString(),
|
||||
updatedAt: deriveUpdatedAt(local),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -46,7 +47,7 @@ export function toSection(local: LocalSection): Section {
|
|||
content: local.content,
|
||||
order: local.order,
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: local.updatedAt ?? new Date().toISOString(),
|
||||
updatedAt: deriveUpdatedAt(local),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -59,7 +60,7 @@ export function toStep(local: LocalStep): Step {
|
|||
content: local.content,
|
||||
order: local.order,
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: local.updatedAt ?? new Date().toISOString(),
|
||||
updatedAt: deriveUpdatedAt(local),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -71,7 +72,7 @@ export function toRun(local: LocalRun): Run {
|
|||
completedAt: local.completedAt,
|
||||
completedStepIds: local.completedStepIds,
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: local.updatedAt ?? new Date().toISOString(),
|
||||
updatedAt: deriveUpdatedAt(local),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ export const guidesStore = {
|
|||
},
|
||||
|
||||
async updateGuide(id: string, dto: UpdateGuideDto): Promise<void> {
|
||||
const updates: Record<string, unknown> = { updatedAt: new Date().toISOString() };
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (dto.title !== undefined) updates.title = dto.title;
|
||||
if (dto.description !== undefined) updates.description = dto.description;
|
||||
if (dto.category !== undefined) updates.category = dto.category;
|
||||
|
|
@ -74,17 +74,17 @@ export const guidesStore = {
|
|||
// Cascade: soft-delete sections, steps, and runs
|
||||
const sections = await sectionTable.where('guideId').equals(id).toArray();
|
||||
for (const s of sections) {
|
||||
await sectionTable.update(s.id, { deletedAt: now, updatedAt: now });
|
||||
await sectionTable.update(s.id, { deletedAt: now });
|
||||
}
|
||||
const steps = await stepTable.where('guideId').equals(id).toArray();
|
||||
for (const s of steps) {
|
||||
await stepTable.update(s.id, { deletedAt: now, updatedAt: now });
|
||||
await stepTable.update(s.id, { deletedAt: now });
|
||||
}
|
||||
const runs = await runTable.where('guideId').equals(id).toArray();
|
||||
for (const r of runs) {
|
||||
await runTable.update(r.id, { deletedAt: now, updatedAt: now });
|
||||
await runTable.update(r.id, { deletedAt: now });
|
||||
}
|
||||
await guideTable.update(id, { deletedAt: now, updatedAt: now });
|
||||
await guideTable.update(id, { deletedAt: now });
|
||||
},
|
||||
|
||||
// ─── Sections ────────────────────────────────────────
|
||||
|
|
@ -107,7 +107,7 @@ export const guidesStore = {
|
|||
},
|
||||
|
||||
async updateSection(id: string, dto: UpdateSectionDto): Promise<void> {
|
||||
const updates: Record<string, unknown> = { updatedAt: new Date().toISOString() };
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (dto.title !== undefined) updates.title = dto.title;
|
||||
if (dto.content !== undefined) updates.content = dto.content;
|
||||
await encryptRecord('sections', updates);
|
||||
|
|
@ -116,7 +116,7 @@ export const guidesStore = {
|
|||
|
||||
async deleteSection(id: string): Promise<void> {
|
||||
const now = new Date().toISOString();
|
||||
await sectionTable.update(id, { deletedAt: now, updatedAt: now });
|
||||
await sectionTable.update(id, { deletedAt: now });
|
||||
},
|
||||
|
||||
// ─── Steps ───────────────────────────────────────────
|
||||
|
|
@ -140,7 +140,7 @@ export const guidesStore = {
|
|||
},
|
||||
|
||||
async updateStep(id: string, dto: UpdateStepDto): Promise<void> {
|
||||
const updates: Record<string, unknown> = { updatedAt: new Date().toISOString() };
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (dto.title !== undefined) updates.title = dto.title;
|
||||
if (dto.content !== undefined) updates.content = dto.content;
|
||||
if (dto.sectionId !== undefined) updates.sectionId = dto.sectionId;
|
||||
|
|
@ -150,7 +150,7 @@ export const guidesStore = {
|
|||
|
||||
async deleteStep(id: string): Promise<void> {
|
||||
const now = new Date().toISOString();
|
||||
await stepTable.update(id, { deletedAt: now, updatedAt: now });
|
||||
await stepTable.update(id, { deletedAt: now });
|
||||
},
|
||||
|
||||
// ─── Runs (Progress Tracking) ────────────────────────
|
||||
|
|
@ -194,7 +194,6 @@ export const guidesStore = {
|
|||
if (run.completedStepIds.includes(stepId)) return;
|
||||
await runTable.update(runId, {
|
||||
completedStepIds: [...run.completedStepIds, stepId],
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -203,7 +202,6 @@ export const guidesStore = {
|
|||
if (!run) return;
|
||||
await runTable.update(runId, {
|
||||
completedStepIds: run.completedStepIds.filter((id) => id !== stepId),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -212,7 +210,6 @@ export const guidesStore = {
|
|||
const run = await runTable.get(runId);
|
||||
await runTable.update(runId, {
|
||||
completedAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
if (run?.timeBlockId) {
|
||||
await updateBlock(run.timeBlockId, { endDate: now });
|
||||
|
|
@ -225,6 +222,6 @@ export const guidesStore = {
|
|||
if (run?.timeBlockId) {
|
||||
await deleteBlock(run.timeBlockId);
|
||||
}
|
||||
await runTable.update(id, { deletedAt: now, updatedAt: now });
|
||||
await runTable.update(id, { deletedAt: now });
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
*/
|
||||
|
||||
import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte';
|
||||
import { deriveUpdatedAt } from '$lib/data/sync';
|
||||
import { db } from '$lib/data/database';
|
||||
import { scopedForModule } from '$lib/data/scope';
|
||||
import type { LocalHabit, LocalHabitLog, Habit, HabitLog } from './types';
|
||||
|
|
@ -28,7 +29,7 @@ export function toHabit(local: LocalHabit): Habit {
|
|||
// default at create time in habits.svelte.ts.
|
||||
visibility: local.visibility ?? 'space',
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: local.updatedAt ?? new Date().toISOString(),
|
||||
updatedAt: deriveUpdatedAt(local),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -126,7 +126,6 @@ export const habitsStore = {
|
|||
) {
|
||||
await habitTable.update(id, {
|
||||
...data,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -148,7 +147,6 @@ export const habitsStore = {
|
|||
visibility: next,
|
||||
visibilityChangedAt: now,
|
||||
visibilityChangedBy: getEffectiveUserId(),
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
emitDomainEvent('VisibilityChanged', 'habits', 'habits', id, {
|
||||
|
|
@ -163,7 +161,6 @@ export const habitsStore = {
|
|||
const habit = await habitTable.get(id);
|
||||
await habitTable.update(id, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
emitDomainEvent('HabitDeleted', 'habits', 'habits', id, {
|
||||
habitId: id,
|
||||
|
|
@ -318,7 +315,6 @@ export const habitsStore = {
|
|||
for (let i = 0; i < habitIds.length; i++) {
|
||||
await habitTable.update(habitIds[i], {
|
||||
order: i,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
|
@ -334,7 +330,6 @@ export const habitsStore = {
|
|||
// Update the habit record
|
||||
await habitTable.update(habitId, {
|
||||
schedule,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Find existing template block for this habit
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
*/
|
||||
|
||||
import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte';
|
||||
import { deriveUpdatedAt } from '$lib/data/sync';
|
||||
import { db } from '$lib/data/database';
|
||||
import { scopedForModule } from '$lib/data/scope';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
|
|
@ -104,7 +105,7 @@ export function toCollection(local: LocalCollection): Collection {
|
|||
order: local.order,
|
||||
itemCount: local.itemCount,
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: local.updatedAt ?? new Date().toISOString(),
|
||||
updatedAt: deriveUpdatedAt(local),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -126,7 +127,7 @@ export function toItem(local: LocalItem): Item {
|
|||
tags: local.tags,
|
||||
order: local.order,
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: local.updatedAt ?? new Date().toISOString(),
|
||||
updatedAt: deriveUpdatedAt(local),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -141,7 +142,7 @@ export function toLocation(local: LocalLocation): Location {
|
|||
depth: local.depth,
|
||||
order: local.order,
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: local.updatedAt ?? new Date().toISOString(),
|
||||
updatedAt: deriveUpdatedAt(local),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -154,7 +155,7 @@ export function toCategory(local: LocalCategory): Category {
|
|||
color: local.color ?? undefined,
|
||||
order: local.order,
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: local.updatedAt ?? new Date().toISOString(),
|
||||
updatedAt: deriveUpdatedAt(local),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -32,7 +32,6 @@ export const categoriesStore = {
|
|||
async update(id: string, data: Partial<Pick<LocalCategory, 'name' | 'icon' | 'color'>>) {
|
||||
await invCategoryTable.update(id, {
|
||||
...data,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -47,7 +46,7 @@ export const categoriesStore = {
|
|||
collectIds(id);
|
||||
const now = new Date().toISOString();
|
||||
for (const deleteId of idsToDelete) {
|
||||
await invCategoryTable.update(deleteId, { deletedAt: now, updatedAt: now });
|
||||
await invCategoryTable.update(deleteId, { deletedAt: now });
|
||||
}
|
||||
InventoryEvents.categoryDeleted();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -43,14 +43,12 @@ export const collectionsStore = {
|
|||
) {
|
||||
await invCollectionTable.update(id, {
|
||||
...data,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
async delete(id: string) {
|
||||
await invCollectionTable.update(id, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
InventoryEvents.collectionDeleted();
|
||||
},
|
||||
|
|
@ -58,14 +56,13 @@ export const collectionsStore = {
|
|||
async reorder(orderedIds: string[]) {
|
||||
const now = new Date().toISOString();
|
||||
for (let i = 0; i < orderedIds.length; i++) {
|
||||
await invCollectionTable.update(orderedIds[i], { order: i, updatedAt: now });
|
||||
await invCollectionTable.update(orderedIds[i], { order: i });
|
||||
}
|
||||
},
|
||||
|
||||
async updateItemCount(collectionId: string, count: number) {
|
||||
await invCollectionTable.update(collectionId, {
|
||||
itemCount: count,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -78,7 +78,6 @@ export const itemsStore = {
|
|||
) {
|
||||
const diff: Partial<LocalItem> = {
|
||||
...data,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await encryptRecord('invItems', diff);
|
||||
await invItemTable.update(id, diff);
|
||||
|
|
@ -88,7 +87,6 @@ export const itemsStore = {
|
|||
async delete(id: string) {
|
||||
await invItemTable.update(id, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
InventoryEvents.itemDeleted();
|
||||
},
|
||||
|
|
@ -98,7 +96,7 @@ export const itemsStore = {
|
|||
const toDelete = all.filter((i) => !i.deletedAt && i.collectionId === collectionId);
|
||||
const now = new Date().toISOString();
|
||||
for (const item of toDelete) {
|
||||
await invItemTable.update(item.id, { deletedAt: now, updatedAt: now });
|
||||
await invItemTable.update(item.id, { deletedAt: now });
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -109,7 +107,6 @@ export const itemsStore = {
|
|||
const note = { id: crypto.randomUUID(), content, createdAt: now };
|
||||
await invItemTable.update(itemId, {
|
||||
notes: [...item.notes, note],
|
||||
updatedAt: now,
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -118,7 +115,6 @@ export const itemsStore = {
|
|||
if (!item) return;
|
||||
await invItemTable.update(itemId, {
|
||||
notes: item.notes.filter((n) => n.id !== noteId),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -49,7 +49,6 @@ export const locationsStore = {
|
|||
async update(id: string, data: Partial<Pick<LocalLocation, 'name' | 'description' | 'icon'>>) {
|
||||
await invLocationTable.update(id, {
|
||||
...data,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -64,7 +63,7 @@ export const locationsStore = {
|
|||
collectIds(id);
|
||||
const now = new Date().toISOString();
|
||||
for (const deleteId of idsToDelete) {
|
||||
await invLocationTable.update(deleteId, { deletedAt: now, updatedAt: now });
|
||||
await invLocationTable.update(deleteId, { deletedAt: now });
|
||||
}
|
||||
InventoryEvents.locationDeleted();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
*/
|
||||
|
||||
import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte';
|
||||
import { deriveUpdatedAt } from '$lib/data/sync';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import { scopedForModule } from '$lib/data/scope';
|
||||
import type {
|
||||
|
|
@ -48,7 +49,7 @@ export function toInvoice(local: LocalInvoice): Invoice {
|
|||
pdfBlobKey: local.pdfBlobKey ?? null,
|
||||
totals: local.totals,
|
||||
createdAt: local.createdAt ?? now,
|
||||
updatedAt: local.updatedAt ?? now,
|
||||
updatedAt: deriveUpdatedAt(local),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -137,10 +137,7 @@ export const invoicesStore = {
|
|||
}
|
||||
const wrapped = { ...patch } as Record<string, unknown>;
|
||||
await encryptRecord('invoices', wrapped);
|
||||
await invoiceTable.update(id, {
|
||||
...wrapped,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
await invoiceTable.update(id, wrapped as never);
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
@ -159,10 +156,7 @@ export const invoicesStore = {
|
|||
// `lines` is in the encryption allowlist; `totals` is not. encryptRecord
|
||||
// only touches allowlisted keys, so a single call is correct for both.
|
||||
await encryptRecord('invoices', patch);
|
||||
await invoiceTable.update(id, {
|
||||
...patch,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
await invoiceTable.update(id, patch as never);
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
@ -178,7 +172,6 @@ export const invoicesStore = {
|
|||
await invoiceTable.update(id, {
|
||||
status: 'sent',
|
||||
sentAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
emitDomainEvent('InvoiceSent', 'invoices', 'invoices', id, {
|
||||
invoiceId: id,
|
||||
|
|
@ -198,7 +191,6 @@ export const invoicesStore = {
|
|||
await invoiceTable.update(id, {
|
||||
status: 'paid',
|
||||
paidAt: stamp,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
emitDomainEvent('InvoicePaid', 'invoices', 'invoices', id, {
|
||||
invoiceId: id,
|
||||
|
|
@ -243,7 +235,6 @@ export const invoicesStore = {
|
|||
}
|
||||
await invoiceTable.update(id, {
|
||||
status: 'void',
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
emitDomainEvent('InvoiceVoided', 'invoices', 'invoices', id, {
|
||||
invoiceId: id,
|
||||
|
|
@ -294,7 +285,6 @@ export const invoicesStore = {
|
|||
}
|
||||
await invoiceTable.update(id, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
emitDomainEvent('InvoiceDeleted', 'invoices', 'invoices', id, { invoiceId: id });
|
||||
},
|
||||
|
|
|
|||
|
|
@ -134,7 +134,6 @@ export const invoiceSettingsStore = {
|
|||
out = formatNumber(prefix, nextN, padding);
|
||||
await invoiceSettingsTable.update(INVOICE_SETTINGS_ID, {
|
||||
nextNumber: nextN + 1,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
return out;
|
||||
|
|
@ -146,7 +145,6 @@ export const invoiceSettingsStore = {
|
|||
await encryptRecord('invoiceSettings', wrapped);
|
||||
await invoiceSettingsTable.update(INVOICE_SETTINGS_ID, {
|
||||
...wrapped,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
emitDomainEvent('InvoiceSettingsUpdated', 'invoices', 'invoiceSettings', INVOICE_SETTINGS_ID, {
|
||||
fields: Object.keys(patch),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { formatDate } from '$lib/i18n/format';
|
||||
import { deriveUpdatedAt } from '$lib/data/sync';
|
||||
/**
|
||||
* Reactive Queries & Pure Helpers for Journal module.
|
||||
*
|
||||
|
|
@ -29,7 +30,7 @@ export function toJournalEntry(local: LocalJournalEntry): JournalEntry {
|
|||
wordCount: local.wordCount ?? 0,
|
||||
transcriptModel: local.transcriptModel ?? null,
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: local.updatedAt ?? new Date().toISOString(),
|
||||
updatedAt: deriveUpdatedAt(local),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -76,7 +76,6 @@ export const journalStore = {
|
|||
) {
|
||||
const diff: Partial<LocalJournalEntry> = {
|
||||
...data,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Recompute word count when content changes
|
||||
|
|
@ -117,7 +116,6 @@ export const journalStore = {
|
|||
content: transcript,
|
||||
transcriptModel: result.model,
|
||||
wordCount: countWords(transcript),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await encryptRecord('journalEntries', diff);
|
||||
await journalEntryTable.update(entryId, diff);
|
||||
|
|
@ -133,7 +131,6 @@ export const journalStore = {
|
|||
async deleteEntry(id: string) {
|
||||
await journalEntryTable.update(id, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
emitDomainEvent('JournalEntryDeleted', 'journal', 'journalEntries', id, { entryId: id });
|
||||
},
|
||||
|
|
@ -143,7 +140,6 @@ export const journalStore = {
|
|||
if (!entry) return;
|
||||
await journalEntryTable.update(id, {
|
||||
isPinned: !entry.isPinned,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -152,14 +148,12 @@ export const journalStore = {
|
|||
if (!entry) return;
|
||||
await journalEntryTable.update(id, {
|
||||
isFavorite: !entry.isFavorite,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
async setMood(id: string, mood: JournalMood | null) {
|
||||
await journalEntryTable.update(id, {
|
||||
mood,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
if (mood)
|
||||
emitDomainEvent('JournalMoodSet', 'journal', 'journalEntries', id, { entryId: id, mood });
|
||||
|
|
@ -168,7 +162,6 @@ export const journalStore = {
|
|||
async archiveEntry(id: string) {
|
||||
await journalEntryTable.update(id, {
|
||||
isArchived: true,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
*/
|
||||
|
||||
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
|
||||
import { deriveUpdatedAt } from '$lib/data/sync';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import { scopedTable } from '$lib/data/scope/scoped-db';
|
||||
import type { KontextDoc, LocalKontextDoc } from './types';
|
||||
|
|
@ -19,7 +20,7 @@ export function toKontextDoc(local: LocalKontextDoc): KontextDoc {
|
|||
id: local.id,
|
||||
content: local.content ?? '',
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: local.updatedAt ?? new Date().toISOString(),
|
||||
updatedAt: deriveUpdatedAt(local),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -45,7 +45,6 @@ export const kontextStore = {
|
|||
const row = await this.ensureDoc();
|
||||
const diff: Partial<LocalKontextDoc> = {
|
||||
content,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await encryptRecord('kontextDoc', diff);
|
||||
await kontextDocTable.update(row.id, diff);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte';
|
||||
import { deriveUpdatedAt } from '$lib/data/sync';
|
||||
import { scopedForModule } from '$lib/data/scope';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import type { Last, LastStatus, LocalLast } from './types';
|
||||
|
|
@ -34,7 +35,7 @@ export function toLast(local: LocalLast): Last {
|
|||
unlistedToken: local.unlistedToken ?? '',
|
||||
unlistedExpiresAt: local.unlistedExpiresAt ?? null,
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: local.updatedAt ?? new Date().toISOString(),
|
||||
updatedAt: deriveUpdatedAt(local),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -150,7 +150,6 @@ export const lastsStore = {
|
|||
whatIKnowNow: data.whatIKnowNow ?? null,
|
||||
tenderness: data.tenderness ?? null,
|
||||
wouldReclaim: data.wouldReclaim ?? null,
|
||||
updatedAt: nowIso(),
|
||||
};
|
||||
await encryptRecord('lasts', diff);
|
||||
await lastTable.update(id, diff);
|
||||
|
|
@ -165,7 +164,6 @@ export const lastsStore = {
|
|||
status: 'reclaimed',
|
||||
reclaimedAt: nowIso(),
|
||||
reclaimedNote,
|
||||
updatedAt: nowIso(),
|
||||
};
|
||||
await encryptRecord('lasts', diff);
|
||||
await lastTable.update(id, diff);
|
||||
|
|
@ -198,7 +196,6 @@ export const lastsStore = {
|
|||
) {
|
||||
const diff: Partial<LocalLast> = {
|
||||
...data,
|
||||
updatedAt: nowIso(),
|
||||
};
|
||||
await encryptRecord('lasts', diff);
|
||||
await lastTable.update(id, diff);
|
||||
|
|
@ -207,7 +204,6 @@ export const lastsStore = {
|
|||
async deleteLast(id: string) {
|
||||
await lastTable.update(id, {
|
||||
deletedAt: nowIso(),
|
||||
updatedAt: nowIso(),
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -216,14 +212,12 @@ export const lastsStore = {
|
|||
if (!last) return;
|
||||
await lastTable.update(id, {
|
||||
isPinned: !last.isPinned,
|
||||
updatedAt: nowIso(),
|
||||
});
|
||||
},
|
||||
|
||||
async archiveLast(id: string) {
|
||||
await lastTable.update(id, {
|
||||
isArchived: true,
|
||||
updatedAt: nowIso(),
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -289,7 +283,6 @@ export const lastsStore = {
|
|||
async acceptCandidate(id: string) {
|
||||
await lastTable.update(id, {
|
||||
inferredFrom: null,
|
||||
updatedAt: nowIso(),
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -329,7 +322,6 @@ export const lastsStore = {
|
|||
visibility: next,
|
||||
visibilityChangedAt: now,
|
||||
visibilityChangedBy: getEffectiveUserId() ?? undefined,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
if (next === 'unlisted') {
|
||||
|
|
@ -407,7 +399,6 @@ export const lastsStore = {
|
|||
});
|
||||
await lastTable.update(id, {
|
||||
unlistedToken: token,
|
||||
updatedAt: nowIso(),
|
||||
});
|
||||
return token;
|
||||
},
|
||||
|
|
@ -438,7 +429,6 @@ export const lastsStore = {
|
|||
await lastTable.update(id, {
|
||||
unlistedToken: token,
|
||||
unlistedExpiresAt: expiresAt ? expiresAt.toISOString() : null,
|
||||
updatedAt: nowIso(),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
*/
|
||||
|
||||
import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte';
|
||||
import { deriveUpdatedAt } from '$lib/data/sync';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import { db } from '$lib/data/database';
|
||||
import { scopedForModule } from '$lib/data/scope';
|
||||
|
|
@ -39,7 +40,7 @@ export function toLibraryEntry(local: LocalLibraryEntry): LibraryEntry {
|
|||
unlistedToken: local.unlistedToken ?? '',
|
||||
unlistedExpiresAt: local.unlistedExpiresAt ?? null,
|
||||
createdAt: local.createdAt ?? now,
|
||||
updatedAt: local.updatedAt ?? now,
|
||||
updatedAt: deriveUpdatedAt(local),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -132,10 +132,7 @@ export const libraryEntriesStore = {
|
|||
) {
|
||||
const wrapped = { ...patch } as Record<string, unknown>;
|
||||
await encryptRecord('libraryEntries', wrapped);
|
||||
await libraryEntryTable.update(id, {
|
||||
...wrapped,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
await libraryEntryTable.update(id, wrapped as never);
|
||||
// Keep the share-link snapshot in sync if this entry is unlisted.
|
||||
void this.refreshUnlistedSnapshot(id);
|
||||
},
|
||||
|
|
@ -152,7 +149,6 @@ export const libraryEntriesStore = {
|
|||
}
|
||||
await libraryEntryTable.update(id, {
|
||||
...patch,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
if (status === 'completed') {
|
||||
emitDomainEvent('LibraryEntryCompleted', 'library', 'libraryEntries', id, {
|
||||
|
|
@ -178,14 +174,12 @@ export const libraryEntriesStore = {
|
|||
status: 'active',
|
||||
startedAt: nowDate,
|
||||
completedAt: null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
async rate(id: string, rating: number | null) {
|
||||
await libraryEntryTable.update(id, {
|
||||
rating,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -194,7 +188,6 @@ export const libraryEntriesStore = {
|
|||
if (!existing) return;
|
||||
await libraryEntryTable.update(id, {
|
||||
isFavorite: !existing.isFavorite,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -220,7 +213,6 @@ export const libraryEntriesStore = {
|
|||
|
||||
await libraryEntryTable.update(id, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
emitDomainEvent('LibraryEntryDeleted', 'library', 'libraryEntries', id, { entryId: id });
|
||||
},
|
||||
|
|
@ -241,7 +233,6 @@ export const libraryEntriesStore = {
|
|||
visibility: next,
|
||||
visibilityChangedAt: now,
|
||||
visibilityChangedBy: getEffectiveUserId(),
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
if (next === 'unlisted') {
|
||||
|
|
@ -274,7 +265,7 @@ export const libraryEntriesStore = {
|
|||
patch.unlistedExpiresAt = undefined;
|
||||
}
|
||||
|
||||
await libraryEntryTable.update(id, patch);
|
||||
await libraryEntryTable.update(id, patch as never);
|
||||
|
||||
emitDomainEvent('VisibilityChanged', 'library', 'libraryEntries', id, {
|
||||
recordId: id,
|
||||
|
|
@ -317,7 +308,6 @@ export const libraryEntriesStore = {
|
|||
});
|
||||
await libraryEntryTable.update(id, {
|
||||
unlistedToken: token,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
return token;
|
||||
} catch (e) {
|
||||
|
|
@ -351,7 +341,6 @@ export const libraryEntriesStore = {
|
|||
});
|
||||
await libraryEntryTable.update(id, {
|
||||
unlistedExpiresAt: expiresAt ? expiresAt.toISOString() : undefined,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[library] setUnlistedExpiry failed', e);
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue