feat: swipe complete, list archive, today habits and calendar subscriptions
ci / docker (push) Successful in 3m20s

This commit is contained in:
2026-09-06 09:33:37 +08:00
parent 13715945d5
commit b937c8ca72
15 changed files with 474 additions and 57 deletions
+35 -10
View File
@@ -329,20 +329,28 @@ async def create_list(
@app.get("/api/v1/lists", response_model=list[ListOut]) @app.get("/api/v1/lists", response_model=list[ListOut])
async def list_lists(user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): async def list_lists(
query = select(TaskList).where(TaskList.user_id == user.id, TaskList.deleted_at.is_(None)) archived: bool = False,
user: User = Depends(current_user),
db: AsyncSession = Depends(get_db),
):
deleted_condition = TaskList.deleted_at.is_not(None) if archived else TaskList.deleted_at.is_(None)
query = select(TaskList).where(TaskList.user_id == user.id, deleted_condition)
ordering = (TaskList.is_inbox.desc(), TaskList.position, TaskList.created_at) ordering = (TaskList.is_inbox.desc(), TaskList.position, TaskList.created_at)
return list((await db.scalars(query.order_by(*ordering))).all()) return list((await db.scalars(query.order_by(*ordering))).all())
async def _owned_list(db: AsyncSession, user_id: UUID, list_id: UUID) -> TaskList: async def _owned_list(
item = await db.scalar( db: AsyncSession,
select(TaskList).where( user_id: UUID,
TaskList.id == list_id, list_id: UUID,
TaskList.user_id == user_id, *,
TaskList.deleted_at.is_(None), include_archived: bool = False,
) ) -> TaskList:
) conditions = [TaskList.id == list_id, TaskList.user_id == user_id]
if not include_archived:
conditions.append(TaskList.deleted_at.is_(None))
item = await db.scalar(select(TaskList).where(*conditions))
if item is None: if item is None:
raise HTTPException(status_code=404, detail="清单不存在") raise HTTPException(status_code=404, detail="清单不存在")
return item return item
@@ -394,6 +402,23 @@ async def delete_list(
return Response(status_code=204) return Response(status_code=204)
@app.post("/api/v1/lists/{list_id}/restore", response_model=ListOut)
async def restore_list(
list_id: UUID,
user: User = Depends(current_user),
db: AsyncSession = Depends(get_db),
):
item = await _owned_list(db, user.id, list_id, include_archived=True)
if item.is_inbox or item.deleted_at is None:
raise HTTPException(status_code=409, detail="清单未归档")
item.deleted_at = None
await db.flush()
audit(db, user.id, "restore", "list", item.id)
await db.commit()
await db.refresh(item)
return item
@app.post("/api/v1/tags", response_model=TagOut, status_code=201) @app.post("/api/v1/tags", response_model=TagOut, status_code=201)
async def create_tag( async def create_tag(
payload: TagCreate, payload: TagCreate,
+11
View File
@@ -188,6 +188,17 @@ class Attachment(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
class CalendarSubscription(Base):
__tablename__ = "calendar_subscriptions"
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
name: Mapped[str] = mapped_column(String(120))
url: Mapped[str] = mapped_column(Text)
color: Mapped[str] = mapped_column(String(32), default="#f15a29")
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
class AuditLog(Base): class AuditLog(Base):
__tablename__ = "audit_logs" __tablename__ = "audit_logs"
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id) id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
+179
View File
@@ -1,6 +1,12 @@
import asyncio
import csv import csv
import io import io
import ipaddress
import re import re
import socket
import urllib.error
import urllib.parse
import urllib.request
from datetime import UTC, date, datetime, time, timedelta from datetime import UTC, date, datetime, time, timedelta
from pathlib import Path from pathlib import Path
from uuid import UUID from uuid import UUID
@@ -17,6 +23,7 @@ from .db import get_db
from .models import ( from .models import (
Attachment, Attachment,
AuditLog, AuditLog,
CalendarSubscription,
Folder, Folder,
Habit, Habit,
HabitLog, HabitLog,
@@ -251,6 +258,178 @@ async def delete_recurrence(recurrence_id: UUID, scope: str = Query("all", patte
await db.commit(); return Response(status_code=204) await db.commit(); return Response(status_code=204)
class CalendarSubscriptionCreate(BaseModel):
name: str = Field(min_length=1, max_length=120)
url: str = Field(min_length=1, max_length=2000)
color: str = Field(default="#f15a29", min_length=1, max_length=32)
def calendar_subscription_dict(row: CalendarSubscription):
return {"id": row.id, "name": row.name, "url": row.url, "color": row.color, "enabled": row.enabled}
def parse_ics_datetime(value: str):
raw = value.strip()
if len(raw) == 8 and raw.isdigit():
return datetime.strptime(raw, "%Y%m%d").replace(tzinfo=UTC), True
cleaned = raw.removesuffix("Z")
parsed = datetime.fromisoformat(cleaned)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=UTC)
return parsed.astimezone(UTC), False
def parse_ics_events(content: str, source_name: str, color: str):
events = []
current = None
for raw_line in content.splitlines():
line = raw_line.strip()
if line == "BEGIN:VEVENT":
current = {}
continue
if line == "END:VEVENT":
if current and current.get("title") and current.get("starts_at"):
events.append({
"id": current.get("uid") or f"{source_name}-{current['starts_at'].isoformat()}-{current['title']}",
"title": current["title"],
"starts_at": current["starts_at"],
"ends_at": current.get("ends_at"),
"all_day": current.get("all_day", False),
"source_name": source_name,
"color": color,
})
current = None
continue
if current is None or ":" not in line:
continue
key, value = line.split(":", 1)
field = key.split(";", 1)[0].upper()
if field == "SUMMARY":
current["title"] = value.strip()
elif field == "UID":
current["uid"] = value.strip()
elif field in {"DTSTART", "DTEND"}:
try:
dt, all_day = parse_ics_datetime(value)
except ValueError:
continue
current["all_day"] = current.get("all_day", False) or all_day
current["starts_at" if field == "DTSTART" else "ends_at"] = dt
return events
def _validate_public_calendar_url(url: str):
parsed = urllib.parse.urlparse(url)
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
raise HTTPException(422, "日历订阅只支持 http/https 链接")
try:
addresses = socket.getaddrinfo(parsed.hostname, None)
except socket.gaierror as exc:
raise HTTPException(422, "日历订阅域名无法解析") from exc
for item in addresses:
ip = ipaddress.ip_address(item[4][0])
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast or ip.is_reserved:
raise HTTPException(422, "日历订阅不能指向内网地址")
def fetch_calendar_events(url: str, source_name: str, color: str):
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
current_url = url
opener = urllib.request.build_opener(NoRedirect)
for _ in range(4):
_validate_public_calendar_url(current_url)
try:
with opener.open(current_url, timeout=15) as response:
content_type = response.headers.get_content_type()
if content_type not in {"text/calendar", "text/plain", "application/octet-stream"}:
raise HTTPException(422, "订阅链接没有返回 ICS 日历内容")
body = response.read(2_000_001)
if len(body) > 2_000_000:
raise HTTPException(413, "日历订阅内容超过 2MB")
return parse_ics_events(body.decode("utf-8", errors="ignore"), source_name, color)
except urllib.error.HTTPError as exc:
if exc.code not in {301, 302, 303, 307, 308}:
raise HTTPException(502, f"订阅拉取失败: HTTP {exc.code}") from exc
location = exc.headers.get("Location")
if not location:
raise HTTPException(502, "订阅重定向缺少目标地址") from exc
current_url = urllib.parse.urljoin(current_url, location)
except urllib.error.URLError as exc:
raise HTTPException(502, f"订阅拉取失败: {exc.reason}") from exc
raise HTTPException(502, "日历订阅重定向次数过多")
@router.get("/calendar-subscriptions")
async def list_calendar_subscriptions(user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
rows = (await db.scalars(select(CalendarSubscription).where(CalendarSubscription.user_id == user.id).order_by(CalendarSubscription.created_at))).all()
return [calendar_subscription_dict(row) for row in rows]
@router.post("/calendar-subscriptions", status_code=201)
async def create_calendar_subscription(payload: CalendarSubscriptionCreate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
_validate_public_calendar_url(payload.url)
row = CalendarSubscription(user_id=user.id, **payload.model_dump())
db.add(row)
await db.commit()
await db.refresh(row)
return calendar_subscription_dict(row)
@router.get("/calendar-subscriptions/today")
async def today_calendar_events(
day: date,
user: User = Depends(current_user),
db: AsyncSession = Depends(get_db),
):
rows = list((await db.scalars(select(CalendarSubscription).where(CalendarSubscription.user_id == user.id, CalendarSubscription.enabled.is_(True)).order_by(CalendarSubscription.created_at))).all())
if not rows:
return []
tomorrow = day + timedelta(days=1)
day_start = datetime.combine(day, time.min, tzinfo=UTC)
day_end = datetime.combine(tomorrow, time.min, tzinfo=UTC)
async def pull(row: CalendarSubscription):
return await asyncio.to_thread(fetch_calendar_events, row.url, row.name, row.color)
results = await asyncio.gather(*(pull(row) for row in rows), return_exceptions=True)
events = []
for row, result in zip(rows, results, strict=True):
if isinstance(result, Exception):
continue
for item in result:
starts_at = item["starts_at"]
ends_at = item.get("ends_at")
if item["all_day"]:
if not (day_start <= starts_at < day_end):
continue
elif not (day_start <= starts_at < day_end):
continue
events.append({
"id": item["id"],
"title": item["title"],
"starts_at": starts_at.isoformat(),
"ends_at": ends_at.isoformat() if ends_at else None,
"all_day": item["all_day"],
"source_name": row.name,
"color": row.color,
})
events.sort(key=lambda item: (item["all_day"] is False, item["starts_at"], item["title"]))
return events
@router.delete("/calendar-subscriptions/{subscription_id}", status_code=204)
async def delete_calendar_subscription(subscription_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
row = await db.scalar(select(CalendarSubscription).where(CalendarSubscription.id == subscription_id, CalendarSubscription.user_id == user.id))
if not row:
raise HTTPException(404, "日历订阅不存在")
await db.delete(row)
await db.commit()
return Response(status_code=204)
class HabitCreate(BaseModel): class HabitCreate(BaseModel):
name: str = Field(min_length=1, max_length=200) name: str = Field(min_length=1, max_length=200)
kind: str = Field("boolean", pattern="^(boolean|numeric)$") kind: str = Field("boolean", pattern="^(boolean|numeric)$")
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -1,3 +1,3 @@
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#f15a29"><link rel="manifest" href="/manifest.json"><title>dodo</title> <script type="module" crossorigin src="/assets/index-VdV4G6jw.js"></script> <!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#f15a29"><link rel="manifest" href="/manifest.json"><title>dodo</title> <script type="module" crossorigin src="/assets/index-BizE1tvq.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CPHlHtC0.css"> <link rel="stylesheet" crossorigin href="/assets/index-BR_rdQ3j.css">
</head><body><div id="app"></div></body></html> </head><body><div id="app"></div></body></html>
+100 -15
View File
@@ -6,15 +6,16 @@ import {
Settings, Trash2, X, Repeat2, Settings, Trash2, X, Repeat2,
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { filterTasks, fromDateTimeLocal, groupTaskTree, renderMarkdown, toDateTimeLocal } from './lib/task-utils' import { filterTasks, fromDateTimeLocal, groupTaskTree, renderMarkdown, toDateTimeLocal } from './lib/task-utils'
import { isTaskView, shouldToggleHabitSwipe } from './lib/mvp-utils' import { isTaskView, shouldToggleRowSwipe } from './lib/mvp-utils'
import { csrfHeader } from './lib/csrf' import { csrfHeader } from './lib/csrf'
import MvpPanel from './MvpPanel.vue' import MvpPanel from './MvpPanel.vue'
type FolderItem = { id: string; name: string } type FolderItem = { id: string; name: string }
type TaskList = { id: string; folder_id: string | null; name: string; is_inbox: boolean } type TaskList = { id: string; folder_id: string | null; name: string; is_inbox: boolean }
type CalendarSubscription = { id: string; name: string; url: string; color?: string }
type Tag = { id: string; name: string; color: string } type Tag = { id: string; name: string; color: string }
type Task = { id: string; list_id: string; parent_id: string | null; title: string; description: string; priority: number; completed: boolean; version: number; due_at: string | null; recurrence_rule?: string | null; recurrence_end_at?: string | null; tags?: Tag[]; subtasks?: Task[] } type Task = { id: string; list_id: string; parent_id: string | null; title: string; description: string; priority: number; completed: boolean; version: number; due_at: string | null; recurrence_rule?: string | null; recurrence_end_at?: string | null; tags?: Tag[]; subtasks?: Task[] }
type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'settings' type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'calendar' | 'settings'
const initialized = ref<boolean | null>(null) const initialized = ref<boolean | null>(null)
const authenticated = ref(false) const authenticated = ref(false)
@@ -22,6 +23,10 @@ const username = ref('')
const password = ref('') const password = ref('')
const folders = ref<FolderItem[]>([]) const folders = ref<FolderItem[]>([])
const lists = ref<TaskList[]>([]) const lists = ref<TaskList[]>([])
const archivedLists = ref<TaskList[]>([])
const calendarSubscriptions = ref<CalendarSubscription[]>([])
const calendarName = ref('合并日历')
const calendarUrl = ref('https://cal.bboy.app/all.ics')
const tags = ref<Tag[]>([]) const tags = ref<Tag[]>([])
const tasks = ref<Task[]>([]) const tasks = ref<Task[]>([])
const trash = ref<Task[]>([]) const trash = ref<Task[]>([])
@@ -44,10 +49,13 @@ const totalPages = computed(() => Math.max(1, Math.ceil(totalTasks.value / pageS
const expandedFolders = ref(new Set<string>()) const expandedFolders = ref(new Set<string>())
const navigationLoaded = ref(false) const navigationLoaded = ref(false)
const taskSwipeStart = ref<{ id: string; x: number; y: number } | null>(null) const taskSwipeStart = ref<{ id: string; x: number; y: number } | null>(null)
const taskSwipeOffsets = ref<Record<string, number>>({})
let suppressTaskClickId = ''
const activeName = computed(() => { const activeName = computed(() => {
if (activeView.value === 'trash') return '回收站' if (activeView.value === 'trash') return '回收站'
if (activeView.value === 'today') return '今天' if (activeView.value === 'today') return '今天'
if (activeView.value === 'calendar') return '日历'
if (activeView.value === 'upcoming') return '最近 7 天' if (activeView.value === 'upcoming') return '最近 7 天'
if (activeView.value === 'habits') return '习惯' if (activeView.value === 'habits') return '习惯'
if (activeView.value === 'settings') return '设置与数据' if (activeView.value === 'settings') return '设置与数据'
@@ -174,6 +182,7 @@ async function loadNavigation(force = false) {
activeList.value = lists.value.find((item) => item.is_inbox)?.id || lists.value[0]?.id || '' activeList.value = lists.value.find((item) => item.is_inbox)?.id || lists.value[0]?.id || ''
} }
expandedFolders.value = new Set(folders.value.map((folder) => folder.id)) expandedFolders.value = new Set(folders.value.map((folder) => folder.id))
await loadArchivedLists()
} }
async function loadAll() { async function loadAll() {
loading.value = true; error.value = '' loading.value = true; error.value = ''
@@ -202,6 +211,7 @@ async function switchView(view: View, listId?: string) {
page.value = 1 page.value = 1
selectedTask.value = null; mobileSidebar.value = false; mobileDetail.value = false selectedTask.value = null; mobileSidebar.value = false; mobileDetail.value = false
if (view === 'trash') await loadTrash() if (view === 'trash') await loadTrash()
else if (view === 'calendar') await loadCalendarTab()
else if (!isTaskView(view)) tasks.value = [] else if (!isTaskView(view)) tasks.value = []
else await loadAll() else await loadAll()
} }
@@ -222,20 +232,50 @@ async function patchTask(task: Task, patch: Partial<Task>) {
async function toggle(task: Task) { async function toggle(task: Task) {
try { await patchTask(task, { completed: !task.completed }); toast(task.completed ? '已重新打开' : '完成啦') } catch (reason) { fail(reason) } try { await patchTask(task, { completed: !task.completed }); toast(task.completed ? '已重新打开' : '完成啦') } catch (reason) { fail(reason) }
} }
function isInteractiveTarget(target: EventTarget | null) {
return target instanceof Element && Boolean(target.closest('button,input,select,textarea,a,label'))
}
function startTaskSwipe(task: Task, event: TouchEvent) { function startTaskSwipe(task: Task, event: TouchEvent) {
if (activeView.value === 'trash' || loading.value) return if (activeView.value === 'trash' || loading.value || isInteractiveTarget(event.target)) return
const touch = event.touches[0] const touch = event.touches[0]
if (touch) taskSwipeStart.value = { id: task.id, x: touch.clientX, y: touch.clientY } if (touch) {
taskSwipeStart.value = { id: task.id, x: touch.clientX, y: touch.clientY }
taskSwipeOffsets.value[task.id] = 0
}
}
function moveTaskSwipe(task: Task, event: TouchEvent) {
const start = taskSwipeStart.value
const touch = event.touches[0]
if (!start || start.id !== task.id || !touch) return
const deltaX = touch.clientX - start.x
const deltaY = touch.clientY - start.y
if (deltaX > 0 && Math.abs(deltaX) > Math.abs(deltaY)) taskSwipeOffsets.value[task.id] = Math.min(deltaX, 92)
} }
async function finishTaskSwipe(task: Task, event: TouchEvent) { async function finishTaskSwipe(task: Task, event: TouchEvent) {
const start = taskSwipeStart.value const start = taskSwipeStart.value
taskSwipeStart.value = null taskSwipeStart.value = null
const offset = taskSwipeOffsets.value[task.id] ?? 0
taskSwipeOffsets.value[task.id] = 0
if (!start || start.id !== task.id || activeView.value === 'trash' || loading.value) return if (!start || start.id !== task.id || activeView.value === 'trash' || loading.value) return
const touch = event.changedTouches[0] const touch = event.changedTouches[0]
if (touch && shouldToggleHabitSwipe(touch.clientX - start.x, touch.clientY - start.y)) await toggle(task) if (touch && shouldToggleRowSwipe(touch.clientX - start.x, touch.clientY - start.y)) {
suppressTaskClickId = task.id
window.setTimeout(() => { if (suppressTaskClickId === task.id) suppressTaskClickId = '' }, 400)
await toggle(task)
} else if (offset) {
taskSwipeOffsets.value[task.id] = 0
}
} }
function cancelTaskSwipe() { function cancelTaskSwipe(task?: Task) {
taskSwipeStart.value = null taskSwipeStart.value = null
if (task) taskSwipeOffsets.value[task.id] = 0
}
function selectTaskUnlessSwiped(task: Task) {
if (suppressTaskClickId === task.id) {
suppressTaskClickId = ''
return
}
selectTask(task)
} }
async function saveTask() { async function saveTask() {
if (!selectedTask.value?.title.trim()) return if (!selectedTask.value?.title.trim()) return
@@ -279,9 +319,34 @@ async function renameEntity(kind: 'folders' | 'lists', item: FolderItem | TaskLi
try { const updated = await api(`/${kind}/${item.id}`, { method: 'PATCH', body: JSON.stringify({ name }) }); Object.assign(item, updated); toast('已重命名') } catch (reason) { fail(reason) } try { const updated = await api(`/${kind}/${item.id}`, { method: 'PATCH', body: JSON.stringify({ name }) }); Object.assign(item, updated); toast('已重命名') } catch (reason) { fail(reason) }
} }
async function deleteEntity(kind: 'folders' | 'lists', item: FolderItem | TaskList) { async function deleteEntity(kind: 'folders' | 'lists', item: FolderItem | TaskList) {
if (kind === 'lists') {
if (!window.confirm(`归档“${item.name}”?任务会移到收集箱,历史数据保留。`)) return
try { await api(`/${kind}/${item.id}`, { method: 'DELETE' }); await loadArchivedLists(); await refreshAll(); toast('清单已归档') } catch (reason) { fail(reason) }
return
}
if (!window.confirm(`删除“${item.name}”?`)) return if (!window.confirm(`删除“${item.name}”?`)) return
try { await api(`/${kind}/${item.id}`, { method: 'DELETE' }); await refreshAll(); toast('已删除') } catch (reason) { fail(reason) } try { await api(`/${kind}/${item.id}`, { method: 'DELETE' }); await refreshAll(); toast('已删除') } catch (reason) { fail(reason) }
} }
async function loadArchivedLists() {
try { archivedLists.value = await api('/lists?archived=true') } catch { archivedLists.value = [] }
}
async function restoreList(item: TaskList) {
try { await api(`/lists/${item.id}/restore`, { method: 'POST' }); await loadArchivedLists(); await refreshAll(); toast('清单已恢复') } catch (reason) { fail(reason) }
}
async function loadCalendarTab() {
try { calendarSubscriptions.value = await api('/calendar-subscriptions') } catch { calendarSubscriptions.value = [] }
}
async function addCalendarSubscription() {
if (!calendarUrl.value.trim()) return
try {
await api('/calendar-subscriptions', { method: 'POST', body: JSON.stringify({ name: calendarName.value.trim() || calendarUrl.value, url: calendarUrl.value.trim(), color: '#f15a29' }) })
await loadCalendarTab(); toast('日历已订阅')
} catch (reason) { fail(reason) }
}
async function removeCalendarSubscription(id: string) {
if (!window.confirm('取消这个日历订阅?')) return
try { await api(`/calendar-subscriptions/${id}`, { method: 'DELETE' }); await loadCalendarTab(); toast('已取消订阅') } catch (reason) { fail(reason) }
}
async function createTag() { async function createTag() {
const name = window.prompt('标签名称')?.trim(); if (!name) return const name = window.prompt('标签名称')?.trim(); if (!name) return
const color = window.prompt('标签颜色', '#F15A29') || '#F15A29' const color = window.prompt('标签颜色', '#F15A29') || '#F15A29'
@@ -328,6 +393,7 @@ onMounted(bootstrap)
<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==='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==='today' }" @click="switchView('today')"><ListTodo />今天</button> <button :class="{ active: activeView==='today' }" @click="switchView('today')"><ListTodo />今天</button>
<button :class="{ active: activeView==='upcoming' }" @click="switchView('upcoming')"><CalendarDays />最近 7 </button> <button :class="{ active: activeView==='upcoming' }" @click="switchView('upcoming')"><CalendarDays />最近 7 </button>
<button :class="{ active: activeView==='calendar' }" @click="switchView('calendar')"><CalendarDays />日历</button>
<button :class="{ active: activeView==='habits' }" @click="switchView('habits')"><Repeat2 />习惯</button> <button :class="{ active: activeView==='habits' }" @click="switchView('habits')"><Repeat2 />习惯</button>
<button :class="{ active: activeView==='trash' }" @click="switchView('trash')"><Trash2 />回收站</button> <button :class="{ active: activeView==='trash' }" @click="switchView('trash')"><Trash2 />回收站</button>
</nav> </nav>
@@ -335,9 +401,13 @@ onMounted(bootstrap)
<div class="folders"> <div class="folders">
<div v-for="folder in folders" :key="folder.id" class="folder-block"> <div v-for="folder in folders" :key="folder.id" class="folder-block">
<div class="folder-row"><button @click="toggleFolder(folder.id)"><ChevronDown v-if="expandedFolders.has(folder.id)"/><ChevronRight v-else/><Folder/>{{folder.name}}</button><span class="row-actions"><button aria-label="重命名文件夹" @click="renameEntity('folders',folder)"><Pencil/></button><button aria-label="删除文件夹" @click="deleteEntity('folders',folder)"><Trash2/></button><button aria-label="在文件夹中新建清单" @click="createList(folder.id)"><Plus/></button></span></div> <div class="folder-row"><button @click="toggleFolder(folder.id)"><ChevronDown v-if="expandedFolders.has(folder.id)"/><ChevronRight v-else/><Folder/>{{folder.name}}</button><span class="row-actions"><button aria-label="重命名文件夹" @click="renameEntity('folders',folder)"><Pencil/></button><button aria-label="删除文件夹" @click="deleteEntity('folders',folder)"><Trash2/></button><button aria-label="在文件夹中新建清单" @click="createList(folder.id)"><Plus/></button></span></div>
<button v-for="list in lists.filter(l=>l.folder_id===folder.id && !l.is_inbox)" v-show="expandedFolders.has(folder.id)" :key="list.id" class="list-row" :class="{active:activeView==='tasks'&&activeList===list.id}" @click="switchView('tasks',list.id)"><i/><span>{{list.name}}</span><span class="row-actions"><button aria-label="重命名清单" @click.stop="renameEntity('lists',list)"><Pencil/></button><button aria-label="删除清单" @click.stop="deleteEntity('lists',list)"><Trash2/></button></span></button> <button v-for="list in lists.filter(l=>l.folder_id===folder.id && !l.is_inbox)" v-show="expandedFolders.has(folder.id)" :key="list.id" class="list-row" :class="{active:activeView==='tasks'&&activeList===list.id}" @click="switchView('tasks',list.id)"><i/><span>{{list.name}}</span><span class="row-actions"><button aria-label="重命名清单" @click.stop="renameEntity('lists',list)"><Pencil/></button><button aria-label="归档清单" @click.stop="deleteEntity('lists',list)"><ArchiveRestore/></button></span></button>
</div> </div>
<button v-for="list in lists.filter(l=>!l.folder_id&&!l.is_inbox)" :key="list.id" class="list-row" :class="{active:activeView==='tasks'&&activeList===list.id}" @click="switchView('tasks',list.id)"><i/><span>{{list.name}}</span><span class="row-actions"><button aria-label="重命名清单" @click.stop="renameEntity('lists',list)"><Pencil/></button><button aria-label="删除清单" @click.stop="deleteEntity('lists',list)"><Trash2/></button></span></button> <button v-for="list in lists.filter(l=>!l.folder_id&&!l.is_inbox)" :key="list.id" class="list-row" :class="{active:activeView==='tasks'&&activeList===list.id}" @click="switchView('tasks',list.id)"><i/><span>{{list.name}}</span><span class="row-actions"><button aria-label="重命名清单" @click.stop="renameEntity('lists',list)"><Pencil/></button><button aria-label="归档清单" @click.stop="deleteEntity('lists',list)"><ArchiveRestore/></button></span></button>
<template v-if="archivedLists.length">
<div class="section-title"><span>已归档清单</span></div>
<button v-for="list in archivedLists" :key="list.id" class="list-row archived-row" @click="restoreList(list)"><ArchiveRestore/><span>{{list.name}}</span><span class="row-actions"><button aria-label="恢复清单" @click.stop="restoreList(list)"><ArchiveRestore/></button></span></button>
</template>
</div> </div>
<button class="settings" :class="{active:activeView==='settings'}" @click="switchView('settings')"><Settings />设置</button> <button class="settings" :class="{active:activeView==='settings'}" @click="switchView('settings')"><Settings />设置</button>
</aside> </aside>
@@ -348,27 +418,42 @@ onMounted(bootstrap)
<div><p>今天也慢慢来</p><h1>{{ activeName }}</h1></div> <div><p>今天也慢慢来</p><h1>{{ activeName }}</h1></div>
<label class="search"><Search/><input v-model="query" placeholder="搜索任务…" aria-label="搜索任务"><kbd> K</kbd></label> <label class="search"><Search/><input v-model="query" placeholder="搜索任务…" aria-label="搜索任务"><kbd> K</kbd></label>
</header> </header>
<template v-if="['habits','settings'].includes(activeView)"> <template v-if="['habits','settings','today','calendar'].includes(activeView)">
<MvpPanel :key="activeView" :view="activeView as 'habits'|'settings'" @changed="refreshAll" @notice="toast" /> <MvpPanel v-if="['habits','settings'].includes(activeView)" :key="activeView" :view="activeView as 'habits'|'settings'" @changed="refreshAll" @notice="toast" />
<template v-else-if="activeView === 'calendar'">
<header class="view-intro"><div><small>订阅外部日历每天自动同步</small><h2>日历</h2></div><button class="soft-button" @click="loadCalendarTab"><RefreshCw />刷新</button></header>
<div class="settings-grid">
<article class="tool-card wide"><Upload /><h3>添加订阅</h3><p>粘贴 ICS / webcal 链接例如 cal.bboy.app 的合并日历</p><div class="sub-add"><input v-model="calendarName" placeholder="名称(可选)" aria-label="订阅名称"><input v-model="calendarUrl" placeholder="https://…/all.ics" aria-label="订阅地址"><button class="primary-small" @click="addCalendarSubscription">订阅</button></div><div v-if="calendarSubscriptions.length"><div v-for="sub in calendarSubscriptions" :key="sub.id" class="session-row"><span><b>{{sub.name}}</b><small>{{sub.url}}</small></span><button class="danger-text" @click="removeCalendarSubscription(sub.id)">取消订阅</button></div></div><p v-else class="hint">还没有日历订阅</p></article>
</div>
</template>
</template> </template>
<template v-else> <template v-else>
<template v-if="activeView==='today'">
<div class="list-toolbar"><label><input v-model="showCompleted" type="checkbox"> 显示已完成</label><span> {{visibleTasks.length}} </span></div>
<h3 class="section-heading"><ListTodo/>今日任务</h3>
</template>
<form v-if="activeView!=='trash'" class="quick" @submit.prevent="addTask"><CirclePlus/><input v-model="title" class="quick-input" placeholder="添加任务,按回车保存"><button>添加</button></form> <form v-if="activeView!=='trash'" class="quick" @submit.prevent="addTask"><CirclePlus/><input v-model="title" class="quick-input" placeholder="添加任务,按回车保存"><button>添加</button></form>
<div class="list-toolbar"><label v-if="activeView!=='trash'"><input v-model="showCompleted" type="checkbox"> 显示已完成</label><span>第 {{page}} / {{totalPages}} 页 · 共 {{totalTasks}} 项</span><button v-if="query" class="link" @click="query=''">清除搜索</button></div> <div class="list-toolbar"><label v-if="activeView!=='trash'"><input v-model="showCompleted" type="checkbox"> 显示已完成</label><span>第 {{page}} / {{totalPages}} 页 · 共 {{totalTasks}} 项</span><button v-if="query" class="link" @click="query=''">清除搜索</button></div>
<div v-if="activeView!=='trash' && totalPages > 1" class="pager"><button class="secondary" :disabled="page<=1 || loading" @click="previousPage">上一页</button><span>{{page}} / {{totalPages}}</span><button class="secondary" :disabled="page>=totalPages || loading" @click="nextPage">下一页</button></div> <div v-if="activeView!=='trash' && totalPages > 1" class="pager"><button class="secondary" :disabled="page<=1 || loading" @click="previousPage">上一页</button><span>{{page}} / {{totalPages}}</span><button class="secondary" :disabled="page>=totalPages || loading" @click="nextPage">下一页</button></div>
<section class="task-list" :class="{loading}"> <section class="task-list" :class="{loading}">
<template v-for="node in taskTree" :key="node.task.id"> <template v-for="node in taskTree" :key="node.task.id">
<article class="task-row swipeable" :class="{done:node.task.completed,selected:selectedTask?.id===node.task.id}" @touchstart.passive="startTaskSwipe(node.task, $event)" @touchend="finishTaskSwipe(node.task, $event)" @touchcancel="cancelTaskSwipe"> <article class="task-row swipeable" :class="{done:node.task.completed,selected:selectedTask?.id===node.task.id,ready:(taskSwipeOffsets[node.task.id] ?? 0) >= 64}" :style="{ '--swipe-x': `${taskSwipeOffsets[node.task.id] ?? 0}px` }" @touchstart.passive="startTaskSwipe(node.task, $event)" @touchmove.passive="moveTaskSwipe(node.task, $event)" @touchend="finishTaskSwipe(node.task, $event)" @touchcancel="cancelTaskSwipe(node.task)">
<button class="task-main" @click="activeView==='trash'?undefined:selectTask(node.task)"><strong>{{node.task.title}}</strong><span class="meta"><span v-if="node.task.due_at"><CalendarDays/>{{formatDue(node.task.due_at)}}</span><span v-if="node.subtasks.length"><ListChecks/>{{node.subtasks.filter(t=>t.completed).length}}/{{node.subtasks.length}}</span><i v-for="tag in node.task.tags" :key="tag.id" class="tag-dot" :style="{background:tag.color}" :title="tag.name"/></span></button> <span class="swipe-bg"> 完成</span>
<span v-if="activeView!=='trash'" class="task-swipe-hint">{{ node.task.completed ? '已完成' : '右滑' }}</span> <div class="task-main" role="button" tabindex="0" @click="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task)" @keydown.enter="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task)" @keydown.space.prevent="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task)"><strong>{{node.task.title}}</strong><span class="meta"><span v-if="node.task.due_at"><CalendarDays/>{{formatDue(node.task.due_at)}}</span><span v-if="node.subtasks.length"><ListChecks/>{{node.subtasks.filter(t=>t.completed).length}}/{{node.subtasks.length}}</span><i v-for="tag in node.task.tags" :key="tag.id" class="tag-dot" :style="{background:tag.color}" :title="tag.name"/></span></div>
<button v-if="activeView!=='trash'" class="task-swipe-hint" :aria-label="node.task.completed ? `重新打开${node.task.title}` : `完成${node.task.title}`" :aria-pressed="node.task.completed" @click.stop="toggle(node.task)">{{ node.task.completed ? '已完成' : '完成' }}</button>
<span v-if="node.task.priority" class="priority" :class="`p${node.task.priority}`">{{['','','',''][node.task.priority]}}</span> <span v-if="node.task.priority" class="priority" :class="`p${node.task.priority}`">{{['','','',''][node.task.priority]}}</span>
<button v-if="activeView==='trash'" class="restore" @click="restoreTask(node.task)"><ArchiveRestore/>恢复</button> <button v-if="activeView==='trash'" class="restore" @click="restoreTask(node.task)"><ArchiveRestore/>恢复</button>
<button v-else class="icon ghost" aria-label="删除任务" @click.stop="removeTask(node.task)"><Trash2/></button> <button v-else class="icon ghost" aria-label="删除任务" @click.stop="removeTask(node.task)"><Trash2/></button>
<button v-if="activeView==='trash'" class="icon danger ghost" aria-label="永久删除" @click.stop="purgeTask(node.task)"><X/></button> <button v-if="activeView==='trash'" class="icon danger ghost" aria-label="永久删除" @click.stop="purgeTask(node.task)"><X/></button>
</article> </article>
<article v-for="subtask in node.subtasks" :key="subtask.id" class="task-row subtask swipeable" :class="{done:subtask.completed}" @touchstart.passive="startTaskSwipe(subtask, $event)" @touchend="finishTaskSwipe(subtask, $event)" @touchcancel="cancelTaskSwipe"><GripVertical/><button class="task-main" @click="selectTask(subtask)"><strong>{{subtask.title}}</strong></button><span class="task-swipe-hint">{{ subtask.completed ? '已完成' : '右滑' }}</span></article> <article v-for="subtask in node.subtasks" :key="subtask.id" class="task-row subtask swipeable" :class="{done:subtask.completed,ready:(taskSwipeOffsets[subtask.id] ?? 0) >= 64}" :style="{ '--swipe-x': `${taskSwipeOffsets[subtask.id] ?? 0}px` }" @touchstart.passive="startTaskSwipe(subtask, $event)" @touchmove.passive="moveTaskSwipe(subtask, $event)" @touchend="finishTaskSwipe(subtask, $event)" @touchcancel="cancelTaskSwipe(subtask)"><span class="swipe-bg">✓ 完成</span><GripVertical/><div class="task-main" role="button" tabindex="0" @click="selectTaskUnlessSwiped(subtask)" @keydown.enter="selectTaskUnlessSwiped(subtask)" @keydown.space.prevent="selectTaskUnlessSwiped(subtask)"><strong>{{subtask.title}}</strong></div><button class="task-swipe-hint" :aria-label="subtask.completed ? `重新打开${subtask.title}` : `完成${subtask.title}`" :aria-pressed="subtask.completed" @click.stop="toggle(subtask)">{{ subtask.completed ? '已完成' : '完成' }}</button></article>
</template> </template>
<div v-if="!visibleTasks.length&&!loading" class="empty"><ListTodo/><b>{{query?'没有匹配的任务':'这里还很安静'}}</b><span>{{query?'换个关键词试试':'写下第一件想完成的小事吧'}}</span></div> <div v-if="!visibleTasks.length&&!loading" class="empty"><ListTodo/><b>{{query?'没有匹配的任务':'这里还很安静'}}</b><span>{{query?'换个关键词试试':'写下第一件想完成的小事吧'}}</span></div>
</section> </section>
<div v-if="activeView==='today'">
<h3 class="section-heading"><Repeat2/>今日习惯</h3>
<MvpPanel view="today-habits" @changed="refreshAll" @notice="toast" />
</div>
</template> </template>
</main> </main>
@@ -389,7 +474,7 @@ onMounted(bootstrap)
<div v-else class="paper"><ListChecks/><b>选中一个任务</b><p>日期优先级标签子任务和 Markdown 备注会出现在这里</p></div> <div v-else class="paper"><ListChecks/><b>选中一个任务</b><p>日期优先级标签子任务和 Markdown 备注会出现在这里</p></div>
</aside> </aside>
<nav class="bottom"><button :class="{active:activeView==='today'}" @click="switchView('today')"><ListTodo/><span>今天</span></button><button :class="{active:activeView==='tasks'}" @click="switchView('tasks',activeList)"><Inbox/><span>任务</span></button><button :class="{active:activeView==='habits'}" @click="switchView('habits')"><Repeat2/><span>习惯</span></button><button :class="{active:activeView==='settings'}" @click="switchView('settings')"><Settings/><span>设置</span></button></nav> <nav class="bottom"><button :class="{active:activeView==='today'}" @click="switchView('today')"><ListTodo/><span>今天</span></button><button :class="{active:activeView==='tasks'}" @click="switchView('tasks',activeList)"><Inbox/><span>任务</span></button><button :class="{active:activeView==='calendar'}" @click="switchView('calendar')"><CalendarDays/><span>日历</span></button><button :class="{active:activeView==='habits'}" @click="switchView('habits')"><Repeat2/><span>习惯</span></button><button :class="{active:activeView==='settings'}" @click="switchView('settings')"><Settings/><span>设置</span></button></nav>
<button v-if="['tasks','today','upcoming'].includes(activeView)" class="fab" aria-label="添加任务" @click="focusQuick"><CirclePlus/></button> <button v-if="['tasks','today','upcoming'].includes(activeView)" class="fab" aria-label="添加任务" @click="focusQuick"><CirclePlus/></button>
<Transition name="toast"><div v-if="notice" class="toast" role="status">{{notice}}</div></Transition> <Transition name="toast"><div v-if="notice" class="toast" role="status">{{notice}}</div></Transition>
<div v-if="error" class="error-toast" role="alert">{{error}}<button @click="error=''"><X/></button></div> <div v-if="error" class="error-toast" role="alert">{{error}}<button @click="error=''"><X/></button></div>
+94 -14
View File
@@ -1,17 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue' import { onBeforeUnmount, onMounted, ref } from 'vue'
import { Activity, ArchiveRestore, Download, FileJson, LogOut, Plus, RefreshCw, Trash2, Upload } from 'lucide-vue-next' import { Activity, ArchiveRestore, Download, FileJson, LogOut, Plus, RefreshCw, Trash2, Upload } from 'lucide-vue-next'
import { dateKey, isHabitComplete, mergePage, numericHabitInputValue, shouldToggleHabitSwipe } from './lib/mvp-utils' import { dateKey, isHabitComplete, mergePage, numericHabitInputValue, shouldToggleRowSwipe } from './lib/mvp-utils'
import { csrfHeader } from './lib/csrf' import { csrfHeader } from './lib/csrf'
type View = 'habits' | 'settings' type View = 'habits' | 'today-habits' | 'settings'
type Habit = { id: string; name: string; kind?: string; target?: number; max_value?: number | null; unit?: string; cells?: Array<{ day: string; scheduled?: boolean; paused?: boolean; value: number | boolean }>; stats?: Record<string, number> } type Habit = { id: string; name: string; kind?: string; target?: number; max_value?: number | null; unit?: string; cells?: Array<{ day: string; scheduled?:boolean; paused?:boolean; value: number | boolean }>; stats?: Record<string, number> }
type CalendarSubscription = { id: string; name: string; url: string; color: string; enabled?: boolean }
type CalendarEvent = { id: string; title: string; starts_at: string; ends_at?: string | null; all_day: boolean; source_name: string; color?: string }
type Session = { id: string; created_at?: string; last_seen_at?: string; current?: boolean; user_agent?: string } type Session = { id: string; created_at?: string; last_seen_at?: string; current?: boolean; user_agent?: string }
const props = defineProps<{ view: View }>() const props = defineProps<{ view: View }>()
const emit = defineEmits<{ changed: []; notice: [message: string] }>() const emit = defineEmits<{ changed: []; notice: [message: string] }>()
const habits = ref<Habit[]>([]) const habits = ref<Habit[]>([])
const sessions = ref<Session[]>([]) const sessions = ref<Session[]>([])
const audit = ref<any[]>([]) const audit = ref<any[]>([])
const calendarSubscriptions = ref<CalendarSubscription[]>([])
const todayEvents = ref<CalendarEvent[]>([])
const calendarName = ref('合并日历')
const calendarUrl = ref('https://cal.bboy.app/all.ics')
const busy = ref(false) const busy = ref(false)
const error = ref('') const error = ref('')
const habitName = ref('') const habitName = ref('')
@@ -23,6 +29,7 @@ const restoreFile = ref<File | null>(null)
const numericValues = ref<Record<string, number | string>>({}) const numericValues = ref<Record<string, number | string>>({})
const todayKey = ref(dateKey(new Date())) const todayKey = ref(dateKey(new Date()))
const habitSwipeStart = ref<{ id: string; x: number; y: number } | null>(null) const habitSwipeStart = ref<{ id: string; x: number; y: number } | null>(null)
const habitSwipeOffsets = ref<Record<string, number>>({})
let dayRolloverTimer: ReturnType<typeof setInterval> | undefined let dayRolloverTimer: ReturnType<typeof setInterval> | undefined
function formatErrorMessage(detail: unknown): string { function formatErrorMessage(detail: unknown): string {
@@ -71,22 +78,41 @@ async function toggleHabit(h: Habit, day: string) {
emit('notice', next ? '打卡成功 🎉' : '已取消打卡') emit('notice', next ? '打卡成功 🎉' : '已取消打卡')
}) })
} }
function isInteractiveTarget(target: EventTarget | null) {
return target instanceof Element && Boolean(target.closest('button,input,select,textarea,a,label'))
}
function startHabitSwipe(h: Habit, event: TouchEvent) { function startHabitSwipe(h: Habit, event: TouchEvent) {
if (h.kind === 'numeric' || busy.value) return if (h.kind === 'numeric' || busy.value || isInteractiveTarget(event.target)) return
const touch = event.touches[0] const touch = event.touches[0]
if (touch) habitSwipeStart.value = { id: h.id, x: touch.clientX, y: touch.clientY } if (touch) {
habitSwipeStart.value = { id: h.id, x: touch.clientX, y: touch.clientY }
habitSwipeOffsets.value[h.id] = 0
}
}
function moveHabitSwipe(h: Habit, event: TouchEvent) {
const start = habitSwipeStart.value
const touch = event.touches[0]
if (!start || start.id !== h.id || !touch) return
const deltaX = touch.clientX - start.x
const deltaY = touch.clientY - start.y
if (deltaX > 0 && Math.abs(deltaX) > Math.abs(deltaY)) habitSwipeOffsets.value[h.id] = Math.min(deltaX, 92)
} }
async function finishHabitSwipe(h: Habit, event: TouchEvent) { async function finishHabitSwipe(h: Habit, event: TouchEvent) {
const start = habitSwipeStart.value const start = habitSwipeStart.value
habitSwipeStart.value = null habitSwipeStart.value = null
habitSwipeOffsets.value[h.id] = 0
if (!start || start.id !== h.id || h.kind === 'numeric' || busy.value) return if (!start || start.id !== h.id || h.kind === 'numeric' || busy.value) return
const touch = event.changedTouches[0] const touch = event.changedTouches[0]
if (touch && shouldToggleHabitSwipe(touch.clientX - start.x, touch.clientY - start.y)) { if (touch && shouldToggleRowSwipe(touch.clientX - start.x, touch.clientY - start.y)) {
await toggleHabit(h, todayKey.value) await toggleHabit(h, todayKey.value)
} }
} }
function cancelHabitSwipe() { function cancelHabitSwipe(h?: Habit) {
habitSwipeStart.value = null habitSwipeStart.value = null
if (h) habitSwipeOffsets.value[h.id] = 0
}
async function toggleHabitFromButton(h: Habit) {
await toggleHabit(h, todayKey.value)
} }
async function recordNumeric(h: Habit, day: string) { async function recordNumeric(h: Habit, day: string) {
if (busy.value) return if (busy.value) return
@@ -135,6 +161,50 @@ async function loadSettings() {
audit.value = mergePage<any>(a).items audit.value = mergePage<any>(a).items
}) })
} }
async function loadCalendarTab() {
await safe(async () => {
const [subs] = await Promise.all([request('/calendar-subscriptions').catch(() => [])])
calendarSubscriptions.value = (subs as CalendarSubscription[]) ?? []
await loadTodayEvents()
})
}
async function addCalendarSubscription() {
if (!calendarUrl.value.trim()) return
await safe(async () => {
await request('/calendar-subscriptions', {
method: 'POST',
body: JSON.stringify({ name: calendarName.value.trim() || calendarUrl.value, url: calendarUrl.value.trim(), color: '#f15a29' }),
})
await loadCalendarTab()
emit('notice', '日历已订阅')
})
}
async function removeCalendarSubscription(id: string) {
if (!confirm('取消这个日历订阅?')) return
await safe(async () => {
await request(`/calendar-subscriptions/${id}`, { method: 'DELETE' })
await loadCalendarTab()
emit('notice', '已取消订阅')
})
}
function habitTimeText(h: Habit) {
return isDone(h, todayKey.value) ? '✓ 已完成' : '未完成'
}
async function loadTodayEvents() {
if (!calendarSubscriptions.value.length) {
todayEvents.value = []
return
}
try {
const events = await request(`/calendar-subscriptions/today?day=${todayKey.value}`) as CalendarEvent[]
todayEvents.value = events ?? []
} catch { todayEvents.value = [] }
}
function formatEventTime(item: CalendarEvent) {
if (item.all_day) return '全天'
const date = new Date(item.starts_at)
return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`
}
async function revoke(id: string) { async function revoke(id: string) {
await safe(async () => { await request(`/sessions/${id}`, { method: 'DELETE' }); await loadSettings(); emit('notice', '会话已撤销') }) await safe(async () => { await request(`/sessions/${id}`, { method: 'DELETE' }); await loadSettings(); emit('notice', '会话已撤销') })
} }
@@ -167,7 +237,7 @@ async function restore() {
}) })
} }
onMounted(() => { onMounted(() => {
if (props.view === 'habits') { if (props.view === 'habits' || props.view === 'today-habits') {
refreshHabitDay() refreshHabitDay()
void loadHabits() void loadHabits()
dayRolloverTimer = setInterval(refreshHabitDay, 60_000) dayRolloverTimer = setInterval(refreshHabitDay, 60_000)
@@ -185,14 +255,23 @@ onBeforeUnmount(() => {
<p v-if="error" class="inline-error">{{ error }}</p> <p v-if="error" class="inline-error">{{ error }}</p>
<!-- 习惯TickTick 风格一次只操作一个习惯不再逐格小按钮误触 --> <!-- 习惯TickTick 风格一次只操作一个习惯不再逐格小按钮误触 -->
<template v-if="view === 'habits'"> <template v-if="view === 'habits' || view === 'today-habits'">
<header class="view-intro"> <header class="view-intro">
<div><small>把想坚持的事变成每天的日常</small><h2>习惯</h2></div> <div><small>{{ view === 'today-habits' ? '今天习惯单独看,一眼就知道还差什么' : '把想坚持的事变成每天的日常' }}</small><h2>{{ view === 'today-habits' ? '今天的习惯' : '习惯' }}</h2></div>
<button class="soft-button" @click="loadHabits"><RefreshCw />刷新</button> <button class="soft-button" @click="loadHabits"><RefreshCw />刷新</button>
</header> </header>
<!-- 新建习惯 --> <!-- 今日习惯只展示今天该做习惯 -->
<form class="habit-create" @submit.prevent="addHabit"> <div v-if="view === 'today-habits' && !busy" class="today-strip">
<template v-for="h in habits.filter((item) => item.kind === 'boolean' ? !isDone(item, todayKey) : (item.cells ?? []).find((cell) => cell.day === todayKey && cell.scheduled) !== undefined)" :key="h.id">
<button class="today-chip" :class="{ done: isDone(h, todayKey) }" :aria-label="isDone(h, todayKey) ? `完成${h.name}打卡` : `打卡${h.name}`" @click="toggleHabitFromButton(h)"><Repeat2 />{{ h.name }}</button>
</template>
<div v-if="!habits.some((item) => item.kind === 'boolean' ? !isDone(item, todayKey) : (item.cells ?? []).find((cell) => cell.day === todayKey && cell.scheduled) !== undefined)" class="empty-panel">今天的习惯都完成了 🎉</div>
</div>
<div v-else-if="view === 'today-habits' && busy" class="empty-panel">加载中</div>
<!-- 完整习惯列表 -->
<form v-if="view === 'habits'" class="habit-create" @submit.prevent="addHabit">
<input v-model="habitName" placeholder="新习惯名称(如:喝水 8 杯)" aria-label="新习惯名称"> <input v-model="habitName" placeholder="新习惯名称(如:喝水 8 杯)" aria-label="新习惯名称">
<select v-model="habitType" aria-label="习惯类型"> <select v-model="habitType" aria-label="习惯类型">
<option value="boolean">完成 / 未完成</option> <option value="boolean">完成 / 未完成</option>
@@ -205,10 +284,11 @@ onBeforeUnmount(() => {
<!-- 习惯列表布尔习惯整行右滑打卡数值习惯保留明确输入记录 --> <!-- 习惯列表布尔习惯整行右滑打卡数值习惯保留明确输入记录 -->
<div class="habit-list"> <div class="habit-list">
<article v-for="h in habits" :key="h.id" class="habit-row" :class="{ done: isDone(h, todayKey), swipeable: h.kind !== 'numeric' }" @touchstart.passive="startHabitSwipe(h, $event)" @touchend="finishHabitSwipe(h, $event)" @touchcancel="cancelHabitSwipe"> <article v-for="h in habits" :key="h.id" class="habit-row" :class="{ done: isDone(h, todayKey), swipeable: h.kind !== 'numeric', ready: (habitSwipeOffsets[h.id] ?? 0) >= 64 }" :style="{ '--swipe-x': `${habitSwipeOffsets[h.id] ?? 0}px` }" @touchstart.passive="startHabitSwipe(h, $event)" @touchmove.passive="moveHabitSwipe(h, $event)" @touchend="finishHabitSwipe(h, $event)" @touchcancel="cancelHabitSwipe(h)">
<span v-if="h.kind !== 'numeric'" class="swipe-bg"> 打卡</span>
<div v-if="h.kind !== 'numeric'" class="habit-main"> <div v-if="h.kind !== 'numeric'" class="habit-main">
<span><span class="habit-name">{{ h.name }}</span><small>{{ isDone(h, todayKey) ? '今天已完成 · 右滑可取消' : '右滑整行打卡' }}</small></span> <span><span class="habit-name">{{ h.name }}</span><small>{{ isDone(h, todayKey) ? '今天已完成 · 右滑可取消' : '右滑整行打卡' }}</small></span>
<span class="habit-swipe-hint" aria-hidden="true">{{ isDone(h, todayKey) ? '已完成' : '右滑' }}</span> <button class="habit-swipe-hint" :aria-label="isDone(h, todayKey) ? `取消${h.name}今天的打卡` : `完成${h.name}今天的打卡`" :aria-pressed="isDone(h, todayKey)" @click.stop="toggleHabitFromButton(h)">{{ isDone(h, todayKey) ? '已完成' : '打卡' }}</button>
</div> </div>
<div v-else class="habit-main numeric-habit"> <div v-else class="habit-main numeric-habit">
<span><span class="habit-name">{{ h.name }}</span><small>今天 {{ logFor(h, todayKey)?.value || 0 }} / {{ h.target || 1 }}{{ h.unit || '' }}</small></span> <span><span class="habit-name">{{ h.name }}</span><small>今天 {{ logFor(h, todayKey)?.value || 0 }} / {{ h.target || 1 }}{{ h.unit || '' }}</small></span>
+6 -6
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { dateKey, habitWeek, isHabitComplete, isTaskView, numericHabitInputValue, shouldToggleHabitSwipe } from './mvp-utils' import { dateKey, habitWeek, isHabitComplete, isTaskView, numericHabitInputValue, shouldToggleRowSwipe } from './mvp-utils'
describe('MVP view utilities', () => { describe('MVP view utilities', () => {
it('formats a local date as YYYY-MM-DD', () => { it('formats a local date as YYYY-MM-DD', () => {
@@ -29,10 +29,10 @@ describe('MVP view utilities', () => {
expect(numericHabitInputValue(4)).toBe(4) expect(numericHabitInputValue(4)).toBe(4)
}) })
it('toggles a habit only for a deliberate horizontal swipe', () => { it('toggles a row only for a deliberate right swipe', () => {
expect(shouldToggleHabitSwipe(78, 8)).toBe(true) expect(shouldToggleRowSwipe(78, 8)).toBe(true)
expect(shouldToggleHabitSwipe(-78, 8)).toBe(false) expect(shouldToggleRowSwipe(-78, 8)).toBe(false)
expect(shouldToggleHabitSwipe(30, 2)).toBe(false) expect(shouldToggleRowSwipe(30, 2)).toBe(false)
expect(shouldToggleHabitSwipe(80, 35)).toBe(false) expect(shouldToggleRowSwipe(80, 35)).toBe(false)
}) })
}) })
+1 -1
View File
@@ -35,6 +35,6 @@ export function numericHabitInputValue(input: number | string | undefined) {
return Number.isFinite(value) && value >= 0 ? value : null return Number.isFinite(value) && value >= 0 ? value : null
} }
export function shouldToggleHabitSwipe(deltaX: number, deltaY: number) { export function shouldToggleRowSwipe(deltaX: number, deltaY: number) {
return deltaX >= 64 && Math.abs(deltaY) <= 24 && deltaX > Math.abs(deltaY) * 1.5 return deltaX >= 64 && Math.abs(deltaY) <= 24 && deltaX > Math.abs(deltaY) * 1.5
} }
+4 -3
View File
@@ -4,16 +4,17 @@
*{box-sizing:border-box}body{margin:0;background:#f8f3e8}button,input,textarea,select{font:inherit;color:inherit}button{cursor:pointer}svg{width:18px;height:18px;stroke-width:1.8}.center,.auth-shell{min-height:100vh;display:grid;place-items:center}.center{gap:12px;align-content:center;color:var(--muted)}.loader{width:24px;height:24px;border:2px solid var(--line);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite}@keyframes spin{to{transform:rotate(360deg)}} *{box-sizing:border-box}body{margin:0;background:#f8f3e8}button,input,textarea,select{font:inherit;color:inherit}button{cursor:pointer}svg{width:18px;height:18px;stroke-width:1.8}.center,.auth-shell{min-height:100vh;display:grid;place-items:center}.center{gap:12px;align-content:center;color:var(--muted)}.loader{width:24px;height:24px;border:2px solid var(--line);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}
.auth-shell{background:radial-gradient(circle at 20% 10%,#ffe3d5 0,transparent 28%),linear-gradient(135deg,#f8f3e8,#fffaf0)}.auth-card{width:min(390px,90vw);padding:38px;background:var(--paper);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow);display:grid;gap:16px}.brand{font-size:40px;font-weight:850;letter-spacing:-3px}.brand span{color:var(--accent)}.brand.small{font-size:29px}.auth-card p{margin:0 0 8px;color:var(--muted)}.auth-card label,.detail-form>label{display:grid;gap:7px;font-size:12px;font-weight:650;color:#756d61}.auth-card input,.detail-form input,.detail-form select{width:100%;border:1px solid var(--line);background:#fff;padding:11px;border-radius:9px;outline:none}.auth-card input:focus,.detail-form input:focus,.detail-form select:focus,.detail-form textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(241,90,41,.1)}.primary{border:0;background:var(--accent);color:#fff;padding:12px;border-radius:9px;font-weight:700;box-shadow:0 5px 12px rgba(241,90,41,.2)}.auth-card small{color:var(--danger)} .auth-shell{background:radial-gradient(circle at 20% 10%,#ffe3d5 0,transparent 28%),linear-gradient(135deg,#f8f3e8,#fffaf0)}.auth-card{width:min(390px,90vw);padding:38px;background:var(--paper);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow);display:grid;gap:16px}.brand{font-size:40px;font-weight:850;letter-spacing:-3px}.brand span{color:var(--accent)}.brand.small{font-size:29px}.auth-card p{margin:0 0 8px;color:var(--muted)}.auth-card label,.detail-form>label{display:grid;gap:7px;font-size:12px;font-weight:650;color:#756d61}.auth-card input,.detail-form input,.detail-form select{width:100%;border:1px solid var(--line);background:#fff;padding:11px;border-radius:9px;outline:none}.auth-card input:focus,.detail-form input:focus,.detail-form select:focus,.detail-form textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(241,90,41,.1)}.primary{border:0;background:var(--accent);color:#fff;padding:12px;border-radius:9px;font-weight:700;box-shadow:0 5px 12px rgba(241,90,41,.2)}.auth-card small{color:var(--danger)}
.shell{height:100vh;display:grid;grid-template-columns:238px minmax(430px,1fr) 350px;background:var(--paper);overflow:hidden}.sidebar{border-right:1px solid var(--line);background:var(--sidebar);display:flex;flex-direction:column;min-height:0}.brand-row{height:76px;padding:0 20px;display:flex;align-items:center;justify-content:space-between}.primary-nav{display:grid;padding:4px 12px 12px;gap:3px}.primary-nav button,.settings{display:flex;align-items:center;gap:10px;border:0;background:transparent;padding:10px 12px;border-radius:9px;color:#665e52;text-align:left}.primary-nav button:hover,.list-row:hover,.folder-row>button:hover{background:rgba(255,255,255,.52)}.primary-nav button.active,.list-row.active{background:var(--accent-soft);color:#b7421e;font-weight:700}.section-title{padding:17px 17px 7px 22px;display:flex;justify-content:space-between;align-items:center;color:#958b7d;text-transform:uppercase;letter-spacing:.08em;font-size:11px;font-weight:750}.section-title>span:last-child{display:flex}.mini-icon,.row-actions button{border:0;background:transparent;padding:4px;color:#8e8477}.mini-icon svg,.row-actions svg{width:14px;height:14px}.folders{padding:0 10px;overflow:auto}.folder-row{display:flex;align-items:center}.folder-row>button{flex:1;min-width:0;border:0;background:transparent;padding:8px;display:flex;align-items:center;gap:7px;color:#6d6559;text-align:left}.folder-row>button svg{width:14px}.row-actions{display:flex;opacity:0;transition:opacity .15s}.folder-row:hover .row-actions,.list-row:hover .row-actions{opacity:1}.list-row{width:100%;border:0;background:transparent;padding:8px 7px 8px 31px;border-radius:8px;display:flex;align-items:center;gap:9px;text-align:left;color:#665f55}.list-row>span:nth-child(2){flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.list-row i{width:8px;height:8px;background:#d89b62;border-radius:3px}.settings{margin:auto 12px 14px}.mobile-only,.bottom,.fab{display:none} .shell{height:100vh;display:grid;grid-template-columns:238px minmax(430px,1fr) 350px;background:var(--paper);overflow:hidden}.sidebar{border-right:1px solid var(--line);background:var(--sidebar);display:flex;flex-direction:column;min-height:0}.brand-row{height:76px;padding:0 20px;display:flex;align-items:center;justify-content:space-between}.primary-nav{display:grid;padding:4px 12px 12px;gap:3px}.primary-nav button,.settings{display:flex;align-items:center;gap:10px;border:0;background:transparent;padding:10px 12px;border-radius:9px;color:#665e52;text-align:left}.primary-nav button:hover,.list-row:hover,.folder-row>button:hover{background:rgba(255,255,255,.52)}.primary-nav button.active,.list-row.active{background:var(--accent-soft);color:#b7421e;font-weight:700}.section-title{padding:17px 17px 7px 22px;display:flex;justify-content:space-between;align-items:center;color:#958b7d;text-transform:uppercase;letter-spacing:.08em;font-size:11px;font-weight:750}.section-title>span:last-child{display:flex}.mini-icon,.row-actions button{border:0;background:transparent;padding:4px;color:#8e8477}.mini-icon svg,.row-actions svg{width:14px;height:14px}.folders{padding:0 10px;overflow:auto}.folder-row{display:flex;align-items:center}.folder-row>button{flex:1;min-width:0;border:0;background:transparent;padding:8px;display:flex;align-items:center;gap:7px;color:#6d6559;text-align:left}.folder-row>button svg{width:14px}.row-actions{display:flex;opacity:0;transition:opacity .15s}.folder-row:hover .row-actions,.list-row:hover .row-actions{opacity:1}.list-row{width:100%;border:0;background:transparent;padding:8px 7px 8px 31px;border-radius:8px;display:flex;align-items:center;gap:9px;text-align:left;color:#665f55}.list-row>span:nth-child(2){flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.list-row i{width:8px;height:8px;background:#d89b62;border-radius:3px}.settings{margin:auto 12px 14px}.mobile-only,.bottom,.fab{display:none}
main{min-width:0;padding:27px 34px 50px;overflow:auto;background:linear-gradient(rgba(255,253,248,.92),rgba(255,253,248,.92)),repeating-linear-gradient(0deg,transparent,transparent 31px,#eee3d2 32px)}.topbar{display:flex;align-items:center;gap:14px}.topbar>div{flex:1}.topbar p{margin:0;color:var(--muted);font-size:12px}.topbar h1{font-size:27px;margin:3px 0 21px;letter-spacing:-.03em}.search{margin-bottom:18px;display:flex;align-items:center;gap:8px;width:min(260px,36%);padding:8px 10px;background:#faf7f0;border:1px solid var(--line);border-radius:9px;color:var(--muted)}.search input{min-width:0;width:100%;border:0;outline:0;background:transparent}.search kbd{font-size:10px;white-space:nowrap;border:1px solid var(--line);padding:2px 4px;border-radius:4px}.quick{display:flex;align-items:center;gap:10px;background:#fff;border:1px solid var(--line);border-radius:11px;padding:7px 7px 7px 13px;box-shadow:0 2px 10px rgba(81,61,38,.05);transition:.18s}.quick:focus-within{border-color:var(--accent);box-shadow:0 0 0 3px rgba(241,90,41,.09)}.quick>svg{color:var(--accent)}.quick input{border:0;outline:0;background:transparent;min-width:0;flex:1}.quick button{border:0;border-radius:8px;background:var(--accent);color:white;padding:8px 15px;font-weight:650}.list-toolbar{height:42px;display:flex;align-items:center;gap:14px;color:var(--muted);font-size:12px}.list-toolbar label{margin-right:auto}.link{border:0;background:transparent;color:var(--accent);padding:3px;display:inline-flex;gap:3px;align-items:center}.link svg{width:14px}.task-list{transition:opacity .2s}.task-list.loading{opacity:.45}.task-row{min-height:53px;display:flex;align-items:center;gap:10px;border-bottom:1px solid var(--line);padding:4px 8px;transition:background .15s,transform .15s}.task-row.swipeable{touch-action:pan-y}.task-row:hover,.task-row.selected{background:rgba(250,240,229,.75)}.task-row:hover{transform:translateX(2px)}.check{width:19px;height:19px;flex:0 0 19px;border:1.6px solid #c6baa8;background:#fff;border-radius:5px;padding:0;display:grid;place-items:center}.check svg{width:13px}.check.p1{border-color:#4b93d1}.check.p2{border-color:#d79b25}.check.p3{border-color:#dc4b30}.done .check{background:#afa595;color:white}.task-main{border:0;background:transparent;flex:1;min-width:0;text-align:left;padding:8px 0}.task-main strong{display:block;font-size:14px;font-weight:590;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.done .task-main strong{text-decoration:line-through;color:#9b9388}.task-swipe-hint{flex:0 0 auto;border-radius:999px;background:#f3ede3;color:#9a8f80;padding:6px 10px;font-size:11px;font-weight:700}.task-row.done .task-swipe-hint{background:var(--accent-soft);color:#b7421e}.meta{display:flex;align-items:center;gap:9px;color:#9b9286;font-size:11px;margin-top:3px}.meta span{display:flex;align-items:center;gap:3px}.meta svg{width:12px}.tag-dot{width:7px;height:7px;border-radius:50%}.priority{font-size:10px;font-weight:750;padding:3px 6px;border-radius:5px}.priority.p1{color:#3f80ba;background:#e5f2fc}.priority.p2{color:#a56b05;background:#fff1cb}.priority.p3{color:#bd3827;background:#fde2dc}.icon,.ghost{border:0;background:transparent;display:grid;place-items:center;padding:5px;border-radius:6px}.ghost{opacity:0;color:#9d9387}.task-row:hover .ghost{opacity:1}.ghost:hover{color:var(--danger);background:#fce7e2}.restore{display:flex;align-items:center;gap:5px;border:1px solid var(--line);background:#fff;border-radius:7px;padding:6px 8px;font-size:12px}.restore svg{width:14px}.subtask{padding-left:53px;min-height:42px;color:#6d655b}.subtask>svg{width:13px;color:#bbb0a2}.empty{min-height:300px;display:grid;place-items:center;align-content:center;gap:8px;color:#aaa094;text-align:center}.empty>svg{width:38px;height:38px;color:#d8cabb}.empty b{color:#6f675c}.empty span{font-size:13px} main{min-width:0;padding:27px 34px 50px;overflow:auto;background:linear-gradient(rgba(255,253,248,.92),rgba(255,253,248,.92)),repeating-linear-gradient(0deg,transparent,transparent 31px,#eee3d2 32px)}.topbar{display:flex;align-items:center;gap:14px}.topbar>div{flex:1}.topbar p{margin:0;color:var(--muted);font-size:12px}.topbar h1{font-size:27px;margin:3px 0 21px;letter-spacing:-.03em}.search{margin-bottom:18px;display:flex;align-items:center;gap:8px;width:min(260px,36%);padding:8px 10px;background:#faf7f0;border:1px solid var(--line);border-radius:9px;color:var(--muted)}.search input{min-width:0;width:100%;border:0;outline:0;background:transparent}.search kbd{font-size:10px;white-space:nowrap;border:1px solid var(--line);padding:2px 4px;border-radius:4px}.quick{display:flex;align-items:center;gap:10px;background:#fff;border:1px solid var(--line);border-radius:11px;padding:7px 7px 7px 13px;box-shadow:0 2px 10px rgba(81,61,38,.05);transition:.18s}.quick:focus-within{border-color:var(--accent);box-shadow:0 0 0 3px rgba(241,90,41,.09)}.quick>svg{color:var(--accent)}.quick input{border:0;outline:0;background:transparent;min-width:0;flex:1}.quick button{border:0;border-radius:8px;background:var(--accent);color:white;padding:8px 15px;font-weight:650}.list-toolbar{height:42px;display:flex;align-items:center;gap:14px;color:var(--muted);font-size:12px}.list-toolbar label{margin-right:auto}.link{border:0;background:transparent;color:var(--accent);padding:3px;display:inline-flex;gap:3px;align-items:center}.link svg{width:14px}.task-list{transition:opacity .2s}.task-list.loading{opacity:.45}.task-row{--swipe-x:0px;min-height:53px;display:flex;align-items:center;gap:10px;border-bottom:1px solid var(--line);padding:4px 8px;transition:background .15s,transform .18s ease;position:relative;overflow:hidden;isolation:isolate}.task-row.swipeable{touch-action:pan-y;transform:translateX(var(--swipe-x))}.task-row.swipeable:not([style*="0px"]){transition:none}.task-row:hover,.task-row.selected{background:rgba(250,240,229,.75)}.task-row:hover{transform:translateX(calc(var(--swipe-x) + 2px))}.swipe-bg{position:absolute;z-index:-1;inset:0 auto 0 calc(-1 * var(--swipe-x));width:var(--swipe-x);min-width:0;display:flex;align-items:center;padding-left:14px;background:#e9f7ee;color:#24814f;font-size:12px;font-weight:800;white-space:nowrap;overflow:hidden}.task-row.ready .swipe-bg,.habit-row.ready .swipe-bg{background:#dff3e7;color:#187142}.check{width:19px;height:19px;flex:0 0 19px;border:1.6px solid #c6baa8;background:#fff;border-radius:5px;padding:0;display:grid;place-items:center}.check svg{width:13px}.check.p1{border-color:#4b93d1}.check.p2{border-color:#d79b25}.check.p3{border-color:#dc4b30}.done .check{background:#afa595;color:white}.task-main{border:0;background:transparent;flex:1;min-width:0;text-align:left;padding:8px 0}.task-main strong{display:block;font-size:14px;font-weight:590;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.done .task-main strong{text-decoration:line-through;color:#9b9388}.task-swipe-hint{min-height:44px;flex:0 0 auto;border:0;border-radius:999px;background:#f3ede3;color:#9a8f80;padding:8px 11px;font-size:11px;font-weight:700}.task-row.done .task-swipe-hint{background:var(--accent-soft);color:#b7421e}.meta{display:flex;align-items:center;gap:9px;color:#9b9286;font-size:11px;margin-top:3px}.meta span{display:flex;align-items:center;gap:3px}.meta svg{width:12px}.tag-dot{width:7px;height:7px;border-radius:50%}.priority{font-size:10px;font-weight:750;padding:3px 6px;border-radius:5px}.priority.p1{color:#3f80ba;background:#e5f2fc}.priority.p2{color:#a56b05;background:#fff1cb}.priority.p3{color:#bd3827;background:#fde2dc}.icon,.ghost{border:0;background:transparent;display:grid;place-items:center;padding:5px;border-radius:6px}.ghost{opacity:0;color:#9d9387}.task-row:hover .ghost{opacity:1}.ghost:hover{color:var(--danger);background:#fce7e2}.restore{display:flex;align-items:center;gap:5px;border:1px solid var(--line);background:#fff;border-radius:7px;padding:6px 8px;font-size:12px}.restore svg{width:14px}.subtask{padding-left:53px;min-height:42px;color:#6d655b}.subtask>svg{width:13px;color:#bbb0a2}.empty{min-height:300px;display:grid;place-items:center;align-content:center;gap:8px;color:#aaa094;text-align:center}.empty>svg{width:38px;height:38px;color:#d8cabb}.empty b{color:#6f675c}.empty span{font-size:13px}
.detail{min-width:0;border-left:1px solid var(--line);background:#faf7f0;overflow:auto}.detail-head{height:57px;display:flex;align-items:center;justify-content:space-between;padding:0 21px;border-bottom:1px solid var(--line);font-size:12px;font-weight:700;color:#80766a;text-transform:uppercase;letter-spacing:.08em}.paper{margin:22px;padding:28px 20px;min-height:180px;background:#fff;border:1px solid var(--line);border-radius:11px;box-shadow:0 4px 18px rgba(76,57,34,.05);display:grid;place-items:center;align-content:center;text-align:center;color:#8f8578}.paper svg{width:32px;height:32px;color:#ceb8a4;margin-bottom:12px}.paper b{color:#625b50}.paper p{font-size:13px;line-height:1.6}.detail-form{padding:19px;display:grid;gap:15px}.detail-title{display:flex;align-items:flex-start;gap:10px}.check.large{margin-top:8px;width:22px;height:22px;flex-basis:22px}.detail-title textarea{flex:1;border:0;background:transparent;resize:none;outline:none;font-size:19px;line-height:1.4;font-weight:700}.detail-form>label{grid-template-columns:80px 1fr;align-items:center}.detail-form>label input,.detail-form>label select{padding:8px}.field{display:grid;gap:7px}.field-label{display:flex;justify-content:space-between;align-items:center;font-size:12px;font-weight:700;color:#756d61}.tag-picker{display:flex;flex-wrap:wrap;gap:6px}.tag-picker button{border:1px solid var(--line);background:#fff;border-radius:999px;padding:5px 8px;display:flex;align-items:center;gap:5px;font-size:11px}.tag-picker button.chosen{border-color:#c89077;background:#fff1e9}.tag-picker i{width:8px;height:8px;border-radius:50%}.hint{font-size:12px;color:#a49a8d}.markdown .field-label>span:last-child{display:flex;background:#eee7dc;padding:2px;border-radius:6px}.markdown .field-label button{border:0;background:transparent;padding:4px 8px;border-radius:5px;font-size:11px}.markdown .field-label button.active{background:#fff;color:var(--accent)}.markdown textarea{border:1px solid var(--line);background:#fff;border-radius:9px;padding:11px;resize:vertical;outline:none;font:13px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace}.markdown-preview{min-height:160px;padding:10px 12px;background:#fff;border:1px solid var(--line);border-radius:9px;font-size:13px;line-height:1.65;overflow-wrap:anywhere}.markdown-preview h1{font-size:20px}.markdown-preview h2{font-size:16px}.markdown-preview p{margin:8px 0}.markdown-preview code{background:#f2ece2;padding:2px 4px;border-radius:4px}.markdown-preview a{color:var(--accent)}.subtasks{display:grid;gap:5px}.subtask-detail{display:flex;align-items:center;gap:8px;border:0;background:#fff;padding:8px;border-radius:7px;text-align:left}.subtask-detail .check{pointer-events:none}.strike{text-decoration:line-through;color:var(--muted)}.detail-actions{display:flex;justify-content:space-between;align-items:center;padding-top:10px;border-top:1px solid var(--line)}.secondary{border:1px solid var(--line);background:#fff;padding:8px 11px;border-radius:8px;font-weight:650}.danger-text{border:0;background:transparent;color:var(--danger);display:flex;align-items:center;gap:5px;font-size:12px}.danger-text svg{width:14px} .detail{min-width:0;border-left:1px solid var(--line);background:#faf7f0;overflow:auto}.detail-head{height:57px;display:flex;align-items:center;justify-content:space-between;padding:0 21px;border-bottom:1px solid var(--line);font-size:12px;font-weight:700;color:#80766a;text-transform:uppercase;letter-spacing:.08em}.paper{margin:22px;padding:28px 20px;min-height:180px;background:#fff;border:1px solid var(--line);border-radius:11px;box-shadow:0 4px 18px rgba(76,57,34,.05);display:grid;place-items:center;align-content:center;text-align:center;color:#8f8578}.paper svg{width:32px;height:32px;color:#ceb8a4;margin-bottom:12px}.paper b{color:#625b50}.paper p{font-size:13px;line-height:1.6}.detail-form{padding:19px;display:grid;gap:15px}.detail-title{display:flex;align-items:flex-start;gap:10px}.check.large{margin-top:8px;width:22px;height:22px;flex-basis:22px}.detail-title textarea{flex:1;border:0;background:transparent;resize:none;outline:none;font-size:19px;line-height:1.4;font-weight:700}.detail-form>label{grid-template-columns:80px 1fr;align-items:center}.detail-form>label input,.detail-form>label select{padding:8px}.field{display:grid;gap:7px}.field-label{display:flex;justify-content:space-between;align-items:center;font-size:12px;font-weight:700;color:#756d61}.tag-picker{display:flex;flex-wrap:wrap;gap:6px}.tag-picker button{border:1px solid var(--line);background:#fff;border-radius:999px;padding:5px 8px;display:flex;align-items:center;gap:5px;font-size:11px}.tag-picker button.chosen{border-color:#c89077;background:#fff1e9}.tag-picker i{width:8px;height:8px;border-radius:50%}.hint{font-size:12px;color:#a49a8d}.markdown .field-label>span:last-child{display:flex;background:#eee7dc;padding:2px;border-radius:6px}.markdown .field-label button{border:0;background:transparent;padding:4px 8px;border-radius:5px;font-size:11px}.markdown .field-label button.active{background:#fff;color:var(--accent)}.markdown textarea{border:1px solid var(--line);background:#fff;border-radius:9px;padding:11px;resize:vertical;outline:none;font:13px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace}.markdown-preview{min-height:160px;padding:10px 12px;background:#fff;border:1px solid var(--line);border-radius:9px;font-size:13px;line-height:1.65;overflow-wrap:anywhere}.markdown-preview h1{font-size:20px}.markdown-preview h2{font-size:16px}.markdown-preview p{margin:8px 0}.markdown-preview code{background:#f2ece2;padding:2px 4px;border-radius:4px}.markdown-preview a{color:var(--accent)}.subtasks{display:grid;gap:5px}.subtask-detail{display:flex;align-items:center;gap:8px;border:0;background:#fff;padding:8px;border-radius:7px;text-align:left}.subtask-detail .check{pointer-events:none}.strike{text-decoration:line-through;color:var(--muted)}.detail-actions{display:flex;justify-content:space-between;align-items:center;padding-top:10px;border-top:1px solid var(--line)}.secondary{border:1px solid var(--line);background:#fff;padding:8px 11px;border-radius:8px;font-weight:650}.danger-text{border:0;background:transparent;color:var(--danger);display:flex;align-items:center;gap:5px;font-size:12px}.danger-text svg{width:14px}
.toast,.error-toast{position:fixed;z-index:50;left:50%;bottom:24px;transform:translateX(-50%);background:#322d28;color:#fff;border-radius:9px;padding:10px 15px;box-shadow:var(--shadow);font-size:13px}.error-toast{background:var(--danger);display:flex;align-items:center;gap:10px}.error-toast button{border:0;background:transparent;color:#fff;padding:0}.toast-enter-active,.toast-leave-active{transition:.2s}.toast-enter-from,.toast-leave-to{opacity:0;transform:translate(-50%,8px)} .toast,.error-toast{position:fixed;z-index:50;left:50%;bottom:24px;transform:translateX(-50%);background:#322d28;color:#fff;border-radius:9px;padding:10px 15px;box-shadow:var(--shadow);font-size:13px}.error-toast{background:var(--danger);display:flex;align-items:center;gap:10px}.error-toast button{border:0;background:transparent;color:#fff;padding:0}.toast-enter-active,.toast-leave-active{transition:.2s}.toast-enter-from,.toast-leave-to{opacity:0;transform:translate(-50%,8px)}
@media(max-width:1050px){.shell{grid-template-columns:220px minmax(400px,1fr) 310px}main{padding-inline:24px}} @media(max-width:1050px){.shell{grid-template-columns:220px minmax(400px,1fr) 310px}main{padding-inline:24px}}
@media(max-width:800px){.shell{height:100dvh;display:block;overflow:auto}.sidebar,.detail{position:fixed;z-index:30;display:flex;top:0;bottom:0;transition:transform .22s ease;box-shadow:var(--shadow)}.sidebar{left:0;width:min(300px,86vw);transform:translateX(-105%)}.sidebar.open{transform:none}.detail{right:0;width:min(430px,94vw);transform:translateX(105%)}.detail.open{transform:none}.scrim{position:fixed;z-index:20;inset:0;background:rgba(45,38,31,.26)}.mobile-only{display:grid}main{min-height:100dvh;padding:20px 17px 112px}.topbar h1{font-size:24px;margin-bottom:17px}.search{width:auto;margin-bottom:13px;padding:8px}.search input{width:90px}.search kbd{display:none}.quick button{display:none}.row-actions{opacity:1}.bottom{display:flex;position:fixed;z-index:15;left:0;right:0;bottom:0;justify-content:space-around;background:rgba(255,253,248,.96);border-top:1px solid var(--line);padding:8px 5px max(8px,env(safe-area-inset-bottom));box-shadow:0 -5px 18px rgba(79,59,34,.06)}.bottom button{min-width:60px;border:0;background:transparent;color:#81786d;display:grid;place-items:center;gap:2px;font-size:10px}.bottom button.active{color:var(--accent);font-weight:700}.bottom svg{width:20px}.fab{display:grid;place-items:center;position:fixed;z-index:16;right:18px;bottom:76px;width:52px;height:52px;border:0;border-radius:50%;background:var(--accent);color:#fff;box-shadow:0 7px 20px rgba(241,90,41,.38);transition:transform .15s}.fab:active{transform:scale(.94)}.toast,.error-toast{bottom:142px}.task-row{padding-inline:2px}.subtask{padding-left:35px}.ghost{opacity:.45}} @media(max-width:800px){.shell{height:100dvh;display:block;overflow:auto}.sidebar,.detail{position:fixed;z-index:30;display:flex;top:0;bottom:0;transition:transform .22s ease;box-shadow:var(--shadow)}.sidebar{left:0;width:min(300px,86vw);transform:translateX(-105%)}.sidebar.open{transform:none}.detail{right:0;width:min(430px,94vw);transform:translateX(105%)}.detail.open{transform:none}.scrim{position:fixed;z-index:20;inset:0;background:rgba(45,38,31,.26)}.mobile-only{display:grid}main{min-height:100dvh;padding:20px 17px 112px}.topbar h1{font-size:24px;margin-bottom:17px}.search{width:auto;margin-bottom:13px;padding:8px}.search input{width:90px}.search kbd{display:none}.quick button{display:none}.row-actions{opacity:1}.bottom{display:flex;position:fixed;z-index:15;left:0;right:0;bottom:0;justify-content:space-around;background:rgba(255,253,248,.96);border-top:1px solid var(--line);padding:8px 5px max(8px,env(safe-area-inset-bottom));box-shadow:0 -5px 18px rgba(79,59,34,.06)}.bottom button{min-width:60px;border:0;background:transparent;color:#81786d;display:grid;place-items:center;gap:2px;font-size:10px}.bottom button.active{color:var(--accent);font-weight:700}.bottom svg{width:20px}.fab{display:grid;place-items:center;position:fixed;z-index:16;right:18px;bottom:76px;width:52px;height:52px;border:0;border-radius:50%;background:var(--accent);color:#fff;box-shadow:0 7px 20px rgba(241,90,41,.38);transition:transform .15s}.fab:active{transform:scale(.94)}.toast,.error-toast{bottom:142px}.task-row{padding-inline:2px}.subtask{padding-left:35px}.ghost{opacity:.45}}
.mvp-view{display:grid;gap:16px;padding-bottom:36px}.view-intro{display:flex;align-items:end;justify-content:space-between;border-bottom:1px dashed var(--line);padding-bottom:12px}.view-intro h2{margin:2px 0 0;font-size:22px}.view-intro small,.view-intro>span{color:var(--muted);font-size:12px}.tool-card,.empty-panel{background:#fff;border:1px solid var(--line);border-radius:12px;padding:16px;box-shadow:0 3px 14px rgba(81,61,38,.05)}.soft-button,.primary-small,.danger-button,.file-button{display:inline-flex;align-items:center;justify-content:center;gap:6px;border:1px solid var(--line);background:#fff;border-radius:8px;padding:8px 11px;font-size:12px}.soft-button svg,.file-button svg{width:15px}.primary-small{background:var(--accent);border-color:var(--accent);color:#fff}.danger-button{color:var(--danger);border-color:#e5b7ad}.habit-create{display:grid;grid-template-columns:1fr 150px 90px auto;gap:8px}.habit-create input,.habit-create select{min-width:0;border:1px solid var(--line);border-radius:8px;background:#fff;padding:9px}.habit-create button{border:0;border-radius:8px;background:var(--accent);color:#fff;padding:8px 13px;display:flex;align-items:center;gap:5px}.habit-list{display:grid;gap:10px}.tool-card h3{margin:0 0 4px} .mvp-view{display:grid;gap:16px;padding-bottom:36px}.view-intro{display:flex;align-items:end;justify-content:space-between;border-bottom:1px dashed var(--line);padding-bottom:12px}.view-intro h2{margin:2px 0 0;font-size:22px}.view-intro small,.view-intro>span{color:var(--muted);font-size:12px}.tool-card,.empty-panel{background:#fff;border:1px solid var(--line);border-radius:12px;padding:16px;box-shadow:0 3px 14px rgba(81,61,38,.05)}.soft-button,.primary-small,.danger-button,.file-button{display:inline-flex;align-items:center;justify-content:center;gap:6px;border:1px solid var(--line);background:#fff;border-radius:8px;padding:8px 11px;font-size:12px}.soft-button svg,.file-button svg{width:15px}.primary-small{background:var(--accent);border-color:var(--accent);color:#fff}.danger-button{color:var(--danger);border-color:#e5b7ad}.habit-create{display:grid;grid-template-columns:1fr 150px 90px auto;gap:8px}.habit-create input,.habit-create select{min-width:0;border:1px solid var(--line);border-radius:8px;background:#fff;padding:9px}.habit-create button{border:0;border-radius:8px;background:var(--accent);color:#fff;padding:8px 13px;display:flex;align-items:center;gap:5px}.habit-list{display:grid;gap:10px}.tool-card h3{margin:0 0 4px}
.habit-row{display:flex;align-items:center;gap:8px;background:#fff;border:1px solid var(--line);border-radius:14px;padding:5px 5px 5px 12px;box-shadow:0 3px 14px rgba(81,61,38,.05);transition:border-color .15s,box-shadow .15s} .habit-row{--swipe-x:0px;display:flex;align-items:center;gap:8px;background:#fff;border:1px solid var(--line);border-radius:14px;padding:5px 5px 5px 12px;box-shadow:0 3px 14px rgba(81,61,38,.05);transition:border-color .15s,box-shadow .15s,transform .18s ease;position:relative;overflow:hidden;isolation:isolate}
.habit-row.done{border-color:var(--accent)} .habit-row.done{border-color:var(--accent)}
.habit-row.swipeable{touch-action:pan-y} .habit-row.swipeable{touch-action:pan-y;transform:translateX(var(--swipe-x))}
.habit-row.swipeable:not([style*="0px"]){transition:none}
.habit-main{flex:1;min-width:0;display:flex;align-items:center;justify-content:space-between;gap:10px;border:0;background:transparent;padding:13px 4px;text-align:left;border-radius:10px} .habit-main{flex:1;min-width:0;display:flex;align-items:center;justify-content:space-between;gap:10px;border:0;background:transparent;padding:13px 4px;text-align:left;border-radius:10px}
.habit-name{font-size:15px;font-weight:700;color:#3c372f;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .habit-name{font-size:15px;font-weight:700;color:#3c372f;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.habit-main>span:first-child{display:grid;gap:3px}.habit-main small{font-size:11px;color:var(--muted)} .habit-main>span:first-child{display:grid;gap:3px}.habit-main small{font-size:11px;color:var(--muted)}
@@ -0,0 +1,31 @@
"""calendar subscriptions
Revision ID: 0007
Revises: 0006
"""
import sqlalchemy as sa
from alembic import op
revision = "0007"
down_revision = "0006"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"calendar_subscriptions",
sa.Column("id", sa.Uuid(), primary_key=True),
sa.Column("user_id", sa.Uuid(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("name", sa.String(120), nullable=False),
sa.Column("url", sa.Text(), nullable=False),
sa.Column("color", sa.String(32), nullable=False),
sa.Column("enabled", sa.Boolean(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_calendar_subscriptions_user_id", "calendar_subscriptions", ["user_id"])
def downgrade() -> None:
op.drop_index("ix_calendar_subscriptions_user_id", table_name="calendar_subscriptions")
op.drop_table("calendar_subscriptions")
+5
View File
@@ -212,6 +212,11 @@ def test_inbox_is_protected_and_deleted_collections_are_hidden(client):
).json() ).json()
assert client.delete(f"/api/v1/lists/{task_list['id']}").status_code == 204 assert client.delete(f"/api/v1/lists/{task_list['id']}").status_code == 204
assert all(row["id"] != task_list["id"] for row in client.get("/api/v1/lists").json()) assert all(row["id"] != task_list["id"] for row in client.get("/api/v1/lists").json())
archived = client.get("/api/v1/lists", params={"archived": True})
assert archived.status_code == 200
assert [row["id"] for row in archived.json()] == [task_list["id"]]
assert client.post(f"/api/v1/lists/{task_list['id']}/restore").status_code == 200
assert any(row["id"] == task_list["id"] for row in client.get("/api/v1/lists").json())
moved = client.get(f"/api/v1/tasks/{task['id']}").json() moved = client.get(f"/api/v1/tasks/{task['id']}").json()
assert moved["list_id"] == inbox["id"] assert moved["list_id"] == inbox["id"]
assert client.delete(f"/api/v1/folders/{folder['id']}").status_code == 204 assert client.delete(f"/api/v1/folders/{folder['id']}").status_code == 204