Repository: zulip/zulip-terminal
Pull request #1549 (2024-10-04 04:32:33)

Add support for TODO Widget on ZT
<!-- See README for documentation, or ask in #zulip-terminal if unclear -->
### What does this PR do, and why?
Adds support for TODO Widget

### External discussion & connections
<!-- [x] all that apply, specifying topic and adding numbers after # for issues/PRs -->
- [x] Discussed in **#zulip-terminal** in `topic`
- [ ] Fully fixes #
- [x] Partially fixes issue #986 
- [ ] Builds upon previous unmerged work in PR #
- [ ] Is a follow-up to work in PR #
- [ ] Requires merge of PR #
- [ ] Merge will enable work on #

### How did you test this?
<!-- [x] all that apply -->
- [x] Manually - Behavioral changes
- [x] Manually - Visual changes
- [x] Adapting existing automated tests
- [x] Adding automated tests for new behavior (or missing tests)
- [ ] Existing automated tests should already cover this (*only a refactor of tested code*)

### Self-review checklist for each commit
- [x] It is a [minimal coherent idea](https://github.com/zulip/zulip-terminal#structuring-commits---speeding-up-reviews-merging--development)
- [x] It has a commit summary following the [documented style](https://github.com/zulip/zulip-terminal#structuring-commits---speeding-up-reviews-merging--development) (title & body)
- [x] It has a commit summary describing the  motivation and reasoning for the change
- [x] It individually passes linting and tests
- [x] It contains test additions for any new behavior
- [x] It flows clearly from a previous branch commit, and/or prepares for the next commit

### Visual changes    <!-- DELETE SECTION IF NO VISUAL CHANGE -->
| Before | After| Webapp |
|--------|--------|--------|
| ![image](https://github.com/user-attachments/assets/e42d0f26-262b-4f13-be2e-5a22a7a17844) | ![image](https://github.com/user-attachments/assets/b51f20de-f5f4-4d17-9847-f315519ac31a) | ![image](https://github.com/user-attachments/assets/ae9a6b97-a251-4236-b9b7-bf4f9d8d41c8) | 


neiljp: @rsashank We may need some followups on widget messages, such as limiting direct editing? I'm not sure what else may be necessary at this point.
rsashank: @neiljp  Thanks for the feedback! Updated this PR 👍 

> We may need some followups on widget messages, such as limiting direct editing? I'm not sure what else may be necessary at this point.

Isn't editing on widget messages already restricted? I could be mistaken, but I can't seem to edit.
neiljp: @rsashank Thanks for the update; this looks good to go now - the remaining elements can be fine-tuned later :tada:
neiljp: @rsashank You should be able to confirm using `git range-commit`, but I only updated the commit text slightly before pushing back here.

Patch:
diff --git a/docs/developer-file-overview.md b/docs/developer-file-overview.md
index ef21a7c..d488227 100644
--- a/docs/developer-file-overview.md
+++ b/docs/developer-file-overview.md
@@ -17,6 +17,7 @@ Zulip Terminal uses [Zulip's API](https://zulip.com/api/) to store and retrieve
 |                        | unicode_emojis.py   | Unicode emoji data, synchronized semi-regularly with the server source                  |
 |                        | urwid_types.py      | Types from the urwid API, to improve type checking                                      |
 |                        | version.py          | Keeps track of the version of the current code                                          |
+|                        | widget.py           | Process widgets (submessages) like polls, todo lists, etc.                              |
 |                        |                     |                                                                                         |
 | zulipterminal/cli      | run.py              | Marks the entry point into the application                                              |
 |                        |                     |                                                                                         |
diff --git a/zulipterminal/api_types.py b/zulipterminal/api_types.py
index 319dc47..b7474be 100644
--- a/zulipterminal/api_types.py
+++ b/zulipterminal/api_types.py
@@ -503,6 +503,15 @@ class RealmUserEvent(TypedDict):
     person: RealmUserEventPerson
 
 
+class SubmessageEvent(TypedDict):
+    type: Literal["submessage"]
+    msg_type: str
+    message_id: int
+    submessage_id: int
+    sender_id: int
+    content: str
+
+
 # -----------------------------------------------------------------------------
 # See https://zulip.com/api/get-events#subscription-update
 # (also -peer_add and -peer_remove; FIXME: -add & -remove are not yet supported)
@@ -619,6 +628,7 @@ Event = Union[
     UpdateMessageContentEvent,
     UpdateMessagesLocationEvent,
     ReactionEvent,
+    SubmessageEvent,
     SubscriptionUpdateEvent,
     SubscriptionPeerAddRemoveEvent,
     TypingEvent,
diff --git a/zulipterminal/model.py b/zulipterminal/model.py
index e506bce..b3840a5 100644
--- a/zulipterminal/model.py
+++ b/zulipterminal/model.py
@@ -152,6 +152,7 @@ class Model:
             "message": self._handle_message_event,
             "update_message": self._handle_update_message_event,
             "reaction": self._handle_reaction_event,
+            "submessage": self._handle_submessage_event,
             "subscription": self._handle_subscription_event,
             "typing": self._handle_typing_event,
             "update_message_flags": self._handle_update_message_flags_event,
@@ -1860,6 +1861,27 @@ class Model:
             self.index["messages"][message_id] = message
             self._update_rendered_view(message_id)
 
+    def _handle_submessage_event(self, event: Event) -> None:
+        """
+        Handle change to submessages on a message (todo, poll etc.)
+        """
+        assert event["type"] == "submessage"
+        message_id = event["message_id"]
+        if message_id in self.index["messages"]:
+            message = self.index["messages"][message_id]
+            message["submessages"].append(
+                {
+                    "type": event["type"],
+                    "msg_type": event["msg_type"],
+                    "message_id": event["message_id"],
+                    "submessage_id": event["submessage_id"],
+                    "sender_id": event["sender_id"],
+                    "content": event["content"],
+                }
+            )
+            self.index["messages"][message_id] = message
+            self._update_rendered_view(message_id)
+
     def _handle_update_message_flags_event(self, event: Event) -> None:
         """
         Handle change to message flags (eg. starred, read)
diff --git a/zulipterminal/ui_tools/messages.py b/zulipterminal/ui_tools/messages.py
index b8552fd..2b67ae6 100644
--- a/zulipterminal/ui_tools/messages.py
+++ b/zulipterminal/ui_tools/messages.py
@@ -33,6 +33,7 @@ from zulipterminal.helper import get_unused_fence
 from zulipterminal.server_url import near_message_url
 from zulipterminal.ui_tools.tables import render_table
 from zulipterminal.urwid_types import urwid_MarkupTuple, urwid_Size
+from zulipterminal.widget import find_widget_type, process_todo_widget
 
 
 if typing.TYPE_CHECKING:
@@ -729,6 +730,31 @@ class MessageBox(urwid.Pile):
                 "/me", f"<strong>{self.message['sender_full_name']}</strong>", 1
             )
 
+        if self.message.get("submessages"):
+            widget_type = find_widget_type(self.message.get("submessages", []))
+
+            if widget_type == "todo":
+                title, tasks = process_todo_widget(self.message.get("submessages", []))
+
+                todo_widget = "<strong>To-do</strong>\n" + f"<strong>{title}</strong>"
+
+                if tasks:
+                    for task_id, task_info in tasks.items():
+                        task_status = "[✔]" if task_info["completed"] else "[ ]"
+                        task_name = task_info["task"]
+                        task_description = task_info["desc"]
+
+                        todo_widget += f"\n{task_status} <strong>{task_name}</strong>"
+
+                        if task_description:
+                            todo_widget += f": {task_description}"
+
+                # Update the message content with the latest todo_widget,
+                # generated from submessages to reflect the current state.
+                # The original raw content can be fetched if needed,
+                # though it's not very useful.
+                self.message["content"] = todo_widget
+
         # Transform raw message content into markup (As needed by urwid.Text)
         content, self.message_links, self.time_mentions = self.transform_content(
             self.message["content"], self.model.server_url
diff --git a/zulipterminal/widget.py b/zulipterminal/widget.py
new file mode 100644
index 0000000..05e80c7
--- /dev/null
+++ b/zulipterminal/widget.py
@@ -0,0 +1,76 @@
+"""
+Process widgets (submessages) like polls, todo lists, etc.
+"""
+
+import json
+from typing import Dict, List, Tuple, Union
+
+
+Submessage = Dict[str, Union[int, str]]
+
+
+def find_widget_type(submessages: List[Submessage]) -> str:
+    if submessages and "content" in submessages[0]:
+        content = submessages[0]["content"]
+
+        if isinstance(content, str):
+            try:
+                loaded_content = json.loads(content)
+                return loaded_content.get("widget_type", "unknown")
+            except json.JSONDecodeError:
+                return "unknown"
+        else:
+            return "unknown"
+    else:
+        return "unknown"
+
+
+def process_todo_widget(
+    todo_list: List[Submessage],
+) -> Tuple[str, Dict[str, Dict[str, Union[str, bool]]]]:
+    title = ""
+    tasks = {}
+
+    for entry in todo_list:
+        content = entry.get("content")
+        sender_id = entry.get("sender_id")
+        msg_type = entry.get("msg_type")
+
+        if msg_type == "widget" and isinstance(content, str):
+            widget = json.loads(content)
+
+            if widget.get("widget_type") == "todo":
+                if "extra_data" in widget and widget["extra_data"] is not None:
+                    title = widget["extra_data"].get("task_list_title", "")
+                    if title == "":
+                        # Webapp uses "Task list" as default title
+                        title = "Task list"
+                    # Process initial tasks
+                    for i, task in enumerate(widget["extra_data"].get("tasks", [])):
+                        # Initial tasks get  ID as "index,canned"
+                        task_id = f"{i},canned"
+                        tasks[task_id] = {
+                            "task": task["task"],
+                            "desc": task.get("desc", ""),
+                            "completed": False,
+                        }
+
+            elif widget.get("type") == "new_task":
+                # New tasks get ID as "key,sender_id"
+                task_id = f"{widget['key']},{sender_id}"
+                tasks[task_id] = {
+                    "task": widget["task"],
+                    "desc": widget.get("desc", ""),
+                    "completed": False,
+                }
+
+            elif widget.get("type") == "strike":
+                # Strike event - toggle task completion state
+                task_id = widget["key"]
+                if task_id in tasks:
+                    tasks[task_id]["completed"] = not tasks[task_id]["completed"]
+
+            elif widget.get("type") == "new_task_list_title":
+                title = widget["title"]
+
+    return title, tasks


Test patch:
diff --git a/tests/model/test_model.py b/tests/model/test_model.py
index 119b3ae..2cd811a 100644
--- a/tests/model/test_model.py
+++ b/tests/model/test_model.py
@@ -237,6 +237,7 @@ class TestModel:
             "message",
             "update_message",
             "reaction",
+            "submessage",
             "subscription",
             "typing",
             "update_message_flags",
@@ -2913,6 +2914,235 @@ class TestModel:
 
         model._update_rendered_view.assert_called_once_with(event_message_id)
 
+    @pytest.mark.parametrize(
+        "submessages, event, expected_updated_submessage",
+        [
+            case(
+                [
+                    {
+                        "id": 1,
+                        "message_id": 1958326,
+                        "sender_id": 27294,
+                        "msg_type": "widget",
+                        "content": (
+                            '{"widget_type": "todo", "extra_data": '
+                            '{"task_list_title": "Today\'s Work", "tasks": [{"task"'
+                            ': "Handle submessages events on ZT", "desc": ""}, {"task":'
+                            ' "Play ping pong", "desc": ""}]}}'
+                        ),
+                    }
+                ],
+                {
+                    "type": "submessage",
+                    "msg_type": "widget",
+                    "message_id": 1958326,
+                    "submessage_id": 1,
+                    "sender_id": 27294,
+                    "content": '{"type":"strike","key":"0,canned"}',
+                    "id": 1,
+                },
+                [
+                    {
+                        "id": 1,
+                        "message_id": 1958326,
+                        "sender_id": 27294,
+                        "msg_type": "widget",
+                        "content": (
+                            '{"widget_type": "todo", "extra_data": '
+                            '{"task_list_title": "Today\'s Work", "tasks": '
+                            '[{"task": "Handle submessages events on ZT", "desc": ""}, '
+                            '{"task": "Play ping pong", "desc": ""}]}}'
+                        ),
+                    },
+                    {
+                        "type": "submessage",
+                        "msg_type": "widget",
+                        "message_id": 1958326,
+                        "submessage_id": 1,
+                        "sender_id": 27294,
+                        "content": '{"type":"strike","key":"0,canned"}',
+                    },
+                ],
+                id="submessage_strike_event_todo_widget",
+            ),
+            case(
+                [
+                    {
+                        "id": 1,
+                        "message_id": 1958326,
+                        "sender_id": 27294,
+                        "msg_type": "widget",
+                        "content": (
+                            '{"widget_type": "todo", "extra_data": '
+                            '{"task_list_title": "Today\'s Work", "tasks": [{"task": '
+                            '"Handle submessages events on ZT", "desc": ""}, {"task": '
+                            '"Play ping pong", "desc": ""}]}}'
+                        ),
+                    },
+                    {
+                        "id": 12154,
+                        "message_id": 1958326,
+                        "sender_id": 27294,
+                        "msg_type": "widget",
+                        "content": '{"type":"strike","key":"0,canned"}',
+                    },
+                ],
+                {
+                    "type": "submessage",
+                    "msg_type": "widget",
+                    "message_id": 1958326,
+                    "submessage_id": 12185,
+                    "sender_id": 27294,
+                    "content": (
+                        '{"type":"new_task","key":2,"task":"Make a coffee",'
+                        '"desc":"","completed":false}'
+                    ),
+                    "id": 0,
+                },
+                [
+                    {
+                        "id": 1,
+                        "message_id": 1958326,
+                        "sender_id": 27294,
+                        "msg_type": "widget",
+                        "content": (
+                            '{"widget_type": "todo", "extra_data": '
+                            '{"task_list_title": "Today\'s Work", "tasks": [{"task": '
+                            '"Handle submessages events on ZT", "desc": ""}, {"task": '
+                            '"Play ping pong", "desc": ""}]}}'
+                        ),
+                    },
+                    {
+                        "id": 12154,
+                        "message_id": 1958326,
+                        "sender_id": 27294,
+                        "msg_type": "widget",
+                        "content": '{"type":"strike","key":"0,canned"}',
+                    },
+                    {
+                        "type": "submessage",
+                        "msg_type": "widget",
+                        "message_id": 1958326,
+                        "submessage_id": 12185,
+                        "sender_id": 27294,
+                        "content": (
+                            '{"type":"new_task","key":2,"task":"Make a coffee",'
+                            '"desc":"","completed":false}'
+                        ),
+                    },
+                ],
+                id="submessage_new_task_event_todo_widget",
+            ),
+            case(
+                [
+                    {
+                        "id": 12153,
+                        "message_id": 1958326,
+                        "sender_id": 27294,
+                        "msg_type": "widget",
+                        "content": (
+                            '{"widget_type": "todo", "extra_data": '
+                            '{"task_list_title": "Today\'s Work", "tasks": [{"task": '
+                            '"Handle submessages events on ZT", "desc": ""}, {"task": '
+                            '"Play ping pong", "desc": ""}]}}'
+                        ),
+                    },
+                    {
+                        "id": 12154,
+                        "message_id": 1958326,
+                        "sender_id": 27294,
+                        "msg_type": "widget",
+                        "content": '{"type":"strike","key":"0,canned"}',
+                    },
+                    {
+                        "type": "submessage",
+                        "msg_type": "widget",
+                        "message_id": 1958326,
+                        "submessage_id": 12185,
+                        "sender_id": 27294,
+                        "content": (
+                            '{"type":"new_task","key":2,"task":"Make a coffee"'
+                            ',"desc":"","completed":false}'
+                        ),
+                        "id": 0,
+                    },
+                ],
+                {
+                    "type": "submessage",
+                    "msg_type": "widget",
+                    "message_id": 1958326,
+                    "submessage_id": 12186,
+                    "sender_id": 27294,
+                    "content": (
+                        '{"type":"new_task_list_title","title":"Today\'s Work '
+                        '[Updated]"}'
+                    ),
+                    "id": 11,
+                },
+                [
+                    {
+                        "id": 12153,
+                        "message_id": 1958326,
+                        "sender_id": 27294,
+                        "msg_type": "widget",
+                        "content": (
+                            '{"widget_type": "todo", "extra_data": '
+                            '{"task_list_title": "Today\'s Work", "tasks": [{"task": '
+                            '"Handle submessages events on ZT", "desc": ""}, {"task": '
+                            '"Play ping pong", "desc": ""}]}}'
+                        ),
+                    },
+                    {
+                        "id": 12154,
+                        "message_id": 1958326,
+                        "sender_id": 27294,
+                        "msg_type": "widget",
+                        "content": '{"type":"strike","key":"0,canned"}',
+                    },
+                    {
+                        "type": "submessage",
+                        "msg_type": "widget",
+                        "message_id": 1958326,
+                        "submessage_id": 12185,
+                        "sender_id": 27294,
+                        "content": (
+                            '{"type":"new_task","key":2,"task":"Make a coffee"'
+                            ',"desc":"","completed":false}'
+                        ),
+                        "id": 0,
+                    },
+                    {
+                        "type": "submessage",
+                        "msg_type": "widget",
+                        "message_id": 1958326,
+                        "submessage_id": 12186,
+                        "sender_id": 27294,
+                        "content": (
+                            '{"type":"new_task_list_title",'
+                            '"title":"Today\'s Work [Updated]"}'
+                        ),
+                    },
+                ],
+                id="submessage_new_task_list_title_event_todo_widget",
+            ),
+        ],
+    )
+    def test__handle_submessage_event(
+        self,
+        mocker,
+        model,
+        submessages,
+        event,
+        expected_updated_submessage,
+        id=1958326,
+    ):
+        model.index["messages"][id]["submessages"] = submessages
+        model._update_rendered_view = mocker.Mock()
+
+        model._handle_submessage_event(event)
+
+        assert model.index["messages"][id]["submessages"] == expected_updated_submessage
+
     @pytest.fixture(
         params=[
             ("op", 32),  # At server feature level 32, event uses standard field
diff --git a/tests/widget/test_widget.py b/tests/widget/test_widget.py
new file mode 100644
index 0000000..ee2636e
--- /dev/null
+++ b/tests/widget/test_widget.py
@@ -0,0 +1,345 @@
+from typing import Dict, List, Union
+
+import pytest
+from pytest import param as case
+
+from zulipterminal.widget import Submessage, find_widget_type, process_todo_widget
+
+
+@pytest.mark.parametrize(
+    "submessages, expected_widget_type",
+    [
+        case(
+            [
+                {
+                    "id": 11897,
+                    "message_id": 1954461,
+                    "sender_id": 27294,
+                    "msg_type": "widget",
+                    "content": (
+                        '{"widget_type": "poll", "extra_data": '
+                        '{"question": "Sample Question?", "options": ["Yes", "No"]}}'
+                    ),
+                },
+                {
+                    "id": 11898,
+                    "message_id": 1954461,
+                    "sender_id": 27294,
+                    "msg_type": "widget",
+                    "content": '{"type":"new_option","idx":1,"option":"Maybe"}',
+                },
+            ],
+            "poll",
+        ),
+        case(
+            [
+                {
+                    "id": 11899,
+                    "message_id": 1954463,
+                    "sender_id": 27294,
+                    "msg_type": "widget",
+                    "content": (
+                        '{"widget_type": "todo", "extra_data": '
+                        '{"task_list_title": "Today\'s Tasks", "tasks": [{"task": '
+                        '"Write code", "desc": ""}, {"task": "Sleep", "desc": ""}]}}'
+                    ),
+                },
+                {
+                    "id": 11900,
+                    "message_id": 1954463,
+                    "sender_id": 27294,
+                    "msg_type": "widget",
+                    "content": (
+                        '{"type":"new_task","key":2,"task":"Eat","desc":"",'
+                        '"completed":false}'
+                    ),
+                },
+            ],
+            "todo",
+        ),
+        case([{}], "unknown"),
+    ],
+)
+def test_find_widget_type(
+    submessages: List[Submessage], expected_widget_type: str
+) -> None:
+    widget_type = find_widget_type(submessages)
+
+    assert widget_type == expected_widget_type
+
+
+@pytest.mark.parametrize(
+    "submessages, expected_title, expected_tasks",
+    [
+        case(
+            [
+                {
+                    "id": 11899,
+                    "message_id": 1954463,
+                    "sender_id": 27294,
+                    "msg_type": "widget",
+                    "content": (
+                        '{"widget_type": "todo", "extra_data": '
+                        '{"task_list_title": "Today\'s Tasks", "tasks": [{"task": '
+                        '"Write code", "desc": ""}, {"task": "Sleep", "desc": ""}]}}'
+                    ),
+                },
+                {
+                    "id": 11900,
+                    "message_id": 1954463,
+                    "sender_id": 27294,
+                    "msg_type": "widget",
+                    "content": (
+                        '{"type":"new_task","key":2,"task":"Eat","desc":"",'
+                        '"completed":false}'
+                    ),
+                },
+            ],
+            "Today's Tasks",
+            {
+                "0,canned": {"task": "Write code", "desc": "", "completed": False},
+                "1,canned": {"task": "Sleep", "desc": "", "completed": False},
+                "2,27294": {"task": "Eat", "desc": "", "completed": False},
+            },
+            id="title_and_unfinished_tasks",
+        ),
+        case(
+            [
+                {
+                    "id": 11912,
+                    "message_id": 1954626,
+                    "sender_id": 27294,
+                    "msg_type": "widget",
+                    "content": (
+                        '{"widget_type": "todo", "extra_data": '
+                        '{"task_list_title": "", "tasks": [{"task": "Hey", "desc": ""},'
+                        ' {"task": "Hi", "desc": ""}]}}'
+                    ),
+                }
+            ],
+            "Task list",
+            {
+                "0,canned": {"task": "Hey", "desc": "", "completed": False},
+                "1,canned": {"task": "Hi", "desc": "", "completed": False},
+            },
+            id="no_title_and_unfinished_tasks",
+        ),
+        case(
+            [
+                {
+                    "id": 11919,
+                    "message_id": 1954843,
+                    "sender_id": 27294,
+                    "msg_type": "widget",
+                    "content": (
+                        '{"widget_type": "todo", "extra_data": '
+                        '{"task_list_title": "", "tasks": []}}'
+                    ),
+                }
+            ],
+            "Task list",
+            {},
+            id="no_title_or_tasks",
+        ),
+        case(
+            [
+                {
+                    "id": 11932,
+                    "message_id": 1954847,
+                    "sender_id": 27294,
+                    "msg_type": "widget",
+                    "content": (
+                        '{"widget_type": "todo", "extra_data": '
+                        '{"task_list_title": "", "tasks": []}}'
+                    ),
+                },
+                {
+                    "id": 11933,
+                    "message_id": 1954847,
+                    "sender_id": 27294,
+                    "msg_type": "widget",
+                    "content": (
+                        '{"type":"new_task","key":2,"task":"Write code",'
+                        '"desc":"Make the todo ZT PR!","completed":false}'
+                    ),
+                },
+                {
+                    "id": 11934,
+                    "message_id": 1954847,
+                    "sender_id": 27294,
+                    "msg_type": "widget",
+                    "content": (
+                        '{"type":"new_task","key":4,"task":"Sleep",'
+                        '"desc":"at least 8 hours a day","completed":false}'
+                    ),
+                },
+                {
+                    "id": 11935,
+                    "message_id": 1954847,
+                    "sender_id": 27294,
+                    "msg_type": "widget",
+                    "content": (
+                        '{"type":"new_task","key":6,"task":"Eat",'
+                        '"desc":"3 meals a day","completed":false}'
+                    ),
+                },
+                {
+                    "id": 11936,
+                    "message_id": 1954847,
+                    "sender_id": 27294,
+                    "msg_type": "widget",
+                    "content": (
+                        '{"type":"new_task","key":8,"task":"Exercise",'
+                        '"desc":"an hour a day","completed":false}'
+                    ),
+                },
+                {
+                    "id": 11937,
+                    "message_id": 1954847,
+                    "sender_id": 27294,
+                    "msg_type": "widget",
+                    "content": '{"type":"strike","key":"2,27294"}',
+                },
+                {
+                    "id": 11938,
+                    "message_id": 1954847,
+                    "sender_id": 27294,
+                    "msg_type": "widget",
+                    "content": '{"type":"strike","key":"2,27294"}',
+                },
+                {
+                    "id": 11939,
+                    "message_id": 1954847,
+                    "sender_id": 27294,
+                    "msg_type": "widget",
+                    "content": '{"type":"strike","key":"4,27294"}',
+                },
+                {
+                    "id": 11940,
+                    "message_id": 1954847,
+                    "sender_id": 27294,
+                    "msg_type": "widget",
+                    "content": '{"type":"strike","key":"6,27294"}',
+                },
+                {
+                    "id": 11941,
+                    "message_id": 1954847,
+                    "sender_id": 27294,
+                    "msg_type": "widget",
+                    "content": '{"type":"strike","key":"8,27294"}',
+                },
+                {
+                    "id": 11942,
+                    "message_id": 1954847,
+                    "sender_id": 27294,
+                    "msg_type": "widget",
+                    "content": '{"type":"strike","key":"2,27294"}',
+                },
+                {
+                    "id": 11943,
+                    "message_id": 1954847,
+                    "sender_id": 27294,
+                    "msg_type": "widget",
+                    "content": '{"type":"strike","key":"4,27294"}',
+                },
+                {
+                    "id": 11944,
+                    "message_id": 1954847,
+                    "sender_id": 27294,
+                    "msg_type": "widget",
+                    "content": '{"type":"strike","key":"6,27294"}',
+                },
+                {
+                    "id": 11945,
+                    "message_id": 1954847,
+                    "sender_id": 27294,
+                    "msg_type": "widget",
+                    "content": '{"type":"strike","key":"4,27294"}',
+                },
+                {
+                    "id": 11946,
+                    "message_id": 1954847,
+                    "sender_id": 27294,
+                    "msg_type": "widget",
+                    "content": '{"type":"strike","key":"8,27294"}',
+                },
+            ],
+            "Task list",
+            {
+                "2,27294": {
+                    "task": "Write code",
+                    "desc": "Make the todo ZT PR!",
+                    "completed": True,
+                },
+                "4,27294": {
+                    "task": "Sleep",
+                    "desc": "at least 8 hours a day",
+                    "completed": True,
+                },
+                "6,27294": {"task": "Eat", "desc": "3 meals a day", "completed": False},
+                "8,27294": {
+                    "task": "Exercise",
+                    "desc": "an hour a day",
+                    "completed": False,
+                },
+            },
+            id="title_and_description_and_finished_tasks",
+        ),
+        case(
+            [
+                {
+                    "id": 12143,
+                    "message_id": 1958318,
+                    "sender_id": 27294,
+                    "msg_type": "widget",
+                    "content": (
+                        '{"widget_type": "todo", "extra_data": {'
+                        '"task_list_title": "Today\'s Work", "tasks": [{"task": '
+                        '"Update todo titles on ZT", "desc": ""}, '
+                        '{"task": "Push todo update", "desc": ""}]}}'
+                    ),
+                },
+                {
+                    "id": 12144,
+                    "message_id": 1958318,
+                    "sender_id": 27294,
+                    "msg_type": "widget",
+                    "content": (
+                        '{"type":"new_task_list_title",'
+                        '"title":"Today\'s Work [Updated]"}'
+                    ),
+                },
+                {
+                    "id": 12145,
+                    "message_id": 1958318,
+                    "sender_id": 27294,
+                    "msg_type": "widget",
+                    "content": '{"type":"strike","key":"0,canned"}',
+                },
+            ],
+            "Today's Work [Updated]",
+            {
+                "0,canned": {
+                    "task": "Update todo titles on ZT",
+                    "desc": "",
+                    "completed": True,
+                },
+                "1,canned": {
+                    "task": "Push todo update",
+                    "desc": "",
+                    "completed": False,
+                },
+            },
+            id="updated_title_and_finished_tasks",
+        ),
+    ],
+)
+def test_process_todo_widget(
+    submessages: List[Submessage],
+    expected_title: str,
+    expected_tasks: Dict[str, Dict[str, Union[str, bool]]],
+) -> None:
+    title, tasks = process_todo_widget(submessages)
+
+    assert title == expected_title
+    assert tasks == expected_tasks