fix: default task deadline to date only
ci / docker (push) Successful in 4m33s

This commit is contained in:
2026-09-08 09:24:00 +08:00
parent 113791229a
commit 972c4e1fb8
9 changed files with 68 additions and 12 deletions
+1
View File
@@ -95,6 +95,7 @@ class Task(Base):
priority: Mapped[int] = mapped_column(Integer, default=0)
completed: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
due_has_time: Mapped[bool] = mapped_column(Boolean, default=False)
version: Mapped[int] = mapped_column(Integer, default=1)
position: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
+2 -1
View File
@@ -809,7 +809,7 @@ async def export_json(user: User = Depends(current_user), db: AsyncSession = Dep
"exported_at": utcnow(),
"folders": [serialize(x, ["id", "name", "position", "deleted_at"]) for x in folders],
"lists": [serialize(x, ["id", "folder_id", "name", "is_inbox", "position", "deleted_at"]) for x in lists],
"tasks": [serialize(x, ["id", "list_id", "parent_id", "title", "description", "priority", "completed", "due_at", "external_id", "deleted_at"]) for x in tasks],
"tasks": [serialize(x, ["id", "list_id", "parent_id", "title", "description", "priority", "completed", "due_at", "due_has_time", "external_id", "deleted_at"]) for x in tasks],
"recurrences": [serialize(x, ["id", "task_id", "rrule", "starts_at", "ends_at"]) for x in recurrences],
"habits": [serialize(x, ["id", "name", "kind", "target", "max_value", "schedule_type", "weekdays", "month_days", "interval_days", "start_date", "archived_at", "position"]) for x in habits],
"countdowns": [serialize(x, ["id", "title", "event_date", "calendar_mode", "lunar_month", "lunar_day", "ignore_year", "kind", "repeat_rule", "icon", "pinned", "archived_at", "created_at", "updated_at"]) for x in countdowns],
@@ -868,6 +868,7 @@ async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merg
priority=raw.get("priority", 0),
completed=raw.get("completed", False),
due_at=datetime.fromisoformat(raw["due_at"]) if raw.get("due_at") else None,
due_has_time=raw.get("due_has_time", True),
external_id=ext,
)
db.add(row)
+4 -1
View File
@@ -76,6 +76,7 @@ class TaskCreate(BaseModel):
description: str = ""
priority: int = Field(default=0, ge=0, le=3)
due_at: datetime | None = None
due_has_time: bool = False
parent_id: UUID | None = None
rrule: str | None = Field(default=None, min_length=5, max_length=1000)
@@ -85,13 +86,14 @@ class TaskUpdate(BaseModel):
description: str | None = None
priority: int | None = Field(default=None, ge=0, le=3)
due_at: datetime | None = None
due_has_time: bool | None = None
completed: bool | None = None
list_id: UUID | None = None
version: int = Field(ge=1)
@model_validator(mode="after")
def reject_null_non_nullable_fields(self):
for field in ("title", "description", "priority", "completed", "list_id"):
for field in ("title", "description", "priority", "completed", "list_id", "due_has_time"):
if field in self.model_fields_set and getattr(self, field) is None:
raise ValueError(f"{field} cannot be null")
return self
@@ -117,6 +119,7 @@ class TaskOut(BaseModel):
priority: int
completed: bool
due_at: datetime | None
due_has_time: bool
version: int
+20 -6
View File
@@ -14,7 +14,7 @@ import FloatingAddButton from './components/FloatingAddButton.vue'
type FolderItem = { id: string; name: string }
type TaskList = { id: string; folder_id: string | null; name: string; is_inbox: boolean }
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; 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; due_has_time: boolean; subtasks?: Task[] }
type RepeatOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom'
type Recurrence = { id: string; task_id: string; rrule: string }
type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'countdowns' | 'settings'
@@ -59,6 +59,8 @@ const taskComposeOpen = ref(false)
const composeTitle = ref('')
const composeListId = ref('')
const composeDueAt = ref('')
const composeHasTime = ref(false)
const composeTime = ref('12:00')
const composePriority = ref(0)
const composeDescription = ref('')
const composeRepeat = ref<RepeatOption>('none')
@@ -81,6 +83,8 @@ function openTaskCompose() {
composeTitle.value = ''
composeListId.value = activeView.value === 'tasks' && activeList.value ? activeList.value : inboxId
composeDueAt.value = defaultTaskDueAt()
composeHasTime.value = false
composeTime.value = '12:00'
composePriority.value = 0
composeDescription.value = ''
composeRepeat.value = 'none'
@@ -147,10 +151,12 @@ async function submitTaskCompose() {
if (!taskTitle || !composeListId.value) return
try {
const rrule = composeRepeat.value === 'none' ? null : repeatRrule(composeRepeat.value, composeRepeatConfig.value)
const dueValue = composeDueAt.value ? `${composeDueAt.value}T${composeHasTime.value ? composeTime.value : '23:59'}` : ''
const task = await api('/tasks', { method: 'POST', body: JSON.stringify({
title: taskTitle,
list_id: composeListId.value,
due_at: fromDateTimeLocal(composeDueAt.value),
due_at: fromDateTimeLocal(dueValue),
due_has_time: composeHasTime.value,
priority: composePriority.value,
description: composeDescription.value,
rrule,
@@ -627,7 +633,13 @@ async function restoreList(item: TaskList) {
try { await api(`/lists/${item.id}/restore`, { method: 'POST' }); await loadArchivedLists(); await refreshAll(); toast('清单已恢复') } catch (reason) { fail(reason) }
}
function toggleFolder(id: string) { const next = new Set(expandedFolders.value); next.has(id) ? next.delete(id) : next.add(id); expandedFolders.value = next }
function formatDue(value: string | null) { return value ? new Intl.DateTimeFormat('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }).format(new Date(value)) : '' }
function formatDue(value: string | null, hasTime = true) {
if (!value) return ''
const date = new Date(value)
return hasTime
? new Intl.DateTimeFormat('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }).format(date)
: new Intl.DateTimeFormat('zh-CN', { month: 'short', day: 'numeric' }).format(date)
}
function previousPage() {
if (page.value <= 1 || loading.value) return
page.value -= 1
@@ -701,7 +713,7 @@ onMounted(bootstrap)
<h3 class="section-heading overdue-heading"><CalendarDays/>已过期 <span>{{overdueTaskTree.length}}</span></h3>
<div class="task-list overdue-list">
<template v-for="node in overdueTaskTree" :key="`overdue-${node.task.id}`">
<article class="task-row overdue-task"><button class="task-check" :aria-label="`完成${node.task.title}`" :aria-pressed="false" @click.stop="toggle(node.task)"><span class="task-check-mark" :class="`p${node.task.priority}`"></span></button><div class="task-main" role="button" tabindex="0" @click="selectTask(node.task)" @keydown.enter="selectTask(node.task)"><strong>{{node.task.title}}</strong><span class="meta"><span><CalendarDays/>{{formatDue(node.task.due_at)}}</span></span></div><span class="overdue-badge">已过期</span></article>
<article class="task-row overdue-task"><button class="task-check" :aria-label="`完成${node.task.title}`" :aria-pressed="false" @click.stop="toggle(node.task)"><span class="task-check-mark" :class="`p${node.task.priority}`"></span></button><div class="task-main" role="button" tabindex="0" @click="selectTask(node.task)" @keydown.enter="selectTask(node.task)"><strong>{{node.task.title}}</strong><span class="meta"><span><CalendarDays/>{{formatDue(node.task.due_at,node.task.due_has_time)}}</span></span></div><span class="overdue-badge">已过期</span></article>
</template>
</div>
</section>
@@ -714,7 +726,7 @@ onMounted(bootstrap)
<article :data-task-id="node.task.id" class="task-row swipeable" :class="{done:node.task.completed,selected:selectedTask?.id===node.task.id,ready:Math.abs(taskSwipeOffsets[node.task.id] ?? 0) >= 64,reordering:taskReorder?.id===node.task.id,'reorder-target':taskReorderTarget===node.task.id}" :style="{ '--swipe-x': `${taskSwipeOffsets[node.task.id] ?? 0}px`, '--reorder-y': `${taskReorder?.id === node.task.id ? taskReorder.offsetY : 0}px` }" @pointerdown="startTaskPointer(node.task, $event)" @pointermove="moveTaskPointer(node.task, $event)" @pointerup="finishTaskPointer(node.task, $event)" @pointercancel="cancelTaskPointer(node.task)" @touchstart.passive="startTaskSwipe(node.task, $event)" @touchmove.passive="moveTaskSwipe(node.task, $event)" @touchend="finishTaskSwipe(node.task, $event)" @touchcancel="cancelTaskSwipe(node.task)">
<button v-if="activeView!=='trash'" class="drag-handle task-drag-handle" :disabled="Boolean(query) || totalPages > 1" aria-label="上下拖动任务排序" title="上下拖动排序" @pointerdown.stop="startTaskReorder(node.task, $event)" @pointermove.stop="moveTaskReorder(node.task, $event)" @pointerup.stop="finishTaskReorder(node.task, $event)" @pointercancel.stop="cancelTaskReorder"><GripVertical/></button>
<button v-if="activeView!=='trash'" class="task-check" :aria-label="node.task.completed ? `重新打开${node.task.title}` : `完成${node.task.title}`" :aria-pressed="node.task.completed" @click.stop="toggle(node.task)"><span class="task-check-mark" :class="`p${node.task.priority}`"><Check v-if="node.task.completed" /></span></button>
<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></span></div>
<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,node.task.due_has_time)}}</span><span v-if="node.subtasks.length"><ListChecks/>{{node.subtasks.filter(t=>t.completed).length}}/{{node.subtasks.length}}</span></span></div>
<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-else class="icon ghost" aria-label="删除任务" @click.stop="removeTask(node.task)"><Trash2/></button>
@@ -759,7 +771,9 @@ onMounted(bootstrap)
<header><div><small>NEW TASK</small><h2 id="task-compose-title">{{ taskComposeTitle }}</h2></div><button class="icon" type="button" aria-label="关闭添加任务" @click="closeTaskCompose"><X/></button></header>
<label>任务名称<input v-model="composeTitle" class="task-compose-input" placeholder="准备做点什么?" autocomplete="off"></label>
<div class="task-compose-row"><label>清单<select v-model="composeListId"><option v-for="list in lists" :key="list.id" :value="list.id">{{list.name}}</option></select></label><label>优先级<select v-model.number="composePriority"><option :value="0">无</option><option :value="1">低</option><option :value="2">中</option><option :value="3"></option></select></label></div>
<label>截止时间<input v-model="composeDueAt" type="datetime-local"></label>
<label>截止日期<input v-model="composeDueAt" type="date"></label>
<label class="time-toggle"><input v-model="composeHasTime" type="checkbox"> 添加时间</label>
<label v-if="composeHasTime">时间<input v-model="composeTime" type="time"></label>
<label>重复<select v-model="composeRepeat" :disabled="!composeDueAt"><option value="none">不重复</option><option value="daily">每天</option><option value="weekly">每周</option><option value="monthly">每月</option><option value="yearly">每年</option><option value="custom">自定义…</option></select><small v-if="!composeDueAt" class="field-hint">设置截止时间后可重复</small></label>
<section v-if="composeRepeat==='custom'" class="repeat-custom-fields"><div><span>每隔</span><input v-model.number="composeRepeatConfig.interval" type="number" min="1"><select v-model="composeRepeatConfig.frequency"><option value="daily">天</option><option value="weekly">周</option><option value="monthly">月</option><option value="yearly">年</option></select></div><label v-if="composeRepeatConfig.frequency==='weekly'">重复日期<span class="weekday-picker"><label v-for="day in weekdayOptions" :key="day.value"><input v-model="composeRepeatConfig.weekdays" type="checkbox" :value="day.value">{{day.label}}</label></span></label><label v-if="composeRepeatConfig.frequency==='monthly'">每月日期<input v-model.number="composeRepeatConfig.monthDays![0]" type="number" min="1" max="31"></label><label>结束方式<select v-model="composeRepeatConfig.endMode"><option value="never">永不结束</option><option value="date">指定日期</option><option value="count">重复次数</option></select></label><label v-if="composeRepeatConfig.endMode==='date'">结束日期<input v-model="composeRepeatConfig.until" type="date"></label><label v-if="composeRepeatConfig.endMode==='count'">重复次数<input v-model.number="composeRepeatConfig.count" type="number" min="1"></label></section>
<label>备注<textarea v-model="composeDescription" rows="3" placeholder="可选,支持 Markdown"/></label>
+2 -2
View File
@@ -63,9 +63,9 @@ describe('task utilities', () => {
expect(filterTasks(searchable, '工作').map((task) => task.id)).toEqual(['4'])
})
it('defaults new tasks to today at noon in local time', () => {
it('defaults new tasks to today without a time', () => {
const due = defaultTaskDueAt(new Date(2026, 8, 8, 21, 30))
expect(due).toBe('2026-09-08T12:00')
expect(due).toBe('2026-09-08')
})
it('formats API dates in local wall-clock time', () => {
+1 -1
View File
@@ -148,7 +148,7 @@ export function defaultTaskDueAt(now = new Date()) {
const year = now.getFullYear()
const month = `${now.getMonth() + 1}`.padStart(2, '0')
const day = `${now.getDate()}`.padStart(2, '0')
return `${year}-${month}-${day}T12:00`
return `${year}-${month}-${day}`
}
export function toDateTimeLocal(value: string | null | undefined) {
+3 -1
View File
@@ -185,7 +185,9 @@ describe('mobile touch targets', () => {
describe('unified floating add interaction', () => {
it('defaults every new-task composer to today', () => {
expect(app).toContain('composeDueAt.value = defaultTaskDueAt()')
expect(app).not.toContain("activeView.value === 'today' ? toDateTimeLocal(quickTaskFields")
expect(app).toContain('v-model="composeDueAt" type="date"')
expect(app).toContain('v-model="composeHasTime" type="checkbox"')
expect(app).toContain("composeHasTime.value ? composeTime.value : '23:59'")
})
it('lets task create and detail forms configure a repeat rule', () => {
@@ -0,0 +1,23 @@
"""add task due time flag
Revision ID: 0013_task_due_has_time
Revises: 0012_habit_position
"""
import sqlalchemy as sa
from alembic import op
revision = "0013_task_due_has_time"
down_revision = "0012_habit_position"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("tasks") as batch_op:
batch_op.add_column(sa.Column("due_has_time", sa.Boolean(), nullable=False, server_default=sa.false()))
def downgrade() -> None:
with op.batch_alter_table("tasks") as batch_op:
batch_op.drop_column("due_has_time")
+12
View File
@@ -29,6 +29,18 @@ def test_bootstrap_returns_navigation_and_current_user(client):
def test_task_due_date_without_time_round_trips(client):
inbox = boot(client)
created = client.post(
"/api/v1/tasks",
json={"title": "全天任务", "list_id": inbox["id"], "due_at": "2026-09-08T23:59:00Z", "due_has_time": False},
)
assert created.status_code == 201
assert created.json()["due_has_time"] is False
listed = client.get("/api/v1/tasks", params={"list_id": inbox["id"]}).json()["items"]
assert listed[0]["due_has_time"] is False
def test_creating_task_with_recurrence_is_atomic(client):
inbox = boot(client)
created = client.post(