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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

一个基于 PySide6 的轻量桌面待办工具,提供任务管理、截止时间、提醒与推迟、系统托盘、深浅色主题和本地数据保护。

当前版本为 **v2.1.2**,版本号的唯一来源是 `todo_app/constants.py` 中的 `APP_VERSION`。
当前版本为 **v2.1.3**,版本号的唯一来源是 `todo_app/constants.py` 中的 `APP_VERSION`。

## 功能概览

Expand All @@ -16,6 +16,7 @@

## v2.x 近期变化

- **v2.1.3**:空闲秒级刷新只在卡片计时或完成状态实际变化时更新界面,避免重复图标、样式与列表布局工作。
- **v2.1.2**:通知行改用分裂式“推迟1h”按钮,主区域一键推迟,箭头保留其他时长选项。
- **v2.1.1**:拆分用户可见名称与稳定的 `QSettings` 命名空间,界面不再显示过期的 v1 标识,已有窗口几何和状态继续兼容。
- **v2.1.0**:到期通知收敛为逐项“完成”“推迟”“忽略”,明确忽略任务的时间约束语义。
Expand Down Expand Up @@ -124,7 +125,7 @@ macOS/Linux 使用 PyInstaller 时,需要将 `--add-data` 的分隔符从 `;`

- 手动触发、推送到 `main` 或推送 `v*` 标签时,在 Windows Runner 生成单文件 `TODOList.exe`。
- 所有构建都会上传名称包含来源和短提交 SHA、保留 7 天的 Actions Artifact。
- `main` 的最新构建会按 `APP_VERSION` 更新可移动的预发布标签,例如 `2.1.2` 对应 `pre2.1.2`,并更新不作为 Latest 的 Pre-release。
- `main` 的最新构建会按 `APP_VERSION` 更新可移动的预发布标签,例如 `2.1.3` 对应 `pre2.1.3`,并更新不作为 Latest 的 Pre-release。
- 手动构建只产生临时 Artifact,不修改标签或 Release。
- 只有发布者显式推送、保持不可变的 `v*` 标签才创建或更新正式 Release;同一标签的工作流重跑可以覆盖同名资产以恢复失败发布。

Expand Down
5 changes: 3 additions & 2 deletions anchor.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
- `feature` → 提升次版本号。
- `bugfix` → 提升修订号。
- 仅文档与注释变更默认不触发版本号递增,除非影响发布说明或行为约定。
- 当前约定版本:`v2.1.2`。
- 当前约定版本:`v2.1.3`。

