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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
- 📐 任务较少或为空时,列表会在滚动条位置保留等宽 viewport gutter,使卡片左右外边距继续对称;滚动条出现后仍保持 8px 紧凑样式且不裁切卡片。
- 🪄 任务卡片在窄窗口下会从计时文本末尾省略并保留状态前缀,省略时可悬停查看完整内容;多卡片之间保留真实 8px 间距,深浅主题下的优先级与编辑/删除浮层也采用更克制、清晰的交互配色。
- ↕️ 待办列表使用 8px 紧凑滚动条;默认窄窗口下,滚动条与其右侧空白之和等于左侧留白,并随深浅主题切换颜色。
- 🔔 多个任务同时到期时会汇总到一个软件内提醒窗口,可选择后批量完成或推迟,不再层层弹出;应用不会创建 Windows 系统通知,最小化或关闭到托盘时也不会显示系统气泡。
- 🔔 多个任务同时到期时会汇总到一个软件内提醒窗口;每条任务只在自己的行内提供“完成”“推迟”“忽略”,没有复选框或底部批量操作。“忽略”会清除该任务的截止时间并保留任务,不会把任务标记为完成或删除;应用不会创建 Windows 系统通知,最小化或关闭到托盘时也不会显示系统气泡。
- ⏱️ 新增任务的默认截止时间严格取当前时间一小时后,日期会自然跨到次日,并按界面可见分钟保存。
- 🕒 截止时间改为内联日期下拉与滚轮时间控件;常见任务可直接滚轮调时,跨日期时才展开日历,无需独立确认窗口。
- ⏳ 任务延后后只编辑内容或优先级会保留新的截止时间与提醒状态;只有实际修改时间或提醒选项才会重置调度。
Expand Down
6 changes: 4 additions & 2 deletions anchor.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
- `feature` → 提升次版本号。
- `bugfix` → 提升修订号。
- 仅文档与注释变更默认不触发版本号递增,除非影响发布说明或行为约定。
- 当前约定版本:`v2.0.0`。
- 当前约定版本:`v2.1.0`。

