Replace to-do dropdown filter with tabs and counts
What does this MR do and why?
The "Items that need your attention" to-do widget on the personal homepage used a GlCollapsibleListbox dropdown to filter to-dos. Users had to open the dropdown to see what categories existed or how many items were in each. This MR replaces the dropdown with tabs, each carrying a count badge, so the categorization is visible without a click.
Tabs, in order: All (no badge, by design), Review requested, Assigned, Build failed, Unmergeable, Review submitted. The order and labels come from the Figma design, and the labels reuse the strings the To-Do List page already uses, so both surfaces name the same reason identically.
On narrow screens the widget now uses plain GlTabs. A small rule in personal_homepage.scss, scoped to .homepage-todos-widget [role='tablist'], makes the tab bar scroll horizontally once the tabs outgrow the card, and hides the scrollbar. Tabs stay on one line via gl-shrink-0 on the nav item and gl-whitespace-nowrap on the link, so labels never break mid-word.
References
- Issue: #627725 (closed)
- Related planned work for the to-dos page: #627722
- Tracking added earlier: !253660 (merged)
Screenshots or screen recordings
Screenshots still need to be attached.
| Before | After |
|---|---|
How to set up and validate locally
- Check out the branch and make sure the personal homepage is your home page (visit
http://gdk.test:3000/). - Seed to-do items across several reasons so the badges differ, using a rails runner script that creates pending
Todorecords for your user with actionsreview_requested,assigned,build_failed,unmergeable, andreview_submitted, with at least one action above five so a5+badge appears and one with none so a0badge appears. - Load the homepage and confirm the six tabs render with their counts, that
Allhas no badge, and that a count above five reads5+. - Click through the tabs and confirm the list filters and the empty state appears for a reason with no items.
- Mark a to-do done and confirm the badge for that tab decreases.
- Narrow the window until the tabs overflow, then confirm the bar scrolls by trackpad or touch as well as by the chevron buttons.
Performance and database review
What the change does, query-wise
The widget previously ran one GraphQL query per load (the list). It now runs six: the list, plus five aliased count fields in a single GraphQL document, one per filter tab. Each alias resolves through Resolvers::TodosResolver and TodosFinder independently, so it is five separate SQL counts in one HTTP request.
Following review feedback the counts changed from exact to limited. TodoType sets connection_type_class Types::CountableConnectionType, whose count field accepts a limit argument, so the query now asks for count(limit: 5). That returns limit + 1 once the total exceeds the limit, which is all the badge's 5+ state needs. The limit is passed as a GraphQL variable sourced from the component's N_BADGE_MAX constant so the badge text, screen reader text and query limit cannot drift apart.
Refetch points: the widget refetches both the list and the counts when it becomes visible again (browser tab refocus) and when any to-do is actioned from the widget.
Raw SQL, before
This was the original exact count. All five were identical except the action value.
SELECT COUNT(*) FROM "todos"
LEFT JOIN banned_users ON todos.author_id = banned_users.user_id
WHERE "todos"."user_id" = 36551747
AND "todos"."state" = 'pending'
AND "banned_users"."user_id" IS NULL
AND "todos"."action" = 9
AND ("todos"."snoozed_until" <= NOW() OR "todos"."snoozed_until" IS NULL)Raw SQL, after
This is the current query. The LIMIT 6 subquery is what Kaminari's total_count_with_limit generates for count(limit: 5).
SELECT COUNT(*) FROM (
SELECT 1 AS one FROM "todos"
LEFT JOIN banned_users ON todos.author_id = banned_users.user_id
WHERE "todos"."user_id" = 1614863
AND "todos"."state" = 'pending'
AND "banned_users"."user_id" IS NULL
AND "todos"."action" = 9
AND ("todos"."snoozed_until" <= NOW() OR "todos"."snoozed_until" IS NULL)
LIMIT 6
) subquery_for_countBoth versions use the same five aliases, differing only by the action value:
| Alias | action |
|---|---|
| reviewRequested | 9 |
| assigned | 1 |
| buildFailed | 3 |
| unmergeable | 6 |
| reviewSubmitted | 11 |
The LEFT JOIN banned_users anti-join comes from the pending_without_hidden scope on the Todo model.
Query plans, before
These were run against the author's own account, which has only 9 pending to-dos, so they show the best case.
- reviewRequested: https://console.postgres.ai/gitlab/projects/gitlab-production-main/sessions/58995/commands/164146
- assigned: https://postgres.ai/console/gitlab/gitlab-production-main/sessions/58995/commands/164147
- buildFailed: https://postgres.ai/console/gitlab/gitlab-production-main/sessions/58995/commands/164148
- unmergeable: https://postgres.ai/console/gitlab/gitlab-production-main/sessions/58995/commands/164149
- reviewSubmitted: https://postgres.ai/console/gitlab/gitlab-production-main/sessions/58995/commands/164150
Query plans, after
These were run against the gitlab-qa user (user_id = 1614863), which the database review guidelines name for producing plans that hit enough data. That account has 749 pending to-dos. All figures below are warm-cache second runs; first runs are dominated by cold-clone I/O and are not representative.
- reviewRequested, no matches: https://console.postgres.ai/gitlab/projects/gitlab-production-main/sessions/59391/commands/164538
- assigned, capped at 6 matches: https://console.postgres.ai/gitlab/projects/gitlab-production-main/sessions/59391/commands/164539
| action | count returned | rows walked | anti-join loops | buffers | execution |
|---|---|---|---|---|---|
| 9 review_requested | 0 | 749 | 0 | 657 | 1.16ms |
| 1 assigned | 6 (capped) | 672 | 6 | 601 | 0.83ms |
| 3 build_failed | 6 (capped) | 31 | 6 | 52 | 0.17ms |
| 6 unmergeable | 6 (capped) | 498 | 6 | 430 | 0.65ms |
| 11 review_submitted | 0 | 749 | 0 | 657 | 0.90ms |
The before and after plans use different users, so they are not a like-for-like comparison. The before and after figures quoted in the next section both come from the 749 to-do account.
What the plans show
- The limited count removes the anti-join cost, which was dominant. An unlimited count over the same 749 to-do account ran the
banned_usersindex-only scan with 749 loops, consuming 2212 of 2284 buffer hits. With the limit, loops drop to 6, or 0 for a reason with no matches. - The limit does not bound the index scan. Because
actionis applied as aFilterand not anIndex Cond, Postgres walks the user's pending rows inidorder until it finds six matches. Rows walked ranges from 31 to 749 depending on where matching rows sit. The two zero-match reasons walk all 749 rows to prove nothing matches and cannot be bounded. - Planning now dominates execution. The five counts execute in 3.71ms combined, but each costs about 2.8ms to plan, so the widget spends roughly 14ms planning against 3.7ms executing. The limit does not change this.
Alternative considered and rejected
Requesting first: 6 with nodes { id } and counting client side. This is rejected in favour of count(limit: 5), not in favour of an exact count.
The reason it loses: asking for nodes makes the resolver preload each to-do's polymorphic target, project, author and note, plus a project access-level preloader. That took five counts from 5 SQL queries to roughly 170 when measured locally. count(limit: 5) bounds the work the same way without requesting nodes.
MR acceptance checklist
Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.


