Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions apps/web/src/data/models/hangingNotes.ts
Original file line number Diff line number Diff line change
@@ -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<string>()
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
}
9 changes: 9 additions & 0 deletions apps/web/src/data/models/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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}}])
})
})
93 changes: 61 additions & 32 deletions apps/web/src/data/repositories/syncedPreferencesRepo.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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
},
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/features/dashboard/DashboardHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ const DashboardHeader = ({
<header ref={headerRef} className={`app-shell__header dash-header${scenery ? ' dash-header--scenery' : ''}`} data-header-scene={scene.mode} style={scenery ? { minHeight: activeSettings.sceneHeight } : undefined}>
{scene.mode === 'cloudtrain' && <CloudTrain settings={scene} anchor={scene.sceneAnchor} palette={trainPalette} paused={scene.paused || reducedMotion} />}
{scene.mode === 'spring' && <SpringField settings={scene.spring} palette={springPalette} paused={scene.spring.paused || reducedMotion} />}
<div className="dash-hero" style={scenery && activeSettings.height > 80 ? { minHeight: activeSettings.height } : undefined}>
<div className="dash-hero dash-hero--notes" style={scenery && activeSettings.height > 80 ? { minHeight: activeSettings.height } : undefined}>
<div className="dash-hero__day">
<time className="dash-hero__date" dateTime={dayKey}>
{zh ? (
Expand Down Expand Up @@ -187,6 +187,7 @@ const DashboardHeader = ({
</div>
<TodayNotesLine dayKey={dayKey} className="dash-hero__day-notes" />
</div>
<div className="dash-notes-anchor" aria-hidden="true" />
<figure className={`dash-hero__quote${quote ? '' : ' dash-hero__quote--empty'}`}>
{quote ? (
<blockquote
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/features/dashboard/DashboardPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ vi.mock('../../shared/ui/Dialog', () => ({
default: ({ open, children }: { open: boolean; children: React.ReactNode }) => (open ? <div>{children}</div> : null),
}))

vi.mock('./hanging-notes/HangingNotes', () => ({ default: () => <div data-testid="hanging-notes" /> }))

vi.mock('./DashboardHeader', () => ({
default: ({
onToggleLayoutEdit,
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/features/dashboard/DashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { DashboardLayoutItem } from '../../data/models/types'
import { useSearchParams } from 'react-router-dom'
import DashboardHeader, { type DashboardPage as DashboardPageKind } from './DashboardHeader'
import CustomDashboard from './CustomDashboard'
import HangingNotes from './hanging-notes/HangingNotes'
import { appendViewWidget, readCustomViews, saveCustomViews, uniqueViewName, viewNameError, type CustomView } from './customViews'
import { Input } from '@/components/ui/input'
import { Dialog as ViewDialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog'
Expand Down Expand Up @@ -398,6 +399,8 @@ const DashboardPage = () => {
onCreateView={() => switchSafely(() => { setCreateName(uniqueViewName(zh ? '我的视图' : 'My view', views)); setCreateTemplate('blank'); setCreateError(''); setCreateOpen(true) })}
/>

<HangingNotes />

<div aria-live="polite" aria-atomic="true">
</div>

Expand Down
23 changes: 16 additions & 7 deletions apps/web/src/features/dashboard/desk/DeskProps.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,16 +53,25 @@ const measurePlacement = (header: HTMLElement): Placement | null => {
const toolbar = local('.dash-toolbar')
if (!day || !toolbar) return null
const quote = local('.dash-hero__quote')
const notes = local('.dash-notes-anchor')
const views = local('.dash-toolbar__views')
const actions = local('.dash-toolbar__actions')
// The desk line is the middle of the toolbar's 1px rule.
const height = Math.round(toolbar.bottom - 0.5 + DESK_LINE_INSET)
const from = Math.max(day.right, views?.right ?? toolbar.left) + CLEARANCE
const to = Math.min(quote?.left ?? toolbar.right, actions?.left ?? toolbar.right) - CLEARANCE
const unit = Math.round(Math.min(DESK_UNIT_MAX, height / DESK_LAYOUT.height) * 100) / 100
if (!(unit >= DESK_UNIT_MIN) || to - from < DESK_LAYOUT.minWidth * unit) return null
const left = Math.round(from)
return { left, width: Math.round(to - from), height, unit, origin: Math.round(header.offsetWidth / 2) - left }
let from = Math.max(day.right, views?.right ?? toolbar.left) + CLEARANCE
let to = Math.min(quote?.left ?? toolbar.right, actions?.left ?? toolbar.right) - CLEARANCE
// Leave the hanging paper's column clear; use the larger free side for the desk.
if (notes && notes.right > from && notes.left < to) {
const leftEnd = Math.max(from, notes.left - CLEARANCE)
const rightStart = Math.min(to, notes.right + CLEARANCE)
if (leftEnd - from >= to - rightStart) to = leftEnd
else from = rightStart
}
const unit = Math.floor(Math.min(DESK_UNIT_MAX, height / DESK_LAYOUT.height, (to - from) / DESK_LAYOUT.minWidth) * 100) / 100
if (!(unit >= DESK_UNIT_MIN)) return null
const left = Math.round(from), width = Math.round(to - from)
const origin = notes ? Math.round(width / 2) : Math.round(header.offsetWidth / 2) - left
return { left, width, height, unit, origin }
}

const samePlacement = (a: Placement | null, b: Placement | null) =>
Expand Down Expand Up @@ -90,7 +99,7 @@ const useDeskPlacement = (headerRef: RefObject<HTMLElement | null>) => {
}
const observer = new ResizeObserver(schedule)
observer.observe(header)
for (const selector of ['.dash-hero__day', '.dash-hero__quote', '.dash-toolbar__views', '.dash-toolbar__actions']) {
for (const selector of ['.dash-hero__day', '.dash-hero__quote', '.dash-notes-anchor', '.dash-toolbar__views', '.dash-toolbar__actions']) {
const el = header.querySelector(selector)
if (el) observer.observe(el)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// @vitest-environment jsdom
import { act, cleanup, render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { prepareAppUpdate } from '../../../shared/runtime/updateLifecycle'
import HangingNotes from './HangingNotes'

const mocks = vi.hoisted(() => ({ create: vi.fn(), save: vi.fn(), get: vi.fn() }))
vi.mock('dexie', () => ({ liveQuery: () => ({ subscribe: (observer: { next: (value: unknown) => void }) => {
observer.next({ hangingNotes: [] })
return { unsubscribe: vi.fn() }
} }) }))
vi.mock('../../../store/auth', () => ({ getAuth: () => null, subscribeAuth: () => () => undefined }))
vi.mock('../../../shared/i18n/useI18n', () => ({ useI18n: () => ({ language: 'zh' }) }))
vi.mock('../../../data/repositories/syncedPreferencesRepo', () => ({ syncedPreferencesRepo: { get: mocks.get, saveHangingNotes: mocks.save } }))
vi.mock('./controller', () => ({ createHangingNotes: mocks.create }))

beforeEach(() => {
vi.clearAllMocks()
mocks.create.mockReturnValue({ update: vi.fn(), destroy: vi.fn() })
mocks.get.mockResolvedValue({ hangingNotes: [] })
})
afterEach(cleanup)

describe('hanging note update preparation', () => {
it('waits for a queued note write before the app can update', async () => {
let finish!: () => void
mocks.save.mockReturnValue(new Promise<void>(resolve => { finish = resolve }))
render(<HangingNotes />)
let save!: Promise<unknown>
await act(async () => { save = mocks.create.mock.calls[0][2]([], () => []) })
let prepared = false
const preparation = prepareAppUpdate().then(() => { prepared = true })
await act(async () => { await Promise.resolve() })
expect(prepared).toBe(false)
await act(async () => { finish(); await save; await preparation })
expect(prepared).toBe(true)
})

it('blocks an update after a failed save and removes its listener on unmount', async () => {
mocks.save.mockRejectedValue(new Error('disk unavailable'))
const view = render(<HangingNotes />)
await act(async () => {
await expect(mocks.create.mock.calls[0][2]([], () => [])).rejects.toThrow('disk unavailable')
})
await expect(prepareAppUpdate()).rejects.toThrow('disk unavailable')
view.unmount()
await expect(prepareAppUpdate()).resolves.toBeUndefined()
})
})
49 changes: 49 additions & 0 deletions apps/web/src/features/dashboard/hanging-notes/HangingNotes.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { useEffect, useRef, useSyncExternalStore } from 'react'
import { liveQuery } from 'dexie'
import { getAuth, subscribeAuth } from '../../../store/auth'
import { syncedPreferencesRepo } from '../../../data/repositories/syncedPreferencesRepo'
import { createHangingNotes } from './controller'
import { useI18n } from '../../../shared/i18n/useI18n'
import { onPrepareAppUpdate } from '../../../shared/runtime/updateLifecycle'
import { normalizeHangingNotes } from './model'
import './hanging-notes.css'

const account = () => String(getAuth()?.user?.id ?? 'local')

export default function HangingNotes() {
const { language } = useI18n()
const zh = language === 'zh'
const root = useRef<HTMLDivElement>(null)
const owner = useSyncExternalStore(subscribeAuth, account, () => 'local')
useEffect(() => {
const element = root.current
if (!element) return
let disposed = false, pendingSaves = 0, saveRevision = 0
let engine: ReturnType<typeof createHangingNotes> | undefined
let queue = Promise.resolve<unknown>(undefined)
const stopPreparing = onPrepareAppUpdate(() => queue)
const subscription = liveQuery(() => syncedPreferencesRepo.get()).subscribe({
next: preferences => {
if (disposed) return
const notes = normalizeHangingNotes(preferences?.hangingNotes)
if (engine) { if (!pendingSaves) engine.update(notes); return }
engine = createHangingNotes(element, notes, (next, before) => {
pendingSaves++
const revision = ++saveRevision
const save = queue.catch(() => undefined).then(() => syncedPreferencesRepo.saveHangingNotes(next, before(), () => account() === owner))
queue = save
void save.then(() => {
pendingSaves--
if (!disposed && !pendingSaves) {
void syncedPreferencesRepo.get().then(value => { if (!disposed && !pendingSaves && revision === saveRevision) engine?.update(normalizeHangingNotes(value?.hangingNotes)) }).catch(() => undefined)
}
}, () => { pendingSaves-- })
return save
}, zh)
},
error: () => { if (!disposed) { engine?.destroy(); engine = undefined; element.textContent = zh ? '便签暂时无法读取,请重新打开仪表盘。' : 'Notes could not load. Please reopen Dashboard.' } },
})
return () => { disposed = true; subscription.unsubscribe(); engine?.destroy(); stopPreparing() }
}, [owner, zh])
return <div ref={root} className="dashboard-notes" aria-label={zh ? '风中便签' : 'Hanging notes'} />
}
Loading
Loading