## 数据约束
- 所有待办保存在项目根目录下的 `todos.json`,结构为列表,元素为字典;打包版运行时会改存至用户数据目录(Windows `%APPDATA%\TODOList`,其他平台 `~/.todolist/`)。
Expand Down Expand Up @@ -67,8 +67,9 @@
- 相邻任务卡片的可见外边界固定保留 8px 透明列表间距,item 高度必须与当前卡片动态高度一致且不得小于卡片最小高度;卡片、边框、计时文字和优先级标识按主题形成轻量层次,操作浮层使用不透明主题背景遮住底层计时,编辑/删除按钮默认保持中性,仅在 hover、focus 或 pressed 时分别强化主题强调与危险语义。
- 列表纵向滚动条固定为 8px 紧凑宽度,轨道透明、滑块跟随主题配色;窗口左侧外边距等于“滚动条宽度 + 滚动条右侧外边距”,当前参数为 `15px = 8px + 7px`。滚动条隐藏时,列表 viewport 在同一边界保留 8px gutter;滚动条出现时释放 gutter 给真实滚动条,使可见卡片左右外边界到主内容边界的留白始终对称,取整误差不超过 1px。仅列表向右延伸,顶部筛选和标题行仍保持 15px 右外边距;状态切换不得残留旧几何、触发横向滚动条或造成卡片裁切。
- 已完成任务只通过勾选状态、线框及配色区分,编辑按钮始终可用,由主窗口逻辑负责根据任务 ID 处理编辑请求。
- 提醒流程:`master_timer` 每秒触发 `tick_update` 扫描完整 `self.todos`,提醒不受当前列表筛选影响,可见卡片循环只刷新倒计时;一轮请求先写入提醒去重字段,再汇总到任意时刻唯一的非模态软件内 `NotificationDialog`,同一任务按 ID 去重且“已到期”覆盖“提前提醒”。同批任务只播放一次 `play_sound_effect` 软件提醒音,窗口打开期间的新批次追加到原窗口,不创建 Windows 系统任务通知、Toast 或任务到期托盘气泡。提醒唤醒时优先调用原生接口恢复并前置主窗口,若平台不支持则临时添加 `WindowStaysOnTopHint` 保障可见,之后自动回退。批量完成或推迟只影响选中任务,并各执行一次存储与列表刷新;主窗口隐藏到托盘时同步隐藏提醒窗口但保留批次,恢复主窗口时重新显示同一批次,明确忽略或真正退出后才释放 Qt 对象与主题信号连接。
- 提醒流程:`master_timer` 每秒触发 `tick_update` 扫描完整 `self.todos`,提醒不受当前列表筛选影响,可见卡片循环只刷新倒计时;一轮请求先写入提醒去重字段,再汇总到任意时刻唯一的非模态软件内 `NotificationDialog`,同一任务按 ID 去重且“已到期”覆盖“提前提醒”。同批任务只播放一次 `play_sound_effect` 软件提醒音,窗口打开期间的新批次追加到原窗口,不创建 Windows 系统任务通知、Toast 或任务到期托盘气泡。提醒唤醒时优先调用原生接口恢复并前置主窗口,若平台不支持则临时添加 `WindowStaysOnTopHint` 保障可见,之后自动回退。通知窗口不提供复选框或底部批量操作;每条任务只通过自己的行内“完成”“推迟”“忽略”处置,其中推迟独立选择时长,“忽略”清除时间约束。每次处置由主窗口统一持久化并刷新列表;主窗口隐藏到托盘时同步隐藏提醒窗口但保留批次,恢复主窗口时重新显示同一批次,任务全部处理、用户主动关闭提醒窗口或真正退出后释放 Qt 对象与主题信号连接。
- 推迟流程:推迟会同步更新 `snoozeUntil` 与可编辑的 `dueDate`;若原截止时间已早于推迟目标,默认截止时间自动推进到推迟目标。编辑保存按同一时刻而非 ISO 字符串判断截止时间是否变化,普通内容与优先级修改保留延后的新时间及提醒状态,只有实际修改时间或提醒偏移时才清理旧调度状态。
- 忽略语义:通知中的“忽略”表示保留任务但清除其时间约束;主窗口将 `dueDate` 与 `snoozeUntil` 置为 `None`,将 `notifiedForReminder` 与 `notifiedForDue` 重置为 `False`,保留 `reminderOffset`、`completed` 与 `lastNotifiedAt`。无截止时间时任务不显示超时且不会触发提醒;以后重新设置截止时间时继续使用原提醒偏好。本语义不提供撤销或历史恢复。
- 截止时间编辑:新增任务的默认截止时间沿绝对时间线取本地当前时间一小时后,日期与时间来自同一目标时刻并按可见分钟保存;未改默认日期与分钟时保留该目标的 UTC 实例,避免夏令时重复小时丢失 offset。时间使用支持滚轮和上下键微调的 `QTimeEdit`,日期使用低频内联 `QDateEdit` 日历下拉。选择日期直接应用,不再创建独立日期确认窗口。编辑已有任务时,未改日期与分钟则保留原截止时间的完整精度,实际调整后秒与毫秒归零。
- 托盘行为:系统托盘菜单与最小化逻辑集中在 `main_window.py::_create_tray_icon`,调整时注意多平台兼容;点击窗口最小化按钮会直接隐藏到托盘且不显示系统气泡,提醒触发时会自动还原窗口并置顶。

Expand All @@ -78,6 +79,7 @@
- 若确认无变更,提交说明需写明“锚点已复盘,无需更新”。

