feat: add local pomodoro focus timer
ci / gitleaks (push) Successful in 7s
ci / docker (push) Successful in 3m34s

Frontend-only 25m focus / 5m break timer with pause-resume-reset, deadline-based absolute timing, versioned localStorage persistence, cross-tab conflict protection, midnight-completion attribution, accessible stage controls, and reduced-motion support. Adds 专注 entry to desktop sidebar; mobile bottom nav unchanged; no notifications, task association, history, or cross-device sync. Also fixes mobile E2E task isolation and the stale calendar 390px overflow assertion.
This commit is contained in:
2026-09-22 09:10:05 +08:00
parent 12e4753aeb
commit a535f69cae
14 changed files with 513 additions and 25 deletions
-2
View File
@@ -223,7 +223,6 @@ test('calendar week focus keeps seven usable day controls without page overflow'
mainOverflow: main.scrollWidth - main.clientWidth,
calendarOverflow: calendar.scrollWidth - calendar.clientWidth,
filters: { clientWidth: filters.clientWidth, scrollWidth: filters.scrollWidth, overflowX: getComputedStyle(filters).overflowX },
stripOverflow: element.scrollWidth > element.clientWidth,
buttons: [...element.querySelectorAll<HTMLElement>('.calendar-week-day')].map(button => ({ width: button.getBoundingClientRect().width, height: button.getBoundingClientRect().height })),
}
})
@@ -240,7 +239,6 @@ test('calendar week focus keeps seven usable day controls without page overflow'
return { background: style.backgroundColor, color: style.color }
})
expect(selectedColors).toEqual({ background: 'rgb(241, 90, 41)', color: 'rgb(255, 255, 255)' })
if ((await page.viewportSize())!.width <= 390) expect(metrics.stripOverflow).toBeTruthy()
})
test('settings match the approved paper-ledger geometry and action hierarchy', async ({ page }) => {
+17 -9
View File
@@ -14,26 +14,34 @@ async function createTask(request: APIRequestContext, baseURL: string, listId: s
expect(response.ok(), await response.text()).toBeTruthy()
}
async function openInbox(page: Page) {
const inbox = page.locator('.sidebar').getByRole('button', { name: '收集箱', exact: true })
async function createList(request: APIRequestContext, baseURL: string, name: string) {
const response = await request.post('/api/v1/lists', {
data: { name },
headers: { 'x-csrf-token': await csrf(request), origin: baseURL },
})
expect(response.ok(), await response.text()).toBeTruthy()
return response.json() as Promise<{ id: string }>
}
async function openList(page: Page, name: string) {
const list = page.locator('.sidebar').getByRole('button', { name, exact: true })
if (await page.evaluate(() => window.innerWidth <= 930)) {
await page.locator('main .topbar > button').first().click()
}
await inbox.click()
await list.click()
}
test('task pagination stays below the list and returns to the list start after navigation', async ({ page, request, baseURL }) => {
const bootstrapResponse = await request.get('/api/v1/bootstrap')
expect(bootstrapResponse.ok()).toBeTruthy()
const inbox = (await bootstrapResponse.json()).lists.find((item: { is_inbox: boolean }) => item.is_inbox)
expect(inbox).toBeTruthy()
const nonce = crypto.randomUUID()
const listName = `分页验收清单 ${nonce}`
const list = await createList(request, baseURL!, listName)
for (let index = 1; index <= 51; index += 1) {
await createTask(request, baseURL!, inbox.id, `分页验收任务 ${String(index).padStart(2, '0')}`)
await createTask(request, baseURL!, list.id, `分页验收任务 ${nonce} ${String(index).padStart(2, '0')}`)
}
await page.goto('/')
await openInbox(page)
await openList(page, listName)
const taskList = page.locator('.task-list')
const pager = page.locator('.pager')
+15 -8
View File
@@ -14,27 +14,34 @@ async function createTask(request: APIRequestContext, baseURL: string, title: st
expect(response.ok(), await response.text()).toBeTruthy()
}
async function openInbox(page: Page) {
async function createList(request: APIRequestContext, baseURL: string, name: string) {
const response = await request.post('/api/v1/lists', {
data: { name },
headers: { 'x-csrf-token': await csrf(request), origin: baseURL },
})
expect(response.ok(), await response.text()).toBeTruthy()
return response.json() as Promise<{ id: string }>
}
async function openList(page: Page, name: string) {
if ((await page.viewportSize())!.width <= 930) {
await page.locator('.topbar').getByRole('button', { name: /展开菜单|收起菜单/ }).click()
}
await page.locator('.sidebar').getByRole('button', { name: '收集箱', exact: true }).click()
await page.locator('.sidebar').getByRole('button', { name, exact: true }).click()
}
test('inbox and task composer match approved visuals', async ({ page, request, baseURL }, testInfo) => {
test.skip(testInfo.project.name !== 'mobile-390', 'approved visual baselines are maintained for mobile-390 only')
await page.clock.install({ time: new Date('2026-09-19T05:00:00.000Z') })
const bootstrap = await request.get('/api/v1/bootstrap')
expect(bootstrap.ok()).toBeTruthy()
const inbox = (await bootstrap.json()).lists.find((item: { is_inbox: boolean }) => item.is_inbox)
expect(inbox).toBeTruthy()
const listName = '视觉回归清单'
const list = await createList(request, baseURL!, listName)
const seededTitles = ['整理本周工作记录', '检查 Dodo 备份', '周末买水果和牛奶'] as const
for (const title of seededTitles) await createTask(request, baseURL!, title, inbox.id)
for (const title of seededTitles) await createTask(request, baseURL!, title, list.id)
await page.goto('/')
await openInbox(page)
await openList(page, listName)
const seededRows = page.locator('.task-row').filter({ hasText: /整理本周工作记录|检查 Dodo 备份|周末买水果和牛奶/ })
await expect(seededRows).toHaveCount(3)
await expect(page).toHaveScreenshot('inbox.png', { fullPage: true })
Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 43 KiB

+7 -3
View File
@@ -3,7 +3,7 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import {
ArchiveRestore, Bold, CalendarDays, CalendarHeart, Check, ChevronDown, ChevronLeft, ChevronRight, Code, Folder,
Ellipsis, GripVertical, Heading2, Inbox, Italic, Link, List, ListChecks, ListOrdered, ListTodo, Menu, Pencil, Plus, Quote,
Settings, Trash2, X, Repeat2, StickyNote,
Settings, Trash2, X, Repeat2, StickyNote, TimerReset,
} from 'lucide-vue-next'
import { applyMarkdownFormat, buildTaskDueDraft, buildTaskRecurrencePayload, defaultTaskDueAt, fromDateTimeLocal, groupTaskTree, groupTrashTaskTree, isSameTaskSortTier, moveItemWithinScope, parseTaskDueDraft, parseTaskRecurrence, parseTaskRrule, renderMarkdown, type AfterCompletionUnit, type MarkdownFormat, type TaskRepeatConfig, type TaskRepeatOption } from './lib/task-utils'
import { beginLatestRequest, createMutationReconciler, createTaskCompletionExitCoordinator, createTaskToggleCoordinator, formatApiErrorDetail, isLatestRequest, isTaskView, loadCountdownCache, mergeTaskToggleResponse, normalizeRequiredName, readStoredBoolean, readStoredNavigation, reconcileCurrentTaskView, runLatestRequest, shouldToggleRowSwipe, startPrimaryWithBackground, taskVersionedPatchPayload, writeStoredBoolean, writeStoredNavigation } from './lib/mvp-utils'
@@ -17,6 +17,7 @@ import MvpPanel from './MvpPanel.vue'
import CountdownPanel from './CountdownPanel.vue'
import MemoPanel from './MemoPanel.vue'
import CalendarPanel from './CalendarPanel.vue'
import PomodoroPanel from './PomodoroPanel.vue'
import FloatingAddButton from './components/FloatingAddButton.vue'
import CompletedFilterPill from './components/CompletedFilterPill.vue'
import CalendarPicker from './components/CalendarPicker.vue'
@@ -33,7 +34,7 @@ type TaskList = { id: string; folder_id: string | null; name: string; is_inbox:
type Task = { id: string; list_id: string; parent_id: string | null; title: string; description: string; priority: number; completed: boolean; completed_at: string | null; version: number; due_at: string | null; due_has_time: boolean; subtasks?: Task[] }
type RepeatOption = TaskRepeatOption
type Recurrence = { id: string; task_id: string; rrule: string | null; trigger_mode: 'scheduled' | 'after_completion'; after_completion_days: number | null; after_completion_unit: AfterCompletionUnit | null }
type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'countdowns' | 'memos' | 'calendar' | 'settings'
type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'focus' | 'countdowns' | 'memos' | 'calendar' | 'settings'
const initialized = ref<boolean | null>(null)
const authReady = ref(false)
@@ -403,6 +404,7 @@ const activeName = computed(() => {
if (activeView.value === 'today') return '今天'
if (activeView.value === 'upcoming') return '最近 7 天'
if (activeView.value === 'habits') return '习惯'
if (activeView.value === 'focus') return '专注'
if (activeView.value === 'countdowns') return '倒数日'
if (activeView.value === 'memos') return '备忘录'
if (activeView.value === 'calendar') return '日历订阅'
@@ -426,7 +428,7 @@ const sourceTasks = computed(() => activeView.value === 'trash' ? trash.value :
const visibleTasks = computed(() => {
const now = new Date()
let result = sourceTasks.value
if (['habits','settings','countdowns','memos','calendar'].includes(activeView.value)) return []
if (['habits','settings','focus','countdowns','memos','calendar'].includes(activeView.value)) return []
if (activeView.value === 'today') result = result.filter((task) => {
const dueToday = task.due_at && new Date(task.due_at).toDateString() === now.toDateString()
const completedToday = task.completed_at && new Date(task.completed_at).toDateString() === now.toDateString()
@@ -1735,6 +1737,7 @@ onUnmounted(() => {
<button :class="{ active: activeView==='tasks' && lists.find(l=>l.id===activeList)?.is_inbox }" @click="switchView('tasks', lists.find(l=>l.is_inbox)?.id)"><Inbox />收集箱</button>
<button :class="{ active: activeView==='upcoming' }" @click="switchView('upcoming')"><CalendarDays />最近 7 </button>
<button :class="{ active: activeView==='habits' }" @click="switchView('habits')"><Repeat2 />习惯</button>
<button :class="{ active: activeView==='focus' }" @click="switchView('focus')"><TimerReset />专注</button>
<button :class="{ active: activeView==='countdowns' }" @click="switchView('countdowns')"><CalendarHeart />倒数日</button>
<button :class="{ active: activeView==='memos' }" @click="switchView('memos')"><StickyNote />备忘录</button>
<button :class="{ active: activeView==='calendar' }" @click="switchView('calendar')"><CalendarDays />日历订阅</button>
@@ -1801,6 +1804,7 @@ onUnmounted(() => {
</template>
<MemoPanel v-else-if="activeView==='memos'" ref="memoPanel" :request="api" @scope="memoTrash=$event==='trash'" @detail="memoDetailOpen=$event" @notice="toast" />
<CalendarPanel v-else-if="activeView==='calendar'" :compact-layout="compactLayout" @detail="calendarDetailOpen=$event" @notice="toast" />
<PomodoroPanel v-else-if="activeView==='focus'" />
<CountdownPanel ref="countdownComposer" v-else-if="activeView==='countdowns'" @notice="toast" />
<template v-else>
<section v-if="activeView==='today'" class="today-context" aria-label="今日概览">
+33
View File
@@ -0,0 +1,33 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
describe('pomodoro integration', () => {
const app = readFileSync('src/App.vue', 'utf8')
const css = readFileSync('src/style.css', 'utf8')
const panel = readFileSync('src/PomodoroPanel.vue', 'utf8')
it('adds focus to desktop navigation and keeps the five-item mobile navigation unchanged', () => {
const primary = app.slice(app.indexOf('<nav class="primary-nav">'), app.indexOf('</nav>', app.indexOf('<nav class="primary-nav">')))
const bottom = app.slice(app.indexOf('<nav class="bottom"'), app.indexOf('</nav>', app.indexOf('<nav class="bottom"')))
expect(primary).toContain("switchView('focus')")
expect(primary.indexOf("switchView('focus')")).toBeGreaterThan(primary.indexOf("switchView('habits')"))
expect(bottom).not.toContain("switchView('focus')")
expect(bottom.match(/aria-current=/g)).toHaveLength(5)
})
it('renders the focus panel without the shared FAB and includes accessible stage controls', () => {
expect(app).toContain("<PomodoroPanel v-else-if=\"activeView==='focus'\"")
expect(app).not.toContain("'focus'].includes(activeView)")
expect(css).toContain('.pomodoro-controls button{min-height:44px')
expect(css).toContain('@media(prefers-reduced-motion:reduce)')
})
it('coordinates writes across tabs with unique writers and a localStorage conflict check', () => {
expect(panel).toContain('const WRITER_ID = crypto.randomUUID()')
expect(panel).toContain('const stored = loadPomodoroState(window.localStorage, STORAGE_KEY)')
expect(panel).toContain('shouldAcceptPomodoroState(stored, next) ? stored : next')
expect(panel).toContain('Date.now(), WRITER_ID')
expect(panel).toContain('savePomodoroState(window.localStorage, STORAGE_KEY, state.value)')
})
})
+127
View File
@@ -0,0 +1,127 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { Coffee, Pause, Play, RotateCcw, SkipForward, TimerReset } from 'lucide-vue-next'
import {
formatPomodoroTime,
loadPomodoroState,
pausePomodoro,
recalibratePomodoro,
resetPomodoro,
savePomodoroState,
shouldAcceptPomodoroState,
skipBreak,
startBreak,
startNextFocus,
startPomodoro,
type PomodoroState,
} from './lib/pomodoro-timer'
const STORAGE_KEY = 'dodo.pomodoro.v1'
const WRITER_ID = crypto.randomUUID()
const state = ref(loadPomodoroState(window.localStorage, STORAGE_KEY))
const nowMs = ref(Date.now())
const announcement = ref('')
let intervalId: number | undefined
const remainingMs = computed(() => state.value.status === 'running' && state.value.endsAt !== null
? Math.max(0, state.value.endsAt - nowMs.value)
: state.value.remainingMs)
const clock = computed(() => formatPomodoroTime(remainingMs.value))
const phaseLabel = computed(() => state.value.phase === 'focus' ? '专注' : '休息')
const statusLabel = computed(() => ({ idle: '准备开始', running: '进行中', paused: '已暂停', done: '已完成' })[state.value.status])
const progress = computed(() => {
const total = (state.value.phase === 'focus' ? state.value.focusMinutes : state.value.breakMinutes) * 60_000
return total ? Math.min(100, Math.max(0, ((total - remainingMs.value) / total) * 100)) : 0
})
function commit(next: PomodoroState, message = '') {
const stored = loadPomodoroState(window.localStorage, STORAGE_KEY)
const resolved = shouldAcceptPomodoroState(stored, next) ? stored : next
const changed = resolved !== state.value
state.value = resolved
nowMs.value = Date.now()
if (changed) savePomodoroState(window.localStorage, STORAGE_KEY, resolved)
if (message) announcement.value = message
}
function start() {
const next = startPomodoro(state.value, Date.now(), WRITER_ID)
commit(next, `${phaseLabel.value}已开始`)
}
function pause() { commit(pausePomodoro(state.value, Date.now(), WRITER_ID), `${phaseLabel.value}已暂停`) }
function reset() { commit(resetPomodoro(state.value, Date.now(), WRITER_ID), `${phaseLabel.value}已重置`) }
function beginBreak() { commit(startBreak(state.value, Date.now(), WRITER_ID), '专注完成,休息已准备好') }
function skip() { commit(skipBreak(state.value, Date.now(), WRITER_ID), '已跳过休息,下一轮专注已准备好') }
function nextFocus() { commit(startNextFocus(state.value, Date.now(), WRITER_ID), '休息完成,下一轮专注已准备好') }
function refresh() {
if (document.visibilityState === 'hidden') return
const before = state.value
const next = recalibratePomodoro(before, Date.now(), WRITER_ID)
nowMs.value = Date.now()
if (next !== before) {
const completed = before.status === 'running' && next.status === 'done'
commit(next, completed ? `${phaseLabel.value}完成` : '')
}
}
function handleStorage(event: StorageEvent) {
if (event.key !== STORAGE_KEY || event.newValue === null) return
const incoming = loadPomodoroState({ getItem: () => event.newValue, setItem: () => undefined }, STORAGE_KEY)
if (shouldAcceptPomodoroState(incoming, state.value)) {
state.value = incoming
refresh()
} else if (shouldAcceptPomodoroState(state.value, incoming)) {
savePomodoroState(window.localStorage, STORAGE_KEY, state.value)
}
}
onMounted(() => {
refresh()
intervalId = window.setInterval(refresh, 250)
document.addEventListener('visibilitychange', refresh)
window.addEventListener('focus', refresh)
window.addEventListener('pageshow', refresh)
window.addEventListener('storage', handleStorage)
})
onUnmounted(() => {
if (intervalId) window.clearInterval(intervalId)
document.removeEventListener('visibilitychange', refresh)
window.removeEventListener('focus', refresh)
window.removeEventListener('pageshow', refresh)
window.removeEventListener('storage', handleStorage)
})
</script>
<template>
<section class="pomodoro-view" aria-labelledby="pomodoro-title">
<header class="pomodoro-heading">
<div><span class="pomodoro-eyebrow">今天完成 {{ state.todayFocusCount }} </span><h1 id="pomodoro-title">专注</h1></div>
<TimerReset aria-hidden="true" />
</header>
<div class="pomodoro-stage" :class="`pomodoro-stage--${state.phase}`">
<div class="pomodoro-stage-meta"><span>{{ phaseLabel }}</span><small>{{ statusLabel }}</small></div>
<output class="pomodoro-clock" :aria-label="`${phaseLabel}剩余 ${clock}`">{{ clock }}</output>
<div class="pomodoro-progress" role="progressbar" :aria-label="`${phaseLabel}进度`" aria-valuemin="0" aria-valuemax="100" :aria-valuenow="Math.round(progress)"><span :style="{ width: `${progress}%` }" /></div>
<p class="pomodoro-note">{{ state.phase === 'focus' ? '留一点完整的时间,只做眼前这一件事。' : '离开屏幕,喝口水,再回来。' }}</p>
<div class="pomodoro-controls">
<button v-if="state.status === 'idle'" class="primary" type="button" @click="start"><Play />开始{{ phaseLabel }}</button>
<button v-else-if="state.status === 'running'" class="primary" type="button" @click="pause"><Pause />暂停</button>
<button v-else-if="state.status === 'paused'" class="primary" type="button" @click="start"><Play />继续</button>
<button v-if="state.status === 'running' || state.status === 'paused'" class="soft-button" type="button" @click="reset"><RotateCcw />重置</button>
<template v-if="state.phase === 'focus' && state.status === 'done'">
<button class="primary" type="button" @click="beginBreak"><Coffee />开始休息</button>
<button class="soft-button" type="button" @click="nextFocus"><SkipForward />跳过休息</button>
</template>
<template v-if="state.phase === 'break'">
<button v-if="state.status !== 'done'" class="soft-button" type="button" @click="skip"><SkipForward />跳过休息</button>
<button v-else class="primary" type="button" @click="nextFocus"><Play />下一轮专注</button>
</template>
</div>
</div>
<p class="pomodoro-summary">专注 {{ state.focusMinutes }} 分钟 · 休息 {{ state.breakMinutes }} 分钟</p>
<p class="sr-only" aria-live="polite">{{ announcement }}</p>
</section>
</template>
+2 -2
View File
@@ -1,7 +1,7 @@
type BooleanStorage = Pick<Storage, 'getItem' | 'setItem'>
type NavigationView = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'countdowns' | 'memos' | 'calendar' | 'settings'
type NavigationView = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'focus' | 'countdowns' | 'memos' | 'calendar' | 'settings'
type StoredNavigation = { view: NavigationView; listId: string }
const NAVIGATION_VIEWS = new Set<NavigationView>(['tasks', 'today', 'upcoming', 'trash', 'habits', 'countdowns', 'memos', 'calendar', 'settings'])
const NAVIGATION_VIEWS = new Set<NavigationView>(['tasks', 'today', 'upcoming', 'trash', 'habits', 'focus', 'countdowns', 'memos', 'calendar', 'settings'])
export function readStoredNavigation(storage: BooleanStorage, key: string): StoredNavigation {
try {
+144
View File
@@ -0,0 +1,144 @@
import { describe, expect, it } from 'vitest'
import { createPomodoroState, finishPomodoro, formatPomodoroTime, loadPomodoroState, pausePomodoro, recalibratePomodoro, resetPomodoro, savePomodoroState, shouldAcceptPomodoroState, skipBreak, startBreak, startNextFocus, startPomodoro } from './pomodoro-timer'
describe('pomodoro timer state', () => {
it('starts a 25 minute focus from idle using an absolute deadline', () => {
const state = createPomodoroState(new Date('2026-09-22T08:00:00'))
const started = startPomodoro(state, Date.parse('2026-09-22T08:00:00'))
expect(started).toMatchObject({
phase: 'focus',
status: 'running',
remainingMs: 25 * 60_000,
endsAt: Date.parse('2026-09-22T08:25:00'),
todayFocusCount: 0,
todayKey: '2026-09-22',
})
})
it('pauses and resumes from the deadline-derived remaining time', () => {
const initial = createPomodoroState(new Date('2026-09-22T08:00:00'))
const running = startPomodoro(initial, Date.parse('2026-09-22T08:00:00'))
const paused = pausePomodoro(running, Date.parse('2026-09-22T08:10:00'))
const resumed = startPomodoro(paused, Date.parse('2026-09-22T09:00:00'))
expect(paused).toMatchObject({ status: 'paused', remainingMs: 15 * 60_000, endsAt: null })
expect(resumed).toMatchObject({ status: 'running', remainingMs: 15 * 60_000, endsAt: Date.parse('2026-09-22T09:15:00') })
})
it('completes focus naturally once, then waits for a manual break', () => {
const initial = createPomodoroState(new Date('2026-09-22T08:00:00'))
const running = startPomodoro(initial, Date.parse('2026-09-22T08:00:00'))
const done = finishPomodoro(running, Date.parse('2026-09-22T08:25:00'))
const repeated = finishPomodoro(done, Date.parse('2026-09-22T08:26:00'))
expect(done).toMatchObject({ phase: 'focus', status: 'done', remainingMs: 0, endsAt: null, todayFocusCount: 1 })
expect(repeated.todayFocusCount).toBe(1)
expect(startBreak(done, Date.parse('2026-09-22T08:27:00'))).toMatchObject({ phase: 'break', status: 'running', remainingMs: 5 * 60_000, endsAt: Date.parse('2026-09-22T08:32:00') })
})
it('moves from a finished break to the next focus only on request', () => {
const focusDone = finishPomodoro(startPomodoro(createPomodoroState(new Date('2026-09-22T08:00:00')), Date.parse('2026-09-22T08:00:00')), Date.parse('2026-09-22T08:25:00'))
const breakRunning = startPomodoro(startBreak(focusDone, Date.parse('2026-09-22T08:26:00')), Date.parse('2026-09-22T08:26:00'))
const breakDone = finishPomodoro(breakRunning, Date.parse('2026-09-22T08:31:00'))
expect(breakDone).toMatchObject({ phase: 'break', status: 'done', todayFocusCount: 1 })
expect(startNextFocus(breakDone, Date.parse('2026-09-22T08:32:00'))).toMatchObject({ phase: 'focus', status: 'running', remainingMs: 25 * 60_000, endsAt: Date.parse('2026-09-22T08:57:00'), todayFocusCount: 1 })
})
it('does not increment completed focus count when resetting or skipping', () => {
const initial = createPomodoroState(new Date('2026-09-22T08:00:00'))
const reset = resetPomodoro(startPomodoro(initial, Date.parse('2026-09-22T08:00:00')), Date.parse('2026-09-22T08:10:00'))
const focusDone = finishPomodoro(startPomodoro(initial, Date.parse('2026-09-22T08:00:00')), Date.parse('2026-09-22T08:25:00'))
const skipped = skipBreak(startBreak(focusDone, Date.parse('2026-09-22T08:26:00')), Date.parse('2026-09-22T08:27:00'))
expect(reset).toMatchObject({ phase: 'focus', status: 'idle', todayFocusCount: 0 })
expect(skipped).toMatchObject({ phase: 'focus', status: 'idle', todayFocusCount: 1 })
})
it('loads only valid versioned state and safely resets corrupted or old data', () => {
const now = new Date('2026-09-22T08:00:00')
const storage = {
value: '{bad json',
getItem() { return this.value },
setItem(_key: string, value: string) { this.value = value },
}
expect(loadPomodoroState(storage, 'timer', now)).toEqual(createPomodoroState(now))
storage.value = JSON.stringify({ ...createPomodoroState(now), version: 0 })
expect(loadPomodoroState(storage, 'timer', now)).toEqual(createPomodoroState(now))
const valid = { ...createPomodoroState(now), status: 'paused' as const, remainingMs: 1234, revision: 4 }
savePomodoroState(storage, 'timer', valid)
expect(loadPomodoroState(storage, 'timer', now)).toEqual(valid)
})
it('resets the daily count across a local date while an active run continues', () => {
const initial = createPomodoroState(new Date('2026-09-22T23:50:00'))
const running = { ...startPomodoro(initial, Date.parse('2026-09-22T23:50:00')), todayFocusCount: 3 }
const recalibrated = recalibratePomodoro(running, Date.parse('2026-09-23T00:01:00'))
expect(recalibrated).toMatchObject({ status: 'running', endsAt: running.endsAt, todayKey: '2026-09-23', todayFocusCount: 0 })
expect(recalibrated.remainingMs).toBe(14 * 60_000)
})
it('attributes an elapsed focus to its deadline date before rolling the visible count to the observation date', () => {
const initial = { ...createPomodoroState(new Date('2026-09-22T23:00:00')), todayFocusCount: 3 }
const running = startPomodoro(initial, Date.parse('2026-09-22T23:30:00'))
const done = recalibratePomodoro(running, Date.parse('2026-09-23T00:10:00'))
expect(done).toMatchObject({
status: 'done',
todayKey: '2026-09-23',
todayFocusCount: 0,
lastFocusCompletedAt: Date.parse('2026-09-22T23:55:00'),
})
})
it('starts a new completion-date count when persisted count belongs to neither completion nor observation date', () => {
const stale = { ...createPomodoroState(new Date('2026-09-20T08:00:00')), todayFocusCount: 7 }
const running = startPomodoro(stale, Date.parse('2026-09-22T08:00:00'))
const done = finishPomodoro(running, Date.parse('2026-09-22T08:25:00'))
expect(done).toMatchObject({ todayKey: '2026-09-22', todayFocusCount: 1, lastFocusCompletedAt: Date.parse('2026-09-22T08:25:00') })
})
it('keeps a natural completion when a stale tab resets later from the same revision', () => {
const base = startPomodoro(createPomodoroState(new Date('2026-09-22T08:00:00'), 'origin'), Date.parse('2026-09-22T08:00:00'), 'origin')
const completed = finishPomodoro(base, Date.parse('2026-09-22T08:25:00'), 'completion-tab')
const staleReset = resetPomodoro(base, Date.parse('2026-09-22T08:26:00'), 'stale-tab')
expect(shouldAcceptPomodoroState(staleReset, completed)).toBe(false)
expect(completed).toMatchObject({ status: 'done', todayFocusCount: 1 })
})
it('uses writer identity as a deterministic tie-break for concurrent mutations with identical clocks', () => {
const base = createPomodoroState(new Date('2026-09-22T08:00:00'), 'origin')
const lowerWriter = startPomodoro(base, Date.parse('2026-09-22T08:00:00'), 'tab-a')
const higherWriter = startPomodoro(base, Date.parse('2026-09-22T08:00:00'), 'tab-b')
expect(shouldAcceptPomodoroState(higherWriter, lowerWriter)).toBe(true)
expect(shouldAcceptPomodoroState(lowerWriter, higherWriter)).toBe(false)
})
it('finishes an elapsed run during recalibration and compares synced revisions monotonically', () => {
const running = startPomodoro(createPomodoroState(new Date('2026-09-22T08:00:00')), Date.parse('2026-09-22T08:00:00'))
const done = recalibratePomodoro(running, Date.parse('2026-09-22T08:26:00'))
expect(done).toMatchObject({ status: 'done', todayFocusCount: 1 })
expect(shouldAcceptPomodoroState({ ...done, revision: done.revision + 1 }, done)).toBe(true)
expect(shouldAcceptPomodoroState({ ...done, updatedAt: done.updatedAt + 1 }, done)).toBe(true)
expect(shouldAcceptPomodoroState(done, done)).toBe(false)
})
it('formats the visible clock with a ceiling so it never shows early completion', () => {
expect(formatPomodoroTime(25 * 60_000)).toBe('25:00')
expect(formatPomodoroTime(60_001)).toBe('01:01')
expect(formatPomodoroTime(0)).toBe('00:00')
})
})
+161
View File
@@ -0,0 +1,161 @@
export const POMODORO_VERSION = 1
export const DEFAULT_FOCUS_MS = 25 * 60_000
export const DEFAULT_BREAK_MS = 5 * 60_000
export type PomodoroPhase = 'focus' | 'break'
export type PomodoroStatus = 'idle' | 'running' | 'paused' | 'done'
export type PomodoroState = {
version: typeof POMODORO_VERSION
phase: PomodoroPhase
status: PomodoroStatus
focusMinutes: number
breakMinutes: number
remainingMs: number
endsAt: number | null
todayKey: string
todayFocusCount: number
lastFocusCompletedAt: number | null
revision: number
updatedAt: number
writerId: string
}
export function localDateKey(date = new Date()) {
const year = date.getFullYear()
const month = `${date.getMonth() + 1}`.padStart(2, '0')
const day = `${date.getDate()}`.padStart(2, '0')
return `${year}-${month}-${day}`
}
export function createPomodoroState(now = new Date(), writerId = 'local'): PomodoroState {
return {
version: POMODORO_VERSION,
phase: 'focus',
status: 'idle',
focusMinutes: 25,
breakMinutes: 5,
remainingMs: DEFAULT_FOCUS_MS,
endsAt: null,
todayKey: localDateKey(now),
todayFocusCount: 0,
lastFocusCompletedAt: null,
revision: 0,
updatedAt: now.getTime(),
writerId,
}
}
function changed(state: PomodoroState, now: number, patch: Partial<PomodoroState>, writerId: string): PomodoroState {
return { ...state, ...patch, revision: state.revision + 1, updatedAt: now, writerId }
}
export function startPomodoro(state: PomodoroState, now = Date.now(), writerId = state.writerId): PomodoroState {
const duration = state.phase === 'focus' ? state.focusMinutes * 60_000 : state.breakMinutes * 60_000
const remainingMs = state.status === 'paused' ? state.remainingMs : duration
return changed(state, now, { status: 'running', remainingMs, endsAt: now + remainingMs }, writerId)
}
export function pausePomodoro(state: PomodoroState, now = Date.now(), writerId = state.writerId): PomodoroState {
if (state.status !== 'running' || state.endsAt === null) return state
return changed(state, now, { status: 'paused', remainingMs: Math.max(0, state.endsAt - now), endsAt: null }, writerId)
}
export function finishPomodoro(state: PomodoroState, now = Date.now(), writerId = state.writerId): PomodoroState {
if (state.status !== 'running' || state.endsAt === null || now < state.endsAt) return state
const completedAt = state.endsAt
const completionKey = localDateKey(new Date(completedAt))
const completedFocus = state.phase === 'focus'
return changed(state, now, {
status: 'done',
remainingMs: 0,
endsAt: null,
todayKey: completedFocus ? completionKey : state.todayKey,
todayFocusCount: completedFocus ? (state.todayKey === completionKey ? state.todayFocusCount : 0) + 1 : state.todayFocusCount,
lastFocusCompletedAt: completedFocus ? completedAt : state.lastFocusCompletedAt,
}, writerId)
}
function moveToPhase(state: PomodoroState, phase: PomodoroPhase, now: number, writerId: string) {
return changed(state, now, {
phase,
status: 'idle',
remainingMs: (phase === 'focus' ? state.focusMinutes : state.breakMinutes) * 60_000,
endsAt: null,
}, writerId)
}
export function startBreak(state: PomodoroState, now = Date.now(), writerId = state.writerId) {
return startPomodoro(moveToPhase(state, 'break', now, writerId), now, writerId)
}
export function startNextFocus(state: PomodoroState, now = Date.now(), writerId = state.writerId) {
return startPomodoro(moveToPhase(state, 'focus', now, writerId), now, writerId)
}
export function skipBreak(state: PomodoroState, now = Date.now(), writerId = state.writerId) {
return moveToPhase(state, 'focus', now, writerId)
}
export function resetPomodoro(state: PomodoroState, now = Date.now(), writerId = state.writerId) {
return moveToPhase(state, state.phase, now, writerId)
}
type PomodoroStorage = Pick<Storage, 'getItem' | 'setItem'>
function isPomodoroState(value: unknown): value is PomodoroState {
if (!value || typeof value !== 'object') return false
const state = value as Record<string, unknown>
return state.version === POMODORO_VERSION
&& (state.phase === 'focus' || state.phase === 'break')
&& ['idle', 'running', 'paused', 'done'].includes(String(state.status))
&& Number.isFinite(state.focusMinutes) && Number(state.focusMinutes) > 0
&& Number.isFinite(state.breakMinutes) && Number(state.breakMinutes) > 0
&& Number.isFinite(state.remainingMs) && Number(state.remainingMs) >= 0
&& (state.endsAt === null || Number.isFinite(state.endsAt))
&& typeof state.todayKey === 'string'
&& Number.isInteger(state.todayFocusCount) && Number(state.todayFocusCount) >= 0
&& (state.lastFocusCompletedAt === null || Number.isFinite(state.lastFocusCompletedAt))
&& Number.isInteger(state.revision) && Number(state.revision) >= 0
&& Number.isFinite(state.updatedAt)
&& typeof state.writerId === 'string'
}
export function loadPomodoroState(storage: PomodoroStorage, key: string, now = new Date()) {
try {
const value = JSON.parse(storage.getItem(key) ?? 'null')
if (isPomodoroState(value)) return value
} catch { /* unavailable or invalid storage resets safely */ }
return createPomodoroState(now)
}
export function savePomodoroState(storage: PomodoroStorage, key: string, state: PomodoroState) {
try { storage.setItem(key, JSON.stringify(state)) } catch { /* storage may be unavailable */ }
}
export function shouldAcceptPomodoroState(incoming: PomodoroState, current: PomodoroState) {
const incomingCompletion = incoming.lastFocusCompletedAt ?? -Infinity
const currentCompletion = current.lastFocusCompletedAt ?? -Infinity
if (incomingCompletion !== currentCompletion) return incomingCompletion > currentCompletion
return incoming.revision > current.revision
|| (incoming.revision === current.revision && incoming.updatedAt > current.updatedAt)
|| (incoming.revision === current.revision && incoming.updatedAt === current.updatedAt && incoming.writerId > current.writerId)
}
export function recalibratePomodoro(state: PomodoroState, now = Date.now(), writerId = state.writerId) {
const todayKey = localDateKey(new Date(now))
let next = state
if (state.status === 'running' && state.endsAt !== null && now >= state.endsAt) {
next = finishPomodoro(state, now, writerId)
}
if (next.todayKey !== todayKey) next = changed(next, now, { todayKey, todayFocusCount: 0 }, writerId)
if (next.status !== 'running' || next.endsAt === null) return next
return { ...next, remainingMs: next.endsAt - now }
}
export function formatPomodoroTime(remainingMs: number) {
const totalSeconds = Math.max(0, Math.ceil(remainingMs / 1000))
const minutes = Math.floor(totalSeconds / 60)
const seconds = totalSeconds % 60
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
}
+3
View File
@@ -233,6 +233,9 @@ main:has(>.mvp-view .settings-sections) .settings-data>.inline-error{margin:14px
.calendar-picker__grid button{min-height:44px}
@media(prefers-reduced-motion:reduce){:is(.primary,.primary-small,.soft-button,.secondary,.restore,.file-button,.danger-button){transition:none}:is(.primary,.primary-small,.soft-button,.secondary,.restore,.file-button,.danger-button):active:not(:disabled):not(.disabled){transform:none}}
/* Local-first focus timer: continuous paper, one orange accent. */
.pomodoro-view{width:min(100%,630px);margin:0 auto;padding:18px 0 64px}.pomodoro-heading{min-height:74px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #e8e0d5}.pomodoro-heading h1{margin:4px 0 0;font-size:34px;line-height:1.1;letter-spacing:-.035em}.pomodoro-heading>svg{width:28px;height:28px;color:var(--accent)}.pomodoro-eyebrow{color:var(--muted);font-size:12px}.pomodoro-stage{padding:48px 0 32px;text-align:center;border-bottom:1px solid #e8e0d5}.pomodoro-stage-meta{display:flex;justify-content:center;align-items:baseline;gap:9px}.pomodoro-stage-meta span{font-size:14px;font-weight:750}.pomodoro-stage-meta small{color:var(--muted);font-size:11px}.pomodoro-clock{display:block;margin:18px 0 16px;font-size:clamp(68px,14vw,104px);font-weight:680;line-height:1;letter-spacing:-.065em;font-variant-numeric:tabular-nums}.pomodoro-progress{width:min(360px,80%);height:4px;margin:0 auto;background:#eadfce;overflow:hidden}.pomodoro-progress span{display:block;height:100%;background:var(--accent);transition:width .25s linear}.pomodoro-note{margin:23px auto 0;color:var(--muted);font-size:13px}.pomodoro-controls{min-height:52px;margin-top:28px;display:flex;justify-content:center;flex-wrap:wrap;gap:9px}.pomodoro-controls button{min-height:44px;min-width:112px}.pomodoro-summary{margin:14px 0 0;text-align:center;color:var(--muted);font-size:11px}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@media(max-width:720px){.pomodoro-view{width:100%;padding:8px 12px calc(84px + var(--safe-area-bottom))}.pomodoro-heading h1{font-size:24px}.pomodoro-stage{padding-top:38px}.pomodoro-clock{font-size:clamp(64px,22vw,88px)}.pomodoro-controls button{min-height:44px;flex:1 1 128px}}@media(prefers-reduced-motion:reduce){.pomodoro-progress span{transition:none}}
/* Approved task detail 01: paper flow. */
.detail{min-width:0;border-left:1px solid var(--line);background:#fffdf8;overflow:hidden;display:flex;flex-direction:column}.shell.detail-open{transition:none}
.detail-head{height:58px;flex:0 0 58px;display:flex;align-items:center;justify-content:space-between;padding:0 18px;border-bottom:1px solid #e4dbcf;font-size:13px;font-weight:700;color:var(--text-primary);text-transform:none;letter-spacing:0}