## 数据约束
- 所有待办保存在项目根目录下的 `todos.json`,结构为列表,元素为字典;打包版运行时会改存至用户数据目录(Windows `%APPDATA%\TODOList`,其他平台 `~/.todolist/`)。
Expand Down Expand Up @@ -68,7 +68,7 @@
- 相邻任务卡片的可见外边界固定保留 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` 保障可见,之后自动回退。通知窗口不提供复选框或底部批量操作;每条任务只通过自己的行内“完成”“推迟1h”“忽略”处置,推迟按钮主区域一键推迟 1 小时,只有箭头区域展开 15 分钟、1 小时、晚上 8 点和次日上午 9 点选项,“忽略”清除时间约束。每次处置由主窗口统一持久化并刷新列表;主窗口隐藏到托盘时同步隐藏提醒窗口但保留批次,恢复主窗口时重新显示同一批次,任务全部处理、用户主动关闭提醒窗口或真正退出后释放 Qt 对象与主题信号连接。
- 提醒流程:`master_timer` 每秒触发 `tick_update` 扫描完整 `self.todos`,提醒不受当前列表筛选影响。卡片先计算最终计时呈现,并分别缓存完成状态与计时文本/样式;只有最终状态变化时才写入 Qt 控件并刷新卡片布局,空闲 Tick 不重复加载完成图标、设置字体/样式或触发列表级布局,新建卡片只执行一次完整计时呈现。一轮提醒请求先写入去重字段,再汇总到任意时刻唯一的非模态软件内 `NotificationDialog`,同一任务按 ID 去重且“已到期”覆盖“提前提醒”。同批任务只播放一次 `play_sound_effect` 软件提醒音,窗口打开期间的新批次追加到原窗口,不创建 Windows 系统任务通知、Toast 或任务到期托盘气泡。提醒唤醒时优先调用原生接口恢复并前置主窗口,若平台不支持则临时添加 `WindowStaysOnTopHint` 保障可见,之后自动回退。通知窗口不提供复选框或底部批量操作;每条任务只通过自己的行内“完成”“推迟1h”“忽略”处置,推迟按钮主区域一键推迟 1 小时,只有箭头区域展开 15 分钟、1 小时、晚上 8 点和次日上午 9 点选项,“忽略”清除时间约束。每次处置由主窗口统一持久化并刷新列表;主窗口隐藏到托盘时同步隐藏提醒窗口但保留批次,恢复主窗口时重新显示同一批次,任务全部处理、用户主动关闭提醒窗口或真正退出后释放 Qt 对象与主题信号连接。
- 推迟流程:推迟会同步更新 `snoozeUntil` 与可编辑的 `dueDate`;若原截止时间已早于推迟目标,默认截止时间自动推进到推迟目标。编辑保存按同一时刻而非 ISO 字符串判断截止时间是否变化,普通内容与优先级修改保留延后的新时间及提醒状态,只有实际修改时间或提醒偏移时才清理旧调度状态。
- 忽略语义:通知中的“忽略”表示保留任务但清除其时间约束;主窗口将 `dueDate` 与 `snoozeUntil` 置为 `None`,将 `notifiedForReminder` 与 `notifiedForDue` 重置为 `False`,保留 `reminderOffset`、`completed` 与 `lastNotifiedAt`。无截止时间时任务不显示超时且不会触发提醒;以后重新设置截止时间时继续使用原提醒偏好。本语义不提供撤销或历史恢复。
- 截止时间编辑:新增任务的默认截止时间沿绝对时间线取本地当前时间一小时后,日期与时间来自同一目标时刻并按可见分钟保存;未改默认日期与分钟时保留该目标的 UTC 实例,避免夏令时重复小时丢失 offset。时间使用支持滚轮和上下键微调的 `QTimeEdit`,日期使用低频内联 `QDateEdit` 日历下拉。选择日期直接应用,不再创建独立日期确认窗口。编辑已有任务时,未改日期与分钟则保留原截止时间的完整精度,实际调整后秒与毫秒归零。
Expand All @@ -80,6 +80,7 @@
- 若确认无变更,提交说明需写明“锚点已复盘,无需更新”。

## 最近约定变更
- 2026-08-13:bugfix,卡片缓存完成态与最终计时呈现,空闲 Tick 不再重复写入 Qt 控件或触发列表级布局,并将卡片初始化收敛为一次完整计时呈现,版本更新至 `v2.1.3`。
- 2026-08-12:bugfix,通知行改用分裂式“推迟1h”按钮,主区域一键执行默认时长,箭头保留其他推迟选项,版本更新至 `v2.1.2`。
- 2026-08-11:bugfix,拆分用户可见应用名与稳定的 `QSettings` 命名空间,移除窗口和托盘中过期的 v1 标识并无损保留已有窗口状态,版本更新至 `v2.1.1`。
- 2026-08-07:feature,到期通知只保留行内逐项“完成”“推迟”“忽略”,移除复选框与底部批量操作;“忽略”保留任务与提醒偏好但清除时间约束,版本更新至 `v2.1.0`。
Expand Down
2 changes: 1 addition & 1 deletion tests/test_app_identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ def setUpClass(cls) -> None:

def test_visible_identity_targets_v2_without_changing_settings_namespace(self) -> None:
self.assertEqual(APP_NAME, "桌面待办事项")
self.assertEqual(APP_VERSION, "2.1.2")
self.assertEqual(APP_VERSION, "2.1.3")
self.assertNotIn("v1", APP_NAME)
self.assertEqual(SETTINGS_ORGANIZATION, "MyProductiveApp")
self.assertEqual(SETTINGS_APPLICATION, "桌面待办事项 v1")
Expand Down
34 changes: 34 additions & 0 deletions tests/test_notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,40 @@ def test_filtered_out_incomplete_task_still_triggers_in_app_reminder(self) -> No
self.assertEqual(len(FakeNotificationDialog.instances), 1)
self.assertEqual(FakeNotificationDialog.instances[0].task_ids(), [1])

def test_idle_tick_with_100_tasks_skips_card_redraw_and_list_layout(self) -> None:
tasks = [make_todo(todo_id, f"空闲任务{todo_id}") for todo_id in range(100)]
for task in tasks:
task["dueDate"] = None

with (
patch("todo_app.main_window.load_todos", return_value=tasks),
patch("todo_app.main_window.save_todos") as save_mock,
):
window = ModernTodoAppWindow()
window.master_timer.stop()
self.addCleanup(self._close_window, window)
cards = [
window.list_widget.itemWidget(window.list_widget.item(index))
for index in range(window.list_widget.count())
]
self.assertEqual(len(cards), 100)
for card in cards:
card.update_text_display = MagicMock()
card._update_frame_background = MagicMock()
window._sync_todo_card_sizes = MagicMock()
window.update_list_widget = MagicMock()

with patch("todo_app.widgets.get_icon") as get_icon_mock:
window.tick_update()

save_mock.assert_not_called()
get_icon_mock.assert_not_called()
window._sync_todo_card_sizes.assert_not_called()
window.update_list_widget.assert_not_called()
for card in cards:
card.update_text_display.assert_not_called()
card._update_frame_background.assert_not_called()

def test_closed_notification_dialog_is_deleted_from_parent(self) -> None:
task = make_todo(1, "关闭后释放")
with (
Expand Down
102 changes: 102 additions & 0 deletions tests/test_widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,108 @@ def test_timer_status_full_text_is_preserved_for_supported_states(self) -> None:
self.assertEqual(widget.timer_display_label.text(), expected_text)
self.assertEqual(widget.timer_display_label.toolTip(), "")

def test_card_initialization_performs_one_full_timer_render(self) -> None:
class CountingTodoItemWidget(TodoItemWidget):
def update_timer_display(self, current_time_utc):
self.timer_render_count = getattr(self, "timer_render_count", 0) + 1
return super().update_timer_display(current_time_utc)

def update_text_display(self):
self.layout_refresh_count = getattr(
self,
"layout_refresh_count",
0,
) + 1
return super().update_text_display()

widget = CountingTodoItemWidget(
{
"id": 1,
"text": "初始化一次",
"priority": "中",
"completed": False,
"dueDate": None,
"snoozeUntil": None,
}
)
self.addCleanup(widget.close)

self.assertEqual(widget.timer_render_count, 1)
self.assertEqual(widget.layout_refresh_count, 1)
self.assertEqual(widget.timer_display_label.full_text, "无截止日期")

def test_unchanged_timer_state_skips_all_qt_presentation_writes(self) -> None:
now = datetime(2026, 8, 13, 12, 0, tzinfo=timezone.utc)
widget = TodoItemWidget(
{
"id": 1,
"text": "空闲任务",
"priority": "中",
"completed": False,
"dueDate": None,
"snoozeUntil": None,
}
)
self.addCleanup(widget.close)

with (
patch.object(widget.complete_button, "setChecked") as set_checked,
patch.object(widget.complete_button, "setIcon") as set_icon,
patch.object(widget, "_update_frame_background") as update_frame,
patch.object(widget.task_text_label, "setStyleSheet") as set_task_style,
patch.object(widget.task_text_label, "setFont") as set_task_font,
patch.object(widget.timer_display_label, "set_full_text") as set_timer_text,
patch.object(widget.timer_display_label, "setStyleSheet") as set_timer_style,
patch.object(widget.timer_display_label, "setFont") as set_timer_font,
patch.object(widget, "update_text_display") as refresh_layout,
):
changed = widget.update_timer_display(now)

self.assertFalse(changed)
for qt_write in (
set_checked,
set_icon,
update_frame,
set_task_style,
set_task_font,
set_timer_text,
set_timer_style,
set_timer_font,
refresh_layout,
):
qt_write.assert_not_called()

def test_completion_transition_renders_once_then_becomes_idle(self) -> None:
now = datetime(2026, 8, 13, 12, 0, tzinfo=timezone.utc)
widget = TodoItemWidget(
{
"id": 1,
"text": "完成状态切换",
"priority": "中",
"completed": False,
"dueDate": None,
"snoozeUntil": None,
}
)
self.addCleanup(widget.close)
widget.todo_item["completed"] = True

with (
patch.object(widget.complete_button, "setIcon") as set_icon,
patch.object(widget, "_update_frame_background") as update_frame,
patch.object(widget, "update_text_display") as refresh_layout,
):
first_changed = widget.update_timer_display(now)
second_changed = widget.update_timer_display(now + timedelta(seconds=1))

self.assertTrue(first_changed)
self.assertFalse(second_changed)
self.assertTrue(widget.complete_button.isChecked())
self.assertEqual(widget.timer_display_label.full_text, "已完成")
self.assertEqual(set_icon.call_count, 1)
self.assertEqual(update_frame.call_count, 1)
self.assertEqual(refresh_layout.call_count, 1)

def _assert_each_logical_line_fits(self, widget: TodoItemWidget) -> None:
label = widget.task_text_label
self.assertFalse(label.wordWrap())
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 = "桌面待办事项"
APP_VERSION = "2.1.2"
APP_VERSION = "2.1.3"

# QSettings 命名空间属于持久化兼容契约,不应随用户可见名称变化。
SETTINGS_ORGANIZATION = "MyProductiveApp"
Expand Down
4 changes: 0 additions & 4 deletions todo_app/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -543,8 +543,6 @@ def tick_update(self) -> None:
item_widget.todo_item.update(original_ref)
item_widget.update_timer_display(now_utc)

self._sync_todo_card_sizes()

if items_changed:
save_todos(self.todos)
self.update_list_widget()
Expand Down Expand Up @@ -942,7 +940,6 @@ def update_list_widget(self) -> None:
self._empty_placeholder_widget = None
self._empty_placeholder_label = None

current_time_utc = datetime.now(timezone.utc)
for todo_data in processed:
list_item = QListWidgetItem(self.list_widget)
item_widget = TodoItemWidget(todo_data.copy(), palette=self._palette)
Expand All @@ -954,7 +951,6 @@ def update_list_widget(self) -> None:
list_item.setSizeHint(QSize(0, item_height))
self.list_widget.addItem(list_item)
self.list_widget.setItemWidget(list_item, item_widget)
item_widget.update_timer_display(current_time_utc)

self._sync_todo_card_sizes()

Expand Down
Loading
Loading