From e21d841a14bb78274c1c6d618a29c60de255eb2c Mon Sep 17 00:00:00 2001 From: VelvetAbyss Date: Sat, 31 Oct 2026 00:12:14 +0800 Subject: [PATCH] feat(dashboard): add hanging reminder notes --- apps/web/src/data/models/hangingNotes.ts | 15 + apps/web/src/data/models/types.ts | 9 + ...syncedPreferencesRepo.hangingNotes.test.ts | 43 + .../repositories/syncedPreferencesRepo.ts | 93 +- .../features/dashboard/DashboardHeader.tsx | 3 +- .../features/dashboard/DashboardPage.test.tsx | 2 + .../src/features/dashboard/DashboardPage.tsx | 3 + .../src/features/dashboard/desk/DeskProps.tsx | 23 +- .../hanging-notes/HangingNotes.test.tsx | 49 + .../dashboard/hanging-notes/HangingNotes.tsx | 49 + .../hanging-notes/controller.test.ts | 105 ++ .../dashboard/hanging-notes/controller.ts | 1008 +++++++++++++++++ .../features/dashboard/hanging-notes/copy.ts | 50 + .../dashboard/hanging-notes/hanging-notes.css | 157 +++ .../features/dashboard/hanging-notes/model.ts | 1 + .../dashboard/hanging-notes/template.ts | 54 + 16 files changed, 1624 insertions(+), 40 deletions(-) create mode 100644 apps/web/src/data/models/hangingNotes.ts create mode 100644 apps/web/src/data/repositories/syncedPreferencesRepo.hangingNotes.test.ts create mode 100644 apps/web/src/features/dashboard/hanging-notes/HangingNotes.test.tsx create mode 100644 apps/web/src/features/dashboard/hanging-notes/HangingNotes.tsx create mode 100644 apps/web/src/features/dashboard/hanging-notes/controller.test.ts create mode 100644 apps/web/src/features/dashboard/hanging-notes/controller.ts create mode 100644 apps/web/src/features/dashboard/hanging-notes/copy.ts create mode 100644 apps/web/src/features/dashboard/hanging-notes/hanging-notes.css create mode 100644 apps/web/src/features/dashboard/hanging-notes/model.ts create mode 100644 apps/web/src/features/dashboard/hanging-notes/template.ts 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}>
+