## 最近约定变更
- 2026-08-07:feature,到期通知只保留行内逐项“完成”“推迟”“忽略”,移除复选框与底部批量操作;“忽略”保留任务与提醒偏好但清除时间约束,版本更新至 `v2.1.0`。
- 2026-07-31:refactor,将任务卡片宽高、区域挤压优先级与详情浮层尺寸/位置收敛为不依赖 Qt 事件循环的纯函数布局模型,用户可见行为与像素结果不变;按 refactor 版本规则更新至 `v2.0.0`。
- 2026-07-31:bugfix,待办数据改为同目录临时文件 `fsync` 后原子替换,覆盖前保留单份有效备份;主文件损坏时只读降级且拒绝后续静默覆盖,版本更新至 `v1.8.1`。
- 2026-07-30:refactor(流程维护),为 pull request 与 `main` 建立 Python 3.11 / Qt offscreen 自动化测试门禁,精确锁定 PySide6 与 PyInstaller,并让 Windows 打包从依赖文件安装(应用行为不变,不触发版本号)。
Expand Down
186 changes: 168 additions & 18 deletions tests/test_notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,13 @@

from PySide6.QtCore import QEvent # noqa: E402
from PySide6.QtGui import QCloseEvent # noqa: E402
from PySide6.QtWidgets import QApplication, QCheckBox, QDialog, QLabel # noqa: E402
from PySide6.QtWidgets import ( # noqa: E402
QApplication,
QCheckBox,
QDialog,
QLabel,
QPushButton,
)

from todo_app.dialogs import NotificationDialog # noqa: E402
from todo_app.main_window import ModernTodoAppWindow # noqa: E402
Expand Down Expand Up @@ -54,25 +60,19 @@ def test_tasks_are_aggregated_deduplicated_and_upgraded(self) -> None:
self.assertIsNotNone(status_label)
self.assertEqual(status_label.text(), "已到期")

def test_actions_emit_only_selected_tasks_and_keep_remaining_rows(self) -> None:
def test_dialog_has_only_inline_actions_and_keeps_remaining_rows(self) -> None:
dialog = NotificationDialog(
[(make_todo(1, "第一项"), True), (make_todo(2, "第二项"), True)]
)
destroyed = []
dialog.destroyed.connect(lambda: destroyed.append(True))
second_checkbox = dialog.findChild(QCheckBox, "notificationSelect_2")
self.assertIsNotNone(second_checkbox)
second_checkbox.setChecked(False)
completed: list[list[int]] = []
snoozed: list[tuple[list[int], timedelta]] = []
dialog.complete_requested.connect(completed.append)
dialog.snooze_requested.connect(lambda ids, duration: snoozed.append((ids, duration)))

dialog.complete_button.click()
dialog.snooze_default()

self.assertEqual(completed, [[1]])
self.assertEqual(snoozed, [([1], timedelta(minutes=15))])
self.assertEqual(dialog.layout().count(), 2)
self.assertEqual(dialog.findChildren(QCheckBox), [])
self.assertFalse(hasattr(dialog, "complete_button"))
self.assertFalse(hasattr(dialog, "snooze_default_button"))
self.assertFalse(hasattr(dialog, "snooze_menu_button"))
self.assertFalse(hasattr(dialog, "ignore_button"))
self.assertIsNone(dialog.findChild(QPushButton, "batchIgnoreButton"))

dialog.show()
self.app.processEvents()
Expand All @@ -84,7 +84,84 @@ def test_actions_emit_only_selected_tasks_and_keep_remaining_rows(self) -> None:
self.app.processEvents()
self.assertEqual(destroyed, [True])

def test_common_three_task_batch_expands_before_scrolling(self) -> None:
def test_inline_actions_target_only_their_own_task(self) -> None:
dialog = NotificationDialog(
[(make_todo(1, "第一项"), True), (make_todo(2, "第二项"), True)]
)
self.addCleanup(dialog.close)
completed: list[list[int]] = []
snoozed: list[tuple[list[int], timedelta]] = []
ignored: list[list[int]] = []
dialog.complete_requested.connect(completed.append)
dialog.snooze_requested.connect(lambda ids, duration: snoozed.append((ids, duration)))
dialog.ignore_requested.connect(ignored.append)

