diff --git a/apps/web/src/data/models/hangingNotes.ts b/apps/web/src/data/models/hangingNotes.ts new file mode 100644 index 0000000..46981d4 --- /dev/null +++ b/apps/web/src/data/models/hangingNotes.ts @@ -0,0 +1,15 @@ +import type { HangingNote } from './types' + +export function normalizeHangingNotes(value: unknown): HangingNote[] { + if (!Array.isArray(value)) return [] + const result: HangingNote[] = [], ids = new Set() + for (const entry of value) { + if (!entry || typeof entry.id !== 'string' || !entry.id || ids.has(entry.id) || typeof entry.text !== 'string' || !['butter', 'sage', 'rose'].includes(entry.color)) continue + const note: HangingNote = { id: entry.id, text: entry.text.slice(0, 500), color: entry.color } + if (entry.pin && [entry.pin.u, entry.pin.y, entry.pin.tilt].every(Number.isFinite)) { + note.pin = { u: Math.max(0, Math.min(1, entry.pin.u)), y: Math.max(0, entry.pin.y), tilt: Math.max(-.08, Math.min(.08, entry.pin.tilt)) } + } + ids.add(note.id); result.push(note) + } + return result +} diff --git a/apps/web/src/data/models/types.ts b/apps/web/src/data/models/types.ts index 326a0b4..88c5b22 100644 --- a/apps/web/src/data/models/types.ts +++ b/apps/web/src/data/models/types.ts @@ -764,7 +764,16 @@ export type HabitLog = BaseEntity & { status: HabitStatus } +export type HangingNote = { + id: string + text: string + color: 'butter' | 'sage' | 'rose' + /** Horizontal proportion and vertical CSS pixels, independent of view height. */ + pin?: { u: number; y: number; tilt: number } +} + export type SyncedPreferences = BaseEntity & { + hangingNotes?: HangingNote[] id: 'synced_preferences' language: LanguageCode uiAnimationsEnabled: boolean diff --git a/apps/web/src/data/repositories/syncedPreferencesRepo.hangingNotes.test.ts b/apps/web/src/data/repositories/syncedPreferencesRepo.hangingNotes.test.ts new file mode 100644 index 0000000..f9d12a3 --- /dev/null +++ b/apps/web/src/data/repositories/syncedPreferencesRepo.hangingNotes.test.ts @@ -0,0 +1,43 @@ +// @vitest-environment jsdom +import 'fake-indexeddb/auto' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { db } from '../db' +import { syncedPreferencesRepo as repo, SYNCED_PREFERENCES_ID } from './syncedPreferencesRepo' +import { normalizeHangingNotes } from '../models/hangingNotes' +import type { HangingNote } from '../models/types' +import { writeLanguage } from '../../shared/prefs/preferences' +vi.mock('../sync/repository', () => ({ requestSyncHandoff: vi.fn(), enqueueSyncOperationInBackground: vi.fn() })) +const a: HangingNote = { id:'a', text:'A', color:'butter' }, b: HangingNote = { id:'b', text:'B', color:'sage' } +beforeEach(async () => { localStorage.clear(); await db.delete({ disableAutoOpen:false }); await db.open() }) +describe('persisting dashboard notes', () => { + it('preserves notes and preferences during concurrent saves', async () => { + await repo.saveHangingNotes([a], []) + writeLanguage('en') + await Promise.all([repo.persistFromLocal(), repo.saveHangingNotes([a,b],[a]), repo.markInitialSeedCompleted(123)]) + const saved=await repo.get() + expect(saved?.hangingNotes).toEqual([a,b]); expect(saved?.language).toBe('en'); expect(saved?.initialSeedCompletedAt).toBe(123) + }) + it('merges separate-sheet edits from stale tabs without changing order', async () => { + await repo.saveHangingNotes([a,b],[]) + await Promise.all([repo.saveHangingNotes([{...a,text:'new A'},b],[a,b]),repo.saveHangingNotes([a,{...b,pin:{u:.4,y:350,tilt:0}}],[a,b])]) + expect((await repo.get())?.hangingNotes).toEqual([{...a,text:'new A'},{...b,pin:{u:.4,y:350,tilt:0}}]) + }) + it('deletes only the removed ID and preserves remote additions', async () => { + await repo.saveHangingNotes([a,b],[]) + await repo.saveHangingNotes([],[a]) + expect((await repo.get())?.hangingNotes).toEqual([b]) + }) + it('refuses a queued save after the account changes', async () => { + await expect(repo.saveHangingNotes([a],[],()=>false)).rejects.toThrow('Account changed') + expect(await repo.get()).toBeNull() + }) + it('recovers a malformed stored field without dropping preferences', async () => { + await repo.persistFromLocal() + await db.syncedPreferences.update(SYNCED_PREFERENCES_ID, { hangingNotes: { invalid:true } as unknown as HangingNote[] }) + await repo.saveHangingNotes([a],[]) + expect((await repo.get())?.hangingNotes).toEqual([a]) + }) + it('validates restored content and positions', () => { + expect(normalizeHangingNotes([null,a,a,{...b,pin:{u:5,y:-1,tilt:2}},{id:'bad',text:'x',color:'unknown'}])).toEqual([a,{...b,pin:{u:1,y:0,tilt:.08}}]) + }) +}) diff --git a/apps/web/src/data/repositories/syncedPreferencesRepo.ts b/apps/web/src/data/repositories/syncedPreferencesRepo.ts index 74c1a88..0f74844 100644 --- a/apps/web/src/data/repositories/syncedPreferencesRepo.ts +++ b/apps/web/src/data/repositories/syncedPreferencesRepo.ts @@ -1,6 +1,7 @@ import { db } from '../db' -import type { SyncedPreferences } from '../models/types' -import { enqueueSyncOperationInBackground } from '../sync/repository' +import type { HangingNote, SyncedPreferences } from '../models/types' +import { normalizeHangingNotes } from '../models/hangingNotes' +import { enqueueSyncOperationInBackground, requestSyncHandoff } from '../sync/repository' import { readDefaultCurrency, readDiaryFont, @@ -104,24 +105,49 @@ export const syncedPreferencesRepo = { return (await db.syncedPreferences.get(SYNCED_PREFERENCES_ID)) ?? null }, async persistFromLocal() { - const existing = await this.get() - const snapshot = buildLocalSnapshot() - const timestamp = now() - const next: SyncedPreferences = existing - ? { - ...existing, - ...snapshot, - updatedAt: timestamp, - } - : { - ...snapshot, - createdAt: timestamp, - updatedAt: timestamp, - } - await db.syncedPreferences.put(next) + const next = await db.transaction('rw', db.syncedPreferences, async () => { + const existing = await this.get() + const snapshot = buildLocalSnapshot() + const timestamp = now() + const next: SyncedPreferences = existing + ? { + ...existing, + ...snapshot, + updatedAt: timestamp, + } + : { + ...snapshot, + createdAt: timestamp, + updatedAt: timestamp, + } + await db.syncedPreferences.put(next) + return next + }) enqueueSyncOperationInBackground('syncedPreferences', 'upsert', next) return next }, + /** Merge just this edit into the latest record; unrelated preferences/sheets survive. */ + async saveHangingNotes(notes: HangingNote[], previous: HangingNote[], isCurrentAccount = () => true) { + const next = await db.transaction('rw', db.syncedPreferences, async () => { + if (!isCurrentAccount()) throw new Error('Account changed') + const existing = await this.get() + if (!isCurrentAccount()) throw new Error('Account changed') + const before = new Map(previous.map(note => [note.id, note])) + const after = new Map(notes.map(note => [note.id, note])) + const changed = notes.filter(note => JSON.stringify(note) !== JSON.stringify(before.get(note.id))) + const removed = new Set(previous.filter(note => !after.has(note.id)).map(note => note.id)) + const updates = new Map(changed.map(note => [note.id, note])) + const current = normalizeHangingNotes(existing?.hangingNotes) + const currentIds = new Set(current.map(note => note.id)) + const hangingNotes = [...current.filter(note => !removed.has(note.id)).map(note => updates.get(note.id) ?? note), ...changed.filter(note => !currentIds.has(note.id))] + const timestamp = now() + const record: SyncedPreferences = { ...(existing ?? { ...buildLocalSnapshot(), createdAt: timestamp }), hangingNotes, updatedAt: timestamp } + await db.syncedPreferences.put(record) + return record + }) + requestSyncHandoff() + return next + }, async hydrateLocalFromDb() { const stored = await this.get() if (!stored) return false @@ -137,21 +163,24 @@ export const syncedPreferencesRepo = { return stored?.initialSeedCompletedAt ?? null }, async markInitialSeedCompleted(timestamp = now()) { - const existing = await this.get() - if (existing?.initialSeedCompletedAt) return existing - const next: SyncedPreferences = existing - ? { - ...existing, - initialSeedCompletedAt: timestamp, - updatedAt: timestamp, - } - : { - ...buildLocalSnapshot(), - initialSeedCompletedAt: timestamp, - createdAt: timestamp, - updatedAt: timestamp, - } - await db.syncedPreferences.put(next) + const next = await db.transaction('rw', db.syncedPreferences, async () => { + const existing = await this.get() + if (existing?.initialSeedCompletedAt) return existing + const next: SyncedPreferences = existing + ? { + ...existing, + initialSeedCompletedAt: timestamp, + updatedAt: timestamp, + } + : { + ...buildLocalSnapshot(), + initialSeedCompletedAt: timestamp, + createdAt: timestamp, + updatedAt: timestamp, + } + await db.syncedPreferences.put(next) + return next + }) enqueueSyncOperationInBackground('syncedPreferences', 'upsert', next) return next }, diff --git a/apps/web/src/features/dashboard/DashboardHeader.tsx b/apps/web/src/features/dashboard/DashboardHeader.tsx index 550af52..aee32bc 100644 --- a/apps/web/src/features/dashboard/DashboardHeader.tsx +++ b/apps/web/src/features/dashboard/DashboardHeader.tsx @@ -157,7 +157,7 @@ const DashboardHeader = ({
{scene.mode === 'cloudtrain' && } {scene.mode === 'spring' && } -
80 ? { minHeight: activeSettings.height } : undefined}> +
80 ? { minHeight: activeSettings.height } : undefined}>
+