complete_first = dialog.findChild(QPushButton, "notificationComplete_1")
snooze_second = dialog.findChild(QPushButton, "notificationSnooze_2")
ignore_first = dialog.findChild(QPushButton, "notificationIgnore_1")
self.assertIsNotNone(complete_first)
self.assertIsNotNone(snooze_second)
self.assertIsNotNone(ignore_first)
self.assertEqual(ignore_first.text(), "忽略")
self.assertIn("清除截止时间", ignore_first.toolTip())
self.assertEqual(
[action.text() for action in snooze_second.menu().actions()],
["15分钟后", "1小时后", "晚上8点", "明天上午9点"],
)

complete_first.click()
one_hour_action = next(
action for action in snooze_second.menu().actions() if action.text() == "1小时后"
)
one_hour_action.trigger()
ignore_first.click()

self.assertEqual(completed, [[1]])
self.assertEqual(snoozed, [([2], timedelta(hours=1))])
self.assertEqual(ignored, [[1]])

def test_due_text_includes_absolute_and_relative_time(self) -> None:
todo = make_todo(1, "相对时间")
todo["dueDate"] = "2026-08-06T10:00:00+00:00"
dialog = NotificationDialog([(todo, False)])
self.addCleanup(dialog.close)

before_due = dialog._format_due_text(
todo,
datetime(2026, 8, 6, 9, 55, tzinfo=timezone.utc),
)
after_due = dialog._format_due_text(
todo,
datetime(2026, 8, 6, 10, 12, tzinfo=timezone.utc),
)

self.assertIn("截止时间:", before_due)
self.assertIn("还有 5分", before_due)
self.assertIn("已超时 12分", after_due)

def test_relative_time_timer_refreshes_all_open_rows(self) -> None:
dialog = NotificationDialog(
[(make_todo(1, "第一项"), True), (make_todo(2, "第二项"), False)]
)
self.addCleanup(dialog.close)
self.assertTrue(dialog._relative_time_timer.isActive())
self.assertEqual(dialog._relative_time_timer.interval(), 1000)

with patch.object(
dialog,
"_format_due_text",
side_effect=["第一项已刷新", "第二项已刷新"],
) as formatter:
dialog._relative_time_timer.timeout.emit()

self.assertEqual(dialog._task_rows[1]["detail_label"].text(), "第一项已刷新")
self.assertEqual(dialog._task_rows[2]["detail_label"].text(), "第二项已刷新")
self.assertEqual(formatter.call_count, 2)
first_now = formatter.call_args_list[0].args[1]
second_now = formatter.call_args_list[1].args[1]
self.assertIs(first_now, second_now)

def test_common_three_task_dialog_expands_before_scrolling(self) -> None:
dialog = NotificationDialog(
[(make_todo(todo_id, f"任务{todo_id}"), True) for todo_id in (1, 2, 3)]
)
Expand Down Expand Up @@ -117,6 +194,7 @@ def __init__(self, requests, parent=None) -> None:
self.visible = False
self.complete_requested = FakeSignal()
self.snooze_requested = FakeSignal()
self.ignore_requested = FakeSignal()
self.finished = FakeSignal()
if isinstance(requests, list):
self.add_or_update_tasks(requests)
Expand Down Expand Up @@ -333,7 +411,7 @@ def test_tray_menu_hides_and_restores_notification_batch(self) -> None:
self.assertEqual(dialog.show_count, 2)
self.assertTrue(dialog.visible)

def test_batch_actions_update_only_requested_tasks_once(self) -> None:
def test_requested_actions_update_only_requested_tasks_once(self) -> None:
tasks = [make_todo(todo_id, f"任务{todo_id}") for todo_id in (1, 2, 3)]
FakeNotificationDialog.instances = []
with (
Expand Down Expand Up @@ -361,7 +439,7 @@ def test_batch_actions_update_only_requested_tasks_once(self) -> None:
window.toggle_complete_todo(3)
self.assertEqual(dialog.task_ids(), [])

def test_batch_snooze_keeps_unselected_tasks_unchanged(self) -> None:
def test_snooze_handler_keeps_other_tasks_unchanged(self) -> None:
first = make_todo(1, "任务1")
second = make_todo(2, "任务2")
first_before = first.copy()
Expand All @@ -387,6 +465,78 @@ def test_batch_snooze_keeps_unselected_tasks_unchanged(self) -> None:
self.assertEqual(save_mock.call_count, 1)
self.assertEqual(window.update_list_widget.call_count, 1)

def test_tasks_can_be_snoozed_independently_with_different_durations(self) -> None:
first = make_todo(1, "任务1")
second = make_todo(2, "任务2")
with (
patch("todo_app.main_window.load_todos", return_value=[]),
patch("todo_app.main_window.save_todos") as save_mock,
patch("todo_app.main_window.NotificationDialog", FakeNotificationDialog),
):
window = ModernTodoAppWindow()
window.master_timer.stop()
self.addCleanup(self._close_window, window)
window.todos = [first, second]
window.update_list_widget = MagicMock()
dialog = FakeNotificationDialog([(first, True), (second, True)], window)
window._notification_dialog = dialog

window._handle_notification_snooze([1], timedelta(minutes=15))
first_after_snooze = first.copy()
window._handle_notification_snooze([2], timedelta(hours=1))

self.assertEqual(first, first_after_snooze)
first_until = datetime.fromisoformat(first["snoozeUntil"])
second_until = datetime.fromisoformat(second["snoozeUntil"])
self.assertGreater(second_until - first_until, timedelta(minutes=44))
self.assertEqual(dialog.task_ids(), [])
self.assertEqual(save_mock.call_count, 2)
self.assertEqual(window.update_list_widget.call_count, 2)

def test_ignore_clears_schedule_but_preserves_task_and_preference(self) -> None:
task = make_todo(1, "保留但清除时间")
task.update(
{
"reminderOffset": 900,
"snoozeUntil": "2026-08-06T12:00:00+00:00",
"notifiedForReminder": True,
"notifiedForDue": True,
"lastNotifiedAt": "2026-08-06T10:00:00+00:00",
}
)
with (
patch("todo_app.main_window.load_todos", return_value=[]),
patch("todo_app.main_window.save_todos") as save_mock,
patch("todo_app.main_window.NotificationDialog", FakeNotificationDialog),
):
window = ModernTodoAppWindow()
window.master_timer.stop()
self.addCleanup(self._close_window, window)
window.todos = [task]
window.update_list_widget = MagicMock()
window._show_notification_batch = MagicMock()
dialog = FakeNotificationDialog([(task, True)], window)
window._notification_dialog = dialog

window._handle_notification_ignore([1])

self.assertIsNone(task["dueDate"])
self.assertIsNone(task["snoozeUntil"])
self.assertFalse(task["notifiedForReminder"])
self.assertFalse(task["notifiedForDue"])
self.assertEqual(task["reminderOffset"], 900)
self.assertFalse(task["completed"])
self.assertEqual(task["lastNotifiedAt"], "2026-08-06T10:00:00+00:00")
self.assertEqual(window.todos, [task])
self.assertEqual(dialog.task_ids(), [])
self.assertEqual(save_mock.call_count, 1)
self.assertEqual(window.update_list_widget.call_count, 1)

window.tick_update()

window._show_notification_batch.assert_not_called()
self.assertEqual(save_mock.call_count, 1)

def _close_window(self, window: ModernTodoAppWindow) -> None:
window.master_timer.stop()
window._quitting_app = True
Expand Down
2 changes: 1 addition & 1 deletion todo_app/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ class ThemeColors:

# --- 基本信息 ---
APP_NAME = "桌面待办事项 v1"
APP_VERSION = "2.0.0"
APP_VERSION = "2.1.0"

# --- 文件资源 ---
APP_ICON_PATH = "assets/icons/app_icon.svg"
Expand Down
Loading
Loading