diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 747e95f5..9a526df5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -81,3 +81,5 @@ Scope is optional. Use imperative mood: "add feature", not "adds feature" or "ad All PRs target `main` on `gordon-code/github-tracker`. Keep PRs focused — one feature or fix per PR makes review faster and reverts cleaner. In the PR body, describe what changed and why. CI runs typecheck, unit tests, and E2E tests automatically. PRs need a passing CI run before merge. + +When adding or changing user-facing features, update [docs/USER_GUIDE.md](docs/USER_GUIDE.md) to reflect the changes. diff --git a/README.md b/README.md index d3d08014..7e4be0a4 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,10 @@ A dashboard for tracking GitHub issues, PRs, and Actions workflow runs across ma ![Dashboard](docs/dashboard-screenshot.png) +## Documentation + +For detailed feature documentation, see the [User Guide](docs/USER_GUIDE.md). + ## Features ### Issues @@ -18,7 +22,7 @@ Open issues where you're the creator, assignee, or mentioned. A scope filter let ### Pull Requests -Open PRs with CI status dots (green/yellow/red), review decision badges, size badges (XS–XXL by lines changed), and draft indicators. A "blocked" filter catches PRs where checks are failing or a review requested changes. The scope filter works here too. Reviewer avatars stack for multiple reviewers. +Open PRs with CI status dots (green/yellow/red), review decision badges, size badges (XS–XL by lines changed), and draft indicators. A "blocked" filter catches PRs where checks are failing or a review requested changes. The scope filter works here too. Reviewer avatars stack for multiple reviewers. ### Actions @@ -96,11 +100,11 @@ src/ layout/ # Header, TabBar, FilterBar onboarding/ # OnboardingWizard, OrgSelector, RepoSelector settings/ # SettingsPage, TrackedUsersSection, ThemePicker, Section, SettingRow - shared/ # 18 shared components: FilterInput, FilterChips, StatusDot, + shared/ # 19 shared components: FilterInput, FilterChips, StatusDot, # ReviewBadge, SizeBadge, RoleBadge, SortDropdown, PaginationControls, # LoadingSpinner, SkeletonRows, ToastContainer, NotificationDrawer, # RepoLockControls, UserAvatarBadge, ExpandCollapseButtons, - # RepoGitHubLink, ChevronIcon, ExternalLinkIcon + # RepoGitHubLink, ChevronIcon, ExternalLinkIcon, Tooltip/InfoTooltip lib/ # 14 modules: format, errors, notifications, oauth, pat, url, # flashDetection, grouping, reorderHighlight, collections, # emoji, label-colors, sentry, github-emoji-map.json @@ -117,8 +121,8 @@ src/ view.ts # View state (tabs, sorting, filters, ignored items, locked repos) worker/ index.ts # OAuth token exchange endpoint, CORS, security headers -tests/ # 1522 unit/component tests across 69 test files -e2e/ # 14 E2E tests across 2 spec files +tests/ # unit/component tests across 70 test files +e2e/ # 15 E2E tests across 3 spec files ``` ## Development diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md new file mode 100644 index 00000000..c8222927 --- /dev/null +++ b/docs/USER_GUIDE.md @@ -0,0 +1,409 @@ +# GitHub Tracker User Guide + +GitHub Tracker is a dashboard that aggregates open issues, pull requests, and GitHub Actions workflow runs across your repositories into a single view at [gh.gordoncode.dev](https://gh.gordoncode.dev). + +## Table of Contents + +- [Getting Started](#getting-started) + - [OAuth Sign-In](#oauth-sign-in) + - [Personal Access Token Sign-In](#personal-access-token-sign-in) + - [Repository Selection](#repository-selection) + - [Organization Access](#organization-access) +- [Dashboard Overview](#dashboard-overview) + - [Tab Structure](#tab-structure) + - [Personal Summary Strip](#personal-summary-strip) + - [Repo Grouping and Expand/Collapse](#repo-grouping-and-expandcollapse) + - [Scope Filter](#scope-filter) +- [Issues Tab](#issues-tab) + - [Filters](#issues-filters) + - [Dependency Dashboard Toggle](#dependency-dashboard-toggle) + - [Sorting](#issues-sorting) + - [Ignored Items](#ignored-items) +- [Pull Requests Tab](#pull-requests-tab) + - [Status Indicators](#status-indicators) + - [Filters](#pull-requests-filters) + - [Sorting](#pull-requests-sorting) +- [Actions Tab](#actions-tab) + - [Workflow Grouping](#workflow-grouping) + - [Show PR Runs](#show-pr-runs) + - [Filters](#actions-filters) +- [Multi-User Tracking](#multi-user-tracking) +- [Monitor-All Mode](#monitor-all-mode) +- [Upstream Repos](#upstream-repos) +- [Refresh and Polling](#refresh-and-polling) +- [Notifications](#notifications) +- [Repo Pinning](#repo-pinning) +- [Settings Reference](#settings-reference) +- [Troubleshooting](#troubleshooting) + +--- + +## Getting Started + +### OAuth Sign-In + +OAuth is the recommended sign-in method. Click **Sign in with GitHub** on the login page and authorize the application. GitHub will redirect you back with a token that grants access to `repo`, `read:org`, and `notifications` scopes. + +OAuth tokens work across all organizations you belong to and support the notifications optimization that reduces API usage in background tabs. + +### Personal Access Token Sign-In + +If you prefer not to use OAuth, you can sign in with a GitHub Personal Access Token (PAT). Click **Use a Personal Access Token** on the login page and paste your token. + +Two token formats are accepted: + +- **Classic tokens** (starts with `ghp_`) — recommended. Works across all organizations you belong to. Required scopes: `repo`, `read:org` (under admin:org), `notifications`. +- **Fine-grained tokens** (starts with `github_pat_`) — also work, but have limitations: they only access one organization at a time, do not support the `notifications` scope, and therefore cannot use the background-poll optimization. Required permissions: Actions (read), Contents (read), Issues (read), Pull requests (read). + +The token is validated against the GitHub API before being stored. It is saved permanently in your browser's `localStorage` — you will not need to re-enter it on revisit. + +### Repository Selection + +After signing in, the onboarding wizard asks you to select repositories to track. Search by name or browse by organization. You can change your selection at any time in **Settings > Repositories**. + +### Organization Access + +OAuth sign-in uses your existing GitHub org memberships. If a private organization does not appear in the repo selector, click **Manage org access** in Settings to open GitHub's OAuth application settings and grant access to that organization. When you return to the tracker, it automatically syncs your updated org list. + +--- + +## Dashboard Overview + +### Tab Structure + +The dashboard has three tabs: + +| Tab | Contents | +|-----|----------| +| **Issues** | Open issues across your selected repos where you are the author, assignee, or mentioned | +| **Pull Requests** | Open PRs where you are the author, reviewer, or assignee | +| **Actions** | Recent workflow runs for your selected repos | + +The active tab is remembered across page loads by default. You can set a fixed default tab in Settings. + +### Personal Summary Strip + +A summary strip appears directly below the tab bar whenever there is actionable activity. It shows counts for: + +- **Issues assigned** to you +- **PRs awaiting your review** (you are a requested reviewer and the PR needs review) +- **PRs ready to merge** (you are the author, checks pass, PR is approved or has no review requirement, not a draft) +- **PRs blocked** (you are the author, PR is not a draft, checks are failing or there is a merge conflict) +- **Actions running** (in-progress workflow runs, excluding ignored items) + +Clicking any count switches to the relevant tab and applies filters that match what was counted. This lets you jump directly to the most important items. + +Counts are computed across all repos regardless of any org or repo filter you have active on the tab. + +### Repo Grouping and Expand/Collapse + +Items are grouped by repository. Each repo group has a header row showing the repo name, item count, and a summary of statuses (check results, review decisions, role counts). Click a repo header to expand or collapse that group. + +Use the **Expand all** / **Collapse all** buttons in the toolbar to expand or collapse all groups at once. + +When a group is collapsed, a brief preview of any status change detected by the hot poll appears under the header for a few seconds before fading. + +### Scope Filter + +The **Scope** filter chip appears on the Issues and Pull Requests tabs when you have tracked users configured or monitor-all repos enabled. It has two options: + +- **Involves me** (default) — shows only items where you (the signed-in user) are the author, assignee, reviewer, or mentioned. For monitored repos, all activity in that repo is always shown regardless of scope. +- **All activity** — shows every open item across your selected repos. Items that involve you are highlighted with a blue left border. + +The scope filter is hidden (and always set to "Involves me") when you have no tracked users and no monitor-all repos, because in that configuration all fetched data already involves you. + +--- + +## Issues Tab + +### Issues Filters + +| Filter | Options | Default | +|--------|---------|---------| +| Scope | Involves me / All activity | Involves me | +| Role | All / Author / Assignee | All | +| Comments | All / Has comments / No comments | All | +| User | All / (tracked user logins) | All (shown when tracked users are configured) | + +Filters can be combined. Click **Reset all** to clear all active filters at once. + +### Dependency Dashboard Toggle + +The **Show Dep Dashboard** button controls whether the Renovate "Dependency Dashboard" issue appears in the list. The Dependency Dashboard issue is hidden by default. This setting applies across all repos. + +### Issues Sorting + +Sort by: Repo, Title, Author, Comments, Created, Updated (default: Updated, descending). + +### Ignored Items + +Each issue row has an ignore button (visible on hover). Ignored items are hidden from the list. To restore ignored items, click the **Ignored** badge in the toolbar, which lists all currently ignored items with an option to unignore each one. Ignored items are automatically pruned after 30 days. + +--- + +## Pull Requests Tab + +### Status Indicators + +Each PR row displays several indicators: + +#### Check Status Dot + +A small colored dot shows the aggregate CI status for the PR's latest commit: + +| Color | Meaning | +|-------|---------| +| Green (solid) | All checks passed | +| Yellow (pulsing) | Checks in progress | +| Red (solid) | Checks failing | +| Yellow/faded (solid) | Checks blocked by merge conflict | +| Gray (solid) | No checks | + +The dot links to the PR's checks page on GitHub. + +#### Review Badges + +| Badge | Meaning | +|-------|---------| +| Approved | At least one approving review, no blocking reviews | +| Changes requested | At least one reviewer requested changes | +| Needs review | Review has been requested but not yet submitted | +| Dismissed | Previously submitted review was dismissed | + +#### Size Badges + +PR size is computed from total lines changed (additions + deletions): + +| Badge | Total lines changed | +|-------|---------------------| +| XS | Less than 10 | +| S | 10 to 99 | +| M | 100 to 499 | +| L | 500 to 999 | +| XL | 1,000 or more | + +The size badge links to the PR's file diff on GitHub. + +#### Role Badges + +Shows your involvement in the PR: **Author**, **Reviewer**, **Assignee**, or **Involved** (for items in upstream repos where you have no direct role). + +### Pull Requests Filters + +| Filter | Options | Default | +|--------|---------|---------| +| Scope | Involves me / All activity | Involves me | +| Role | All / Author / Reviewer / Assignee | All | +| Review | All / Approved / Changes / Needs review / Mergeable | All | +| Status | All / Draft / Ready | All | +| Checks | All / Passing / Failing / Pending / Conflict / Blocked / No CI | All | +| Size | All / XS / S / M / L / XL | All | +| User | All / (tracked user logins) | All (shown when tracked users are configured) | + +**Mergeable** in the Review filter matches PRs that are approved or have no review requirement (equivalent to "safe to merge from review standpoint"). + +**Blocked** in the Checks filter matches PRs with failing checks or a merge conflict. + +### Pull Requests Sorting + +Sort by: Repo, Title, Author, Checks, Review, Size, Created, Updated (default: Updated, descending). + +--- + +## Actions Tab + +### Workflow Grouping + +Workflow runs are grouped first by repository, then by workflow name. Each workflow group shows its most recent runs up to the configured limit (default: 3 runs per workflow, up to 5 workflows per repo). + +### Show PR Runs + +By default, runs triggered by pull request events are hidden to reduce noise. Toggle **Show PR runs** to include them. This preference is saved across sessions. + +### Actions Filters + +| Filter | Options | Default | +|--------|---------|---------| +| Conclusion | All / Success / Failure / Cancelled / Running / Other | All | +| Event | All / Push / Pull request / Schedule / Workflow dispatch / Other | All | + +--- + +## Multi-User Tracking + +You can track another GitHub user's issues and PRs alongside your own. Go to **Settings > Tracked Users**, enter a GitHub username, and click **Add**. The app validates the username against the GitHub API before saving. + +- Tracked user items appear in the same Issues and Pull Requests tabs, mixed with your own items. +- Each item shows small avatar badges indicating which tracked users it was surfaced by. +- When multiple users are tracked, a **User** filter chip appears on the Issues and Pull Requests tabs, letting you view activity for one user at a time. +- Both regular GitHub users and bot accounts (e.g., `renovate[bot]`) can be tracked. Bot accounts are labeled with a "Bot" badge in the Tracked Users section. + +**API usage note:** Each tracked user adds one additional GraphQL search query per poll cycle. At 3 or more tracked users, a warning appears in Settings. The hard cap is 10 tracked users. + +--- + +## Monitor-All Mode + +Normally, the dashboard shows only issues and PRs that involve you (or a tracked user). Monitor-all mode shows every open issue and PR in a specific repo — regardless of whether anyone you track is involved. + +**How to enable:** In **Settings > Repositories**, expand the repo panel. Each repo has an eye icon toggle. Enabling it adds the repo to the monitored list (maximum 10 monitored repos). + +**Effect on display:** Repo groups for monitored repos show a **Monitoring all** badge in their header. Items from monitored repos are always visible even when the Scope filter is set to "Involves me", and they bypass the User filter. + +Upstream repos cannot be monitored (only selected repos are eligible). + +--- + +## Upstream Repos + +Upstream repos are repositories that you contribute to but do not own — for example, open-source projects you have pull requests in. + +**Auto-discovery:** The app discovers upstream repos automatically by searching for issues and PRs involving you across GitHub (not limited to your selected repos). Discovered repos appear in **Settings > Repositories** under "Upstream repos" and are added to issue and PR fetches. + +**Manual management:** You can add or remove upstream repos manually in the Settings repo panel. + +Upstream repos are included in Issues and Pull Requests fetches but excluded from the Actions tab, since workflow run access requires explicit repo permissions. + +--- + +## Refresh and Polling + +### Full Refresh + +The dashboard polls GitHub at a configurable interval (default: **5 minutes**). Each full refresh fetches all issues, PRs, and workflow runs. You can trigger a manual refresh by clicking the refresh button in the header. + +Setting the interval to **Off** disables automatic polling; manual refresh still works. + +A ±30 second jitter is applied to the refresh interval to avoid synchronized API spikes from multiple browser tabs. + +### Hot Poll + +A second, faster poll loop runs alongside the full refresh specifically for in-flight items. It targets: + +- **PRs with pending CI checks** — re-checks status until checks resolve +- **In-progress workflow runs** — re-checks until the run completes + +Default interval: **30 seconds** (configurable from 10 to 120 seconds in Settings). + +While the hot poll is active, a subtle shimmer animation appears on affected PR rows. When a status changes, the row flashes briefly to draw attention. + +The hot poll pauses automatically when the browser tab is hidden (since visual feedback has no value in a background tab). + +### Tab Visibility Behavior + +When the tab is hidden: + +- The **hot poll always pauses** (it provides only visual feedback). +- The **full poll continues in background** when the notifications gate is available (OAuth or classic PAT with `notifications` scope). The gate uses `If-Modified-Since` headers for near-zero-cost 304 checks that do not count against your rate limit. +- When the notifications gate is **unavailable** (fine-grained PAT or classic PAT missing the `notifications` scope), the full poll also pauses in background tabs to conserve API budget. + +When you return to a tab that has been hidden for more than 2 minutes, a catch-up fetch fires immediately regardless of where the timer is in its cycle. + +--- + +## Notifications + +### Notification Drawer + +The bell icon in the header opens the notification drawer, which shows API errors, rate limit warnings, and other system messages. Notifications are dismissed automatically when the underlying condition clears on the next poll cycle. + +### Browser Push Notifications + +Browser push notifications are disabled by default. To enable them: + +1. Go to **Settings > Notifications**. +2. Click **Grant permission** and allow notifications in the browser prompt. +3. Toggle **Enable notifications** on. + +Per-type toggles (all default to on when notifications are enabled): + +| Toggle | What triggers a notification | +|--------|------------------------------| +| Issues | New issues are opened in your tracked repos | +| Pull Requests | New PRs are opened or updated | +| Workflow Runs | Workflow runs complete | + +--- + +## Repo Pinning + +Each repo group header has a pin (lock) control, visible on hover on desktop and always visible on mobile. Pinning a repo keeps it at the top of the list within its tab regardless of sort order or how recently it was updated. + +- Click the pin icon to pin a repo to the top. +- Click it again to unpin. +- Use the up/down arrows (visible when pinned) to reorder pinned repos relative to each other. + +Pin state is per-tab — a repo can be pinned on the Issues tab but not the Pull Requests tab. + +--- + +## Settings Reference + +Settings are saved automatically to `localStorage` and persist across sessions. All settings can be exported as a JSON file via **Settings > Data > Export**. + +### Config Settings + +| Setting | Default | Description | +|---------|---------|-------------| +| Refresh interval | 5 minutes | How often to poll GitHub for new data. Options: 1, 2, 5, 10, 15, 30 minutes, or Off. | +| CI status refresh (hot poll interval) | 30 seconds | How often to re-check in-flight CI checks and workflow runs. Range: 10–120 seconds. | +| Max workflows per repo | 5 | Number of active workflows to track per repository. Range: 1–20. | +| Max runs per workflow | 3 | Number of recent runs to show per workflow. Range: 1–10. | +| Notifications enabled | Off | Master toggle for browser push notifications. | +| Notify: Issues | On | Notify when new issues open (requires notifications enabled). | +| Notify: Pull Requests | On | Notify when PRs are opened or updated (requires notifications enabled). | +| Notify: Workflow Runs | On | Notify when workflow runs complete (requires notifications enabled). | +| Theme | Auto | UI color theme. Auto follows system dark/light preference (Corporate for light, Dim for dark). | +| View density | Comfortable | Spacing between list items. Options: Comfortable, Compact. | +| Items per page | 25 | Number of items per page in each tab. Options: 10, 25, 50, 100. | +| Default tab | Issues | Tab shown when opening the dashboard fresh (without remembered last tab). | +| Remember last tab | On | Return to the last active tab on revisit. | + +### View State Settings + +These are UI preferences that persist across sessions but are not included in the exported config file. + +| Setting | Default | Description | +|---------|---------|-------------| +| Scope filter (Issues) | Involves me | Whether to show only items involving you or all activity. | +| Scope filter (Pull Requests) | Involves me | Whether to show only items involving you or all activity. | +| Show PR runs (Actions) | Off | Whether to show workflow runs triggered by pull request events. | +| Hide Dependency Dashboard | On | Whether to hide the Renovate Dependency Dashboard issue. | +| Sort preferences | Updated (desc) | Sort field and direction per tab, remembered across sessions. | +| Pinned repos | (none) | Repos pinned to the top of each tab's list. | + +--- + +## Troubleshooting + +**Items I expect to see are not showing up.** + +- Check that the Scope filter is set correctly. "Involves me" hides items where you have no direct involvement. Switch to "All activity" to see everything. +- Verify the repo is in your selected repo list (Settings > Repositories). +- Check if the item was accidentally ignored (toolbar Ignored badge). +- If you recently added the repo, wait for the next full refresh or click the manual refresh button. + +**I see a rate limit warning.** + +The tracker uses GitHub's GraphQL and REST APIs. Each poll cycle consumes some of your 5,000 request hourly budget. Tracking many repos, tracked users, or having a short refresh interval increases consumption. Increasing the refresh interval or reducing the number of tracked repos will reduce API usage. + +OAuth tokens and classic PATs use the notifications gate (304 shortcut), which significantly reduces per-cycle cost when nothing has changed. Fine-grained PATs do not support this optimization. + +**PAT vs OAuth: what is the difference?** + +OAuth tokens (from "Sign in with GitHub") work across all your organizations and support all features including the notifications background-poll optimization. Classic PATs with the correct scopes (`repo`, `read:org`, `notifications`) behave identically to OAuth. + +Fine-grained PATs are limited to one organization at a time, do not support the `notifications` scope, and therefore cannot use the background-poll optimization — the full poll pauses in hidden tabs, and a warning appears in the notification drawer. + +**Data looks stale after switching back to the tab.** + +When a tab has been hidden for more than 2 minutes, a catch-up fetch fires automatically on return. If the notifications gate is unavailable (fine-grained PAT), polling was paused while the tab was hidden — the catch-up fetch provides a single refresh on return. To ensure continuous background updates, use OAuth or a classic PAT with the `notifications` scope. + +**I want to stop tracking a repository.** + +Go to **Settings > Repositories > Manage Repositories**, find the repo, and deselect it. If it was in the monitored list, it will be removed from monitoring automatically. + +**How do I sign out or reset everything?** + +- **Sign out**: Settings > Data > Sign out. This clears your auth token and returns you to the login page. Your config is preserved. +- **Reset all**: Settings > Data > Reset all. This clears all settings, cache, auth tokens, and reloads the page. All configuration is lost. diff --git a/e2e/settings.spec.ts b/e2e/settings.spec.ts index 614f6945..9c9e4e98 100644 --- a/e2e/settings.spec.ts +++ b/e2e/settings.spec.ts @@ -6,6 +6,7 @@ import { setupAuth } from "./helpers"; test("settings page renders section headings", async ({ page }) => { await setupAuth(page); await page.goto("/settings"); + await page.waitForLoadState("networkidle"); await expect( page.getByRole("heading", { name: /organizations & repositories/i }) diff --git a/src/app/components/dashboard/DashboardPage.tsx b/src/app/components/dashboard/DashboardPage.tsx index 6d5b9196..5d0a2c01 100644 --- a/src/app/components/dashboard/DashboardPage.tsx +++ b/src/app/components/dashboard/DashboardPage.tsx @@ -25,6 +25,7 @@ import { pushNotification } from "../../lib/errors"; import { getClient, getGraphqlRateLimit } from "../../services/github"; import { formatCount } from "../../lib/format"; import { setsEqual } from "../../lib/collections"; +import { Tooltip } from "../shared/Tooltip"; // ── Shared dashboard store (module-level to survive navigation) ───────────── @@ -450,6 +451,15 @@ export default function DashboardPage() { Source + + Guide + + {(rl) => ( -
+ API RL: {rl().remaining.toLocaleString()}/{formatCount(rl().limit)}/hr -
+ )}
diff --git a/src/app/components/dashboard/IgnoreBadge.tsx b/src/app/components/dashboard/IgnoreBadge.tsx index cebddd63..0b5bc3db 100644 --- a/src/app/components/dashboard/IgnoreBadge.tsx +++ b/src/app/components/dashboard/IgnoreBadge.tsx @@ -1,5 +1,6 @@ import { createSignal, For, Show } from "solid-js"; import type { IgnoredItem } from "../../stores/view"; +import { Tooltip } from "../shared/Tooltip"; interface IgnoreBadgeProps { items: IgnoredItem[]; @@ -84,9 +85,11 @@ export default function IgnoreBadge(props: IgnoreBadgeProps) {

{item.repo}

-

- {item.title} -

+ +

+ {item.title} +

+

Ignored {formatDate(item.ignoredAt)}

diff --git a/src/app/components/dashboard/IssuesTab.tsx b/src/app/components/dashboard/IssuesTab.tsx index 4154d7d7..77de0030 100644 --- a/src/app/components/dashboard/IssuesTab.tsx +++ b/src/app/components/dashboard/IssuesTab.tsx @@ -18,6 +18,7 @@ import { groupByRepo, computePageLayout, slicePageGroups, orderRepoGroups, isUse import { createReorderHighlight } from "../../lib/reorderHighlight"; import RepoLockControls from "../shared/RepoLockControls"; import RepoGitHubLink from "../shared/RepoGitHubLink"; +import { Tooltip } from "../shared/Tooltip"; export interface IssuesTabProps { issues: Issue[]; @@ -271,17 +272,18 @@ export default function IssuesTab(props: IssuesTabProps) { setPage(0); }} /> - + + +
{repoGroup.repoFullName} - Monitoring all + + Monitoring all + 0}> diff --git a/src/app/components/dashboard/ItemRow.tsx b/src/app/components/dashboard/ItemRow.tsx index 9ab23107..28054b0e 100644 --- a/src/app/components/dashboard/ItemRow.tsx +++ b/src/app/components/dashboard/ItemRow.tsx @@ -3,6 +3,7 @@ import { isSafeGitHubUrl } from "../../lib/url"; import { relativeTime, shortRelativeTime, formatCount } from "../../lib/format"; import { expandEmoji } from "../../lib/emoji"; import { labelColorClass } from "../../lib/label-colors"; +import { Tooltip } from "../shared/Tooltip"; export interface ItemRowProps { repo: string; @@ -78,14 +79,15 @@ export default function ItemRow(props: ItemRowProps) { {/* Repo badge */} - - {props.repo} - + + + {props.repo} + + {/* Main content */} @@ -127,75 +129,80 @@ export default function ItemRow(props: ItemRowProps) {
{props.surfacedByBadge}
- - - + + + + + + + 0}> - - - {formatCount(props.commentCount!)} - + + + + {formatCount(props.commentCount!)} + +
{/* Ignore button — visible on hover */} - + {/* Eye-slash icon */} + + + ); } diff --git a/src/app/components/dashboard/PersonalSummaryStrip.tsx b/src/app/components/dashboard/PersonalSummaryStrip.tsx index 77576476..04d05f8b 100644 --- a/src/app/components/dashboard/PersonalSummaryStrip.tsx +++ b/src/app/components/dashboard/PersonalSummaryStrip.tsx @@ -2,6 +2,7 @@ import { createMemo, For, Show } from "solid-js"; import type { Issue, PullRequest, WorkflowRun } from "../../services/api"; import type { TabId } from "../layout/TabBar"; import { viewState, updateViewState, resetAllTabFilters, setTabFilter } from "../../stores/view"; +import { InfoTooltip } from "../shared/Tooltip"; interface SummaryCount { label: string; @@ -176,6 +177,7 @@ export default function PersonalSummaryStrip(props: PersonalSummaryStripProps) { )} + ); diff --git a/src/app/components/dashboard/PullRequestsTab.tsx b/src/app/components/dashboard/PullRequestsTab.tsx index 51d3ca83..d7678ff7 100644 --- a/src/app/components/dashboard/PullRequestsTab.tsx +++ b/src/app/components/dashboard/PullRequestsTab.tsx @@ -23,6 +23,7 @@ import { createReorderHighlight } from "../../lib/reorderHighlight"; import { createFlashDetection } from "../../lib/flashDetection"; import RepoLockControls from "../shared/RepoLockControls"; import RepoGitHubLink from "../shared/RepoGitHubLink"; +import { Tooltip } from "../shared/Tooltip"; export interface PullRequestsTabProps { pullRequests: PullRequest[]; @@ -470,7 +471,9 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { {repoGroup.repoFullName} - Monitoring all + + Monitoring all + 0}> @@ -605,11 +608,13 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { 0}> - - Reviewers: {pr.reviewerLogins.slice(0, 5).join(", ")} - {pr.reviewerLogins.length > 5 && ` +${pr.reviewerLogins.length - 5} more`} - {pr.totalReviewCount > pr.reviewerLogins.length && ` (${pr.totalReviewCount} total)`} - + + + Reviewers: {pr.reviewerLogins.slice(0, 5).join(", ")} + {pr.reviewerLogins.length > 5 && ` +${pr.reviewerLogins.length - 5} more`} + {pr.totalReviewCount > pr.reviewerLogins.length && ` (${pr.totalReviewCount} total)`} + + diff --git a/src/app/components/dashboard/WorkflowRunRow.tsx b/src/app/components/dashboard/WorkflowRunRow.tsx index 1cf04d3c..496c95f6 100644 --- a/src/app/components/dashboard/WorkflowRunRow.tsx +++ b/src/app/components/dashboard/WorkflowRunRow.tsx @@ -3,6 +3,7 @@ import type { WorkflowRun } from "../../services/api"; import type { Config } from "../../stores/config"; import { isSafeGitHubUrl } from "../../lib/url"; import { relativeTime, formatDuration } from "../../lib/format"; +import { Tooltip } from "../shared/Tooltip"; interface WorkflowRunRowProps { run: WorkflowRun; @@ -182,39 +183,41 @@ export default function WorkflowRunRow(props: WorkflowRunRowProps) { {durationLabel(props.run)} - + + + - + + + ); } diff --git a/src/app/components/dashboard/WorkflowSummaryCard.tsx b/src/app/components/dashboard/WorkflowSummaryCard.tsx index bccbfa42..59eaadb4 100644 --- a/src/app/components/dashboard/WorkflowSummaryCard.tsx +++ b/src/app/components/dashboard/WorkflowSummaryCard.tsx @@ -1,6 +1,7 @@ import { createMemo, For, Show } from "solid-js"; import type { WorkflowRun } from "../../services/api"; import WorkflowRunRow from "./WorkflowRunRow"; +import { Tooltip } from "../shared/Tooltip"; interface WorkflowSummaryCardProps { workflowName: string; @@ -81,19 +82,25 @@ export default function WorkflowSummaryCard(props: WorkflowSummaryCardProps) {
0}> - - {counts().success} - + + + {counts().success} + + 0}> - - {counts().failure} - + + + {counts().failure} + + 0}> - - {counts().running} - + + + {counts().running} + +
diff --git a/src/app/components/layout/FilterBar.tsx b/src/app/components/layout/FilterBar.tsx index 5b596608..74150ec3 100644 --- a/src/app/components/layout/FilterBar.tsx +++ b/src/app/components/layout/FilterBar.tsx @@ -2,6 +2,7 @@ import { createMemo, createSignal, createEffect, onCleanup, Show } from "solid-j import { Select } from "@kobalte/core/select"; import { config } from "../../stores/config"; import { viewState, setGlobalFilter } from "../../stores/view"; +import { Tooltip } from "../shared/Tooltip"; interface FilterBarProps { isRefreshing?: boolean; @@ -118,34 +119,35 @@ export default function FilterBar(props: FilterBarProps) { - + + + ); } diff --git a/src/app/components/layout/Header.tsx b/src/app/components/layout/Header.tsx index 734b032a..87ce5888 100644 --- a/src/app/components/layout/Header.tsx +++ b/src/app/components/layout/Header.tsx @@ -4,6 +4,7 @@ import { user, clearAuth } from "../../stores/auth"; import { getUnreadCount, markAllAsRead } from "../../lib/errors"; import NotificationDrawer from "../shared/NotificationDrawer"; import ToastContainer from "../shared/ToastContainer"; +import { Tooltip } from "../shared/Tooltip"; export default function Header() { const navigate = useNavigate(); @@ -55,71 +56,77 @@ export default function Header() { )} -
- - + + + {/* Bell icon with unread badge */} - + + 0}> + + {unreadCount() > 9 ? "9+" : unreadCount()} + + + + - + + + setDrawerOpen(false)} /> diff --git a/src/app/components/onboarding/RepoSelector.tsx b/src/app/components/onboarding/RepoSelector.tsx index 451439ae..87f8c437 100644 --- a/src/app/components/onboarding/RepoSelector.tsx +++ b/src/app/components/onboarding/RepoSelector.tsx @@ -14,6 +14,7 @@ import type { TrackedUser } from "../../stores/config"; import { relativeTime } from "../../lib/format"; import LoadingSpinner from "../shared/LoadingSpinner"; import FilterInput from "../shared/FilterInput"; +import { Tooltip, InfoTooltip } from "../shared/Tooltip"; // Validates owner/repo format (both segments must be non-empty, no spaces) const VALID_REPO_NAME = /^[a-zA-Z0-9._-]{1,100}\/[a-zA-Z0-9._-]{1,100}$/; @@ -556,27 +557,28 @@ export default function RepoSelector(props: RepoSelectorProps) { - + + + @@ -597,7 +599,10 @@ export default function RepoSelector(props: RepoSelectorProps) {
{/* Section heading */}
-

Upstream Repositories

+

+ Upstream Repositories + +

Repos you contribute to but don't own. Issues and PRs are tracked; workflow runs are not.

diff --git a/src/app/components/settings/SettingRow.tsx b/src/app/components/settings/SettingRow.tsx index 97314589..400a176b 100644 --- a/src/app/components/settings/SettingRow.tsx +++ b/src/app/components/settings/SettingRow.tsx @@ -2,13 +2,17 @@ import { JSX, Show } from "solid-js"; export default function SettingRow(props: { label: string; + labelSuffix?: JSX.Element; description?: string; children: JSX.Element; }) { return (
-
{props.label}
+
+ {props.label} + {props.labelSuffix} +
{props.description}
diff --git a/src/app/components/settings/SettingsPage.tsx b/src/app/components/settings/SettingsPage.tsx index f3db7e9c..ce136bf9 100644 --- a/src/app/components/settings/SettingsPage.tsx +++ b/src/app/components/settings/SettingsPage.tsx @@ -14,6 +14,7 @@ import Section from "./Section"; import SettingRow from "./SettingRow"; import ThemePicker from "./ThemePicker"; import TrackedUsersSection from "./TrackedUsersSection"; +import { InfoTooltip } from "../shared/Tooltip"; import type { RepoRef } from "../../services/api"; export default function SettingsPage() { @@ -379,6 +380,7 @@ export default function SettingsPage() { } description="How often to re-check in-flight CI checks and workflow runs (10-120s)" > + +
); diff --git a/src/app/components/settings/TrackedUsersSection.tsx b/src/app/components/settings/TrackedUsersSection.tsx index b785c89f..27922b27 100644 --- a/src/app/components/settings/TrackedUsersSection.tsx +++ b/src/app/components/settings/TrackedUsersSection.tsx @@ -3,6 +3,7 @@ import type { TrackedUser } from "../../stores/config"; import { user } from "../../stores/auth"; import { validateGitHubUser } from "../../services/api"; import { getClient } from "../../services/github"; +import { Tooltip } from "../shared/Tooltip"; interface TrackedUsersSectionProps { users: TrackedUser[]; @@ -125,29 +126,33 @@ export default function TrackedUsersSection(props: TrackedUsersSectionProps) { - bot + + bot +
- + + + )} diff --git a/src/app/components/shared/ExpandCollapseButtons.tsx b/src/app/components/shared/ExpandCollapseButtons.tsx index 3706fd23..5124f785 100644 --- a/src/app/components/shared/ExpandCollapseButtons.tsx +++ b/src/app/components/shared/ExpandCollapseButtons.tsx @@ -1,3 +1,5 @@ +import { Tooltip } from "./Tooltip"; + export interface ExpandCollapseButtonsProps { onExpandAll: () => void; onCollapseAll: () => void; @@ -6,48 +8,50 @@ export interface ExpandCollapseButtonsProps { export default function ExpandCollapseButtons(props: ExpandCollapseButtonsProps) { return (
- - + + + + + + + +
); } diff --git a/src/app/components/shared/RepoGitHubLink.tsx b/src/app/components/shared/RepoGitHubLink.tsx index 2fbc7ca2..0d5bb000 100644 --- a/src/app/components/shared/RepoGitHubLink.tsx +++ b/src/app/components/shared/RepoGitHubLink.tsx @@ -1,4 +1,5 @@ import ExternalLinkIcon from "./ExternalLinkIcon"; +import { Tooltip } from "./Tooltip"; const sectionLabels = { issues: "issues", @@ -13,15 +14,16 @@ export default function RepoGitHubLink(props: { const label = () => sectionLabels[props.section]; return ( - - - + + + + + ); } diff --git a/src/app/components/shared/RepoLockControls.tsx b/src/app/components/shared/RepoLockControls.tsx index ef8c2aec..521414f9 100644 --- a/src/app/components/shared/RepoLockControls.tsx +++ b/src/app/components/shared/RepoLockControls.tsx @@ -1,5 +1,6 @@ import { Show, createMemo } from "solid-js"; import { viewState, lockRepo, unlockRepo, moveLockedRepo, type LockedReposTab } from "../../stores/view"; +import { Tooltip } from "./Tooltip"; interface RepoLockControlsProps { tab: LockedReposTab; @@ -22,52 +23,58 @@ export default function RepoLockControls(props: RepoLockControlsProps) { + + + } + > + - } - > - - - + + + + + + + ); diff --git a/src/app/components/shared/SizeBadge.tsx b/src/app/components/shared/SizeBadge.tsx index 9948faf7..b3ddf999 100644 --- a/src/app/components/shared/SizeBadge.tsx +++ b/src/app/components/shared/SizeBadge.tsx @@ -1,5 +1,6 @@ import { Show } from "solid-js"; import { prSizeCategory } from "../../lib/format"; +import { Tooltip } from "./Tooltip"; interface SizeBadgeProps { additions: number; @@ -17,15 +18,25 @@ const SIZE_CONFIG = { XL: "badge badge-error badge-sm", } as const; +const SIZE_TOOLTIP: Record<"XS" | "S" | "M" | "L" | "XL", string> = { + XS: "XS: <10 lines changed", + S: "S: 10–99 lines changed", + M: "M: 100–499 lines changed", + L: "L: 500–999 lines changed", + XL: "XL: 1000+ lines changed", +}; + export default function SizeBadge(props: SizeBadgeProps) { const size = () => props.category ?? prSizeCategory(props.additions, props.deletions); return ( 0 || props.changedFiles > 0}> - - {size()} - + + + {size()} + + +{props.additions} diff --git a/src/app/components/shared/StatusDot.tsx b/src/app/components/shared/StatusDot.tsx index 733c90f5..b3b68734 100644 --- a/src/app/components/shared/StatusDot.tsx +++ b/src/app/components/shared/StatusDot.tsx @@ -1,4 +1,5 @@ import { Show } from "solid-js"; +import { Tooltip } from "./Tooltip"; export interface StatusDotProps { status: "success" | "pending" | "failure" | "error" | "conflict" | null; @@ -42,7 +43,6 @@ export default function StatusDot(props: StatusDotProps) { const dot = () => ( @@ -57,17 +57,19 @@ export default function StatusDot(props: StatusDotProps) { ); return ( - - {(url) => ( - e.stopPropagation()} - > - {dot()} - - )} - + + + {(url) => ( + e.stopPropagation()} + > + {dot()} + + )} + + ); } diff --git a/src/app/components/shared/Tooltip.tsx b/src/app/components/shared/Tooltip.tsx new file mode 100644 index 00000000..87109645 --- /dev/null +++ b/src/app/components/shared/Tooltip.tsx @@ -0,0 +1,101 @@ +import { createMemo, createSignal, onCleanup } from "solid-js"; +import { Tooltip as KobalteTooltip } from "@kobalte/core/tooltip"; +import type { JSX } from "solid-js"; + +// content is plain string — JSX children are intentionally not supported to avoid needing sanitization + +const TOOLTIP_CONTENT_CLASS = "z-50 max-w-xs rounded bg-neutral px-2 py-1 text-xs text-neutral-content shadow-lg"; + +interface TooltipProps { + content: string; + placement?: "top" | "bottom" | "left" | "right"; + focusable?: boolean; + class?: string; + children: JSX.Element; +} + +export function Tooltip(props: TooltipProps) { + const [isHovered, setIsHovered] = createSignal(false); + const [isFocused, setIsFocused] = createSignal(false); + const open = createMemo(() => isHovered() || isFocused()); + + // openDelay is ignored in controlled mode; implement the delay manually + let hoverTimer: ReturnType | undefined; + let closeTimer: ReturnType | undefined; + onCleanup(() => { + clearTimeout(hoverTimer); + clearTimeout(closeTimer); + }); + + return ( + { + if (!isOpen) { + clearTimeout(hoverTimer); + clearTimeout(closeTimer); + setIsHovered(false); + setIsFocused(false); + } + }} + placement={props.placement ?? "top"} + gutter={4} + > + { + clearTimeout(hoverTimer); + clearTimeout(closeTimer); + hoverTimer = setTimeout(() => setIsHovered(true), 300); + }} + onPointerLeave={() => { + clearTimeout(hoverTimer); + closeTimer = setTimeout(() => setIsHovered(false), 100); + }} + onFocusIn={() => setIsFocused(true)} + onFocusOut={() => setIsFocused(false)} + > + {props.children} + + + + + {props.content} + + + + ); +} + +interface InfoTooltipProps { + content: string; + placement?: "top" | "bottom" | "left" | "right"; +} + +export function InfoTooltip(props: InfoTooltipProps) { + return ( + + + i + + + + + {props.content} + + + + ); +} diff --git a/src/app/components/shared/UserAvatarBadge.tsx b/src/app/components/shared/UserAvatarBadge.tsx index 280619e1..1650ee23 100644 --- a/src/app/components/shared/UserAvatarBadge.tsx +++ b/src/app/components/shared/UserAvatarBadge.tsx @@ -1,4 +1,5 @@ import { createMemo, For, Show } from "solid-js"; +import { Tooltip } from "./Tooltip"; export function buildSurfacedByUsers( surfacedBy: string[] | undefined, @@ -30,17 +31,18 @@ export default function UserAvatarBadge(props: UserAvatarBadgeProps) { > {(u, i) => ( -
0 ? " -ml-1.5" : ""}`} - > -
- {u.login} + +
0 ? " -ml-1.5" : ""}`} + > +
+ {u.login} +
-
+ )}
diff --git a/tests/components/ExpandCollapseButtons.test.tsx b/tests/components/ExpandCollapseButtons.test.tsx index 45e4f819..78dbd8d5 100644 --- a/tests/components/ExpandCollapseButtons.test.tsx +++ b/tests/components/ExpandCollapseButtons.test.tsx @@ -6,15 +6,15 @@ import ExpandCollapseButtons from "../../src/app/components/shared/ExpandCollaps describe("ExpandCollapseButtons", () => { it("renders both buttons with correct aria-labels", () => { render(() => {}} onCollapseAll={() => {}} />); - expect(screen.getByRole("button", { name: "Expand all" })).toBeTruthy(); - expect(screen.getByRole("button", { name: "Collapse all" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Expand all repos" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Collapse all repos" })).toBeTruthy(); }); it("calls onExpandAll when expand button is clicked", async () => { const user = userEvent.setup(); const onExpandAll = vi.fn(); render(() => {}} />); - await user.click(screen.getByRole("button", { name: "Expand all" })); + await user.click(screen.getByRole("button", { name: "Expand all repos" })); expect(onExpandAll).toHaveBeenCalledTimes(1); }); @@ -22,7 +22,7 @@ describe("ExpandCollapseButtons", () => { const user = userEvent.setup(); const onCollapseAll = vi.fn(); render(() => {}} onCollapseAll={onCollapseAll} />); - await user.click(screen.getByRole("button", { name: "Collapse all" })); + await user.click(screen.getByRole("button", { name: "Collapse all repos" })); expect(onCollapseAll).toHaveBeenCalledTimes(1); }); }); diff --git a/tests/components/IssuesTab.test.tsx b/tests/components/IssuesTab.test.tsx index 72088ac1..079fcf6d 100644 --- a/tests/components/IssuesTab.test.tsx +++ b/tests/components/IssuesTab.test.tsx @@ -407,7 +407,7 @@ describe("IssuesTab", () => { expect(screen.queryByText("org/repo-b")).toBeNull(); // Collapse all — only affects visible (filtered) repos - await user.click(screen.getByLabelText("Collapse all")); + await user.click(screen.getByLabelText("Collapse all repos")); expect(screen.queryByText("Alice issue")).toBeNull(); // Remove filter — repo-b should still be expanded (was hidden during collapse-all) @@ -469,7 +469,7 @@ describe("IssuesTab", () => { expect(screen.queryByText("Issue A")).toBeNull(); expect(screen.queryByText("Issue B")).toBeNull(); - await user.click(screen.getByLabelText("Expand all")); + await user.click(screen.getByLabelText("Expand all repos")); screen.getByText("Issue A"); screen.getByText("Issue B"); }); @@ -485,7 +485,7 @@ describe("IssuesTab", () => { screen.getByText("Issue A"); screen.getByText("Issue B"); - await user.click(screen.getByLabelText("Collapse all")); + await user.click(screen.getByLabelText("Collapse all repos")); expect(screen.queryByText("Issue A")).toBeNull(); expect(screen.queryByText("Issue B")).toBeNull(); }); @@ -581,7 +581,7 @@ describe("IssuesTab", () => { screen.getByText(/Page 1 of 2/); // Expand all — affects repos on ALL pages - await user.click(screen.getByLabelText("Expand all")); + await user.click(screen.getByLabelText("Expand all repos")); // Repo-a items visible on page 1 screen.getByText("Repo A issue 0"); diff --git a/tests/components/ItemRow.test.tsx b/tests/components/ItemRow.test.tsx index 4930cd29..0b2185ca 100644 --- a/tests/components/ItemRow.test.tsx +++ b/tests/components/ItemRow.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen } from "@solidjs/testing-library"; +import { render, screen, fireEvent } from "@solidjs/testing-library"; import userEvent from "@testing-library/user-event"; import { createSignal } from "solid-js"; import ItemRow from "../../src/app/components/dashboard/ItemRow"; @@ -104,8 +104,12 @@ describe("ItemRow", () => { it("ignore button has relative z-10 to sit above overlay link", () => { render(() => ); const ignoreBtn = screen.getByLabelText(/Ignore #42/i); - expect(ignoreBtn.className).toContain("relative"); - expect(ignoreBtn.className).toContain("z-10"); + // The Tooltip wrapper span carries relative z-10; the button itself is inside it + const tooltipTrigger = ignoreBtn.closest("span.relative"); + expect(tooltipTrigger).not.toBeNull(); + expect(tooltipTrigger!.className).toContain("z-10"); + expect(tooltipTrigger!.className).toContain("shrink-0"); + expect(tooltipTrigger!.className).toContain("self-center"); }); it("applies compact padding in compact density", () => { @@ -180,15 +184,49 @@ describe("ItemRow", () => { const created = container.querySelector(`time[datetime="${defaultProps.createdAt}"]`); const updated = container.querySelector(`time[datetime="${defaultProps.updatedAt}"]`); expect(created!.textContent).toBe("2h"); - expect(created!.getAttribute("title")).toBe(`Created: ${new Date(defaultProps.createdAt).toLocaleString()}`); expect(updated!.textContent).toBe("30m"); - expect(updated!.getAttribute("title")).toBe(`Updated: ${new Date(defaultProps.updatedAt).toLocaleString()}`); // Middle dot separator is a with aria-hidden const dot = container.querySelector('span[aria-hidden="true"]'); expect(dot).not.toBeNull(); expect(dot!.textContent).toBe("\u00B7"); }); + it("shows date tooltip content on hover", () => { + vi.useFakeTimers(); + const { container, unmount } = render(() => ); + const createdTrigger = container.querySelector( + `time[datetime="${defaultProps.createdAt}"]` + )?.closest("span.inline-flex"); + expect(createdTrigger).not.toBeNull(); + fireEvent.pointerEnter(createdTrigger!); + vi.advanceTimersByTime(300); + expect(document.body.textContent).toContain( + `Created: ${new Date(defaultProps.createdAt).toLocaleString()}` + ); + fireEvent.pointerLeave(createdTrigger!); + vi.advanceTimersByTime(500); + unmount(); + vi.useRealTimers(); + }); + + it("shows updated date tooltip content on hover", () => { + vi.useFakeTimers(); + const { container, unmount } = render(() => ); + const updatedTrigger = container.querySelector( + `time[datetime="${defaultProps.updatedAt}"]` + )?.closest("span.inline-flex"); + expect(updatedTrigger).not.toBeNull(); + fireEvent.pointerEnter(updatedTrigger!); + vi.advanceTimersByTime(300); + expect(document.body.textContent).toContain( + `Updated: ${new Date(defaultProps.updatedAt).toLocaleString()}` + ); + fireEvent.pointerLeave(updatedTrigger!); + vi.advanceTimersByTime(500); + unmount(); + vi.useRealTimers(); + }); + it("shows single date when createdAt equals updatedAt (zero diff)", () => { const sameDate = "2026-03-30T11:00:00Z"; const { container } = render(() => ( @@ -276,6 +314,45 @@ describe("ItemRow", () => { expect(updated!.getAttribute("aria-label")).toMatch(/^Updated 30 minutes? ago$/); }); + it("shows comment count tooltip with correct pluralization", () => { + vi.useFakeTimers(); + const { container, unmount } = render(() => ( + + )); + const tooltipTriggers = container.querySelectorAll("span.inline-flex"); + const commentTrigger = Array.from(tooltipTriggers).find( + (el) => el.textContent?.includes("5") + ); + expect(commentTrigger).not.toBeNull(); + fireEvent.pointerEnter(commentTrigger!); + vi.advanceTimersByTime(300); + expect(document.body.textContent).toContain("5 total comments"); + fireEvent.pointerLeave(commentTrigger!); + vi.advanceTimersByTime(500); + unmount(); + vi.useRealTimers(); + }); + + it("shows singular 'comment' for commentCount=1", () => { + vi.useFakeTimers(); + const { container, unmount } = render(() => ( + + )); + const tooltipTriggers = container.querySelectorAll("span.inline-flex"); + const commentTrigger = Array.from(tooltipTriggers).find( + (el) => el.textContent?.trim() === "1" + ); + expect(commentTrigger).not.toBeNull(); + fireEvent.pointerEnter(commentTrigger!); + vi.advanceTimersByTime(300); + expect(document.body.textContent).toContain("1 total comment"); + expect(document.body.textContent).not.toContain("1 total comments"); + fireEvent.pointerLeave(commentTrigger!); + vi.advanceTimersByTime(500); + unmount(); + vi.useRealTimers(); + }); + it("refreshTick forces time display update", () => { const [tick, setTick] = createSignal(0); let mockNow = MOCK_NOW; diff --git a/tests/components/PullRequestsTab.test.tsx b/tests/components/PullRequestsTab.test.tsx index f52f9cf5..a5fc26b6 100644 --- a/tests/components/PullRequestsTab.test.tsx +++ b/tests/components/PullRequestsTab.test.tsx @@ -509,7 +509,7 @@ describe("PullRequestsTab", () => { expect(screen.queryByText("PR in repo A")).toBeNull(); expect(screen.queryByText("PR in repo B")).toBeNull(); - await user.click(screen.getByLabelText("Expand all")); + await user.click(screen.getByLabelText("Expand all repos")); screen.getByText("PR in repo A"); screen.getByText("PR in repo B"); @@ -527,7 +527,7 @@ describe("PullRequestsTab", () => { screen.getByText("PR in repo A"); screen.getByText("PR in repo B"); - await user.click(screen.getByLabelText("Collapse all")); + await user.click(screen.getByLabelText("Collapse all repos")); expect(screen.queryByText("PR in repo A")).toBeNull(); expect(screen.queryByText("PR in repo B")).toBeNull(); @@ -618,7 +618,7 @@ describe("PullRequestsTab", () => { screen.getByText(/Page 1 of 2/); // Expand all — affects repos on ALL pages - await user.click(screen.getByLabelText("Expand all")); + await user.click(screen.getByLabelText("Expand all repos")); // Repo-a items visible on page 1 screen.getByText("Repo A PR 0"); diff --git a/tests/components/WorkflowRunRow.test.tsx b/tests/components/WorkflowRunRow.test.tsx index fc8efa2a..c139c951 100644 --- a/tests/components/WorkflowRunRow.test.tsx +++ b/tests/components/WorkflowRunRow.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen } from "@solidjs/testing-library"; +import { render, screen, fireEvent } from "@solidjs/testing-library"; import userEvent from "@testing-library/user-event"; import { createSignal } from "solid-js"; import WorkflowRunRow from "../../src/app/components/dashboard/WorkflowRunRow"; @@ -36,10 +36,29 @@ describe("WorkflowRunRow", () => { const timeEl = container.querySelector("time"); expect(timeEl).not.toBeNull(); expect(timeEl!.getAttribute("datetime")).toBe(createdAt); - expect(timeEl!.getAttribute("title")).toBe(`Created: ${new Date(createdAt).toLocaleString()}`); expect(timeEl!.textContent).toMatch(/2 hours? ago/); }); + it("shows date tooltip content on hover", () => { + vi.useFakeTimers(); + const createdAt = new Date(MOCK_NOW - 2 * 60 * 60 * 1000).toISOString(); + const run = makeWorkflowRun({ createdAt }); + const { container, unmount } = render(() => ( + {}} density="comfortable" /> + )); + const timeTrigger = container.querySelector("time")?.closest("span.inline-flex"); + expect(timeTrigger).not.toBeNull(); + fireEvent.pointerEnter(timeTrigger!); + vi.advanceTimersByTime(300); + expect(document.body.textContent).toContain( + `Created: ${new Date(createdAt).toLocaleString()}` + ); + fireEvent.pointerLeave(timeTrigger!); + vi.advanceTimersByTime(500); + unmount(); + vi.useRealTimers(); + }); + it("updates time display when refreshTick changes", () => { let mockNow = MOCK_NOW; vi.mocked(Date.now).mockImplementation(() => mockNow); diff --git a/tests/components/dashboard/PersonalSummaryStrip.test.tsx b/tests/components/dashboard/PersonalSummaryStrip.test.tsx index cd1078e8..aecb6039 100644 --- a/tests/components/dashboard/PersonalSummaryStrip.test.tsx +++ b/tests/components/dashboard/PersonalSummaryStrip.test.tsx @@ -64,6 +64,12 @@ describe("PersonalSummaryStrip — assigned issues", () => { expect(screen.getByText(/assigned/)).toBeDefined(); }); + it("shows InfoTooltip 'More information' button when counts are visible", () => { + const issues = [makeIssue({ assigneeLogins: ["me"] })]; + renderStrip({ issues }); + expect(screen.getByRole("button", { name: "More information" })).toBeTruthy(); + }); + it("does not count issues where user is not assigned", () => { const issues = [ makeIssue({ assigneeLogins: ["other-user"] }), diff --git a/tests/components/shared-badges.test.tsx b/tests/components/shared-badges.test.tsx index bf4193d8..82a78018 100644 --- a/tests/components/shared-badges.test.tsx +++ b/tests/components/shared-badges.test.tsx @@ -1,5 +1,5 @@ -import { describe, it, expect } from "vitest"; -import { render, screen } from "@solidjs/testing-library"; +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@solidjs/testing-library"; import RoleBadge from "../../src/app/components/shared/RoleBadge"; import ReviewBadge from "../../src/app/components/shared/ReviewBadge"; import SizeBadge from "../../src/app/components/shared/SizeBadge"; @@ -84,4 +84,19 @@ describe("SizeBadge", () => { render(() => ); screen.getByText("XS"); }); + + it("shows tooltip with size description on hover", () => { + vi.useFakeTimers(); + const { container } = render(() => ( + + )); + const trigger = container.querySelector("span.inline-flex"); + expect(trigger).not.toBeNull(); + fireEvent.pointerEnter(trigger!); + vi.advanceTimersByTime(300); + expect(document.body.textContent).toContain("XS: <10 lines changed"); + fireEvent.pointerLeave(trigger!); + vi.advanceTimersByTime(500); + vi.useRealTimers(); + }); }); diff --git a/tests/components/shared/StatusDot.test.tsx b/tests/components/shared/StatusDot.test.tsx index 432c17ed..4741ead6 100644 --- a/tests/components/shared/StatusDot.test.tsx +++ b/tests/components/shared/StatusDot.test.tsx @@ -5,36 +5,51 @@ import StatusDot from "../../../src/app/components/shared/StatusDot"; describe("StatusDot", () => { it('shows "All checks passed" label for status="success"', () => { const { container } = render(() => ); - const wrapper = container.querySelector("span"); + const wrapper = container.querySelector("[aria-label]"); expect(wrapper?.getAttribute("aria-label")).toBe("All checks passed"); - expect(wrapper?.getAttribute("title")).toBe("All checks passed"); }); it('shows "Checks in progress" label for status="pending"', () => { const { container } = render(() => ); - const wrapper = container.querySelector("span"); + const wrapper = container.querySelector("[aria-label]"); expect(wrapper?.getAttribute("aria-label")).toBe("Checks in progress"); - expect(wrapper?.getAttribute("title")).toBe("Checks in progress"); }); it('shows "Checks failing" label for status="failure"', () => { const { container } = render(() => ); - const wrapper = container.querySelector("span"); + const wrapper = container.querySelector("[aria-label]"); expect(wrapper?.getAttribute("aria-label")).toBe("Checks failing"); }); it('shows "Checks failing" label for status="error"', () => { const { container } = render(() => ); - const wrapper = container.querySelector("span"); + const wrapper = container.querySelector("[aria-label]"); expect(wrapper?.getAttribute("aria-label")).toBe("Checks failing"); }); it('shows "No checks" label for status=null', () => { const { container } = render(() => ); - const wrapper = container.querySelector("span"); + const wrapper = container.querySelector("[aria-label]"); expect(wrapper?.getAttribute("aria-label")).toBe("No checks"); }); + it('shows "Checks blocked by merge conflict" label for status="conflict"', () => { + const { container } = render(() => ); + const wrapper = container.querySelector("[aria-label]"); + expect(wrapper?.getAttribute("aria-label")).toBe("Checks blocked by merge conflict"); + }); + + it("wraps dot in a link when href is provided", () => { + const { container } = render(() => ( + + )); + const link = container.querySelector("a"); + expect(link).not.toBeNull(); + expect(link!.getAttribute("href")).toBe("https://github.com/owner/repo/checks"); + expect(link!.getAttribute("target")).toBe("_blank"); + expect(link!.getAttribute("rel")).toBe("noopener noreferrer"); + }); + it("has animate-slow-pulse class only for status=pending", () => { const { container: pendingContainer } = render(() => ); expect(pendingContainer.querySelector(".animate-slow-pulse")).not.toBeNull(); diff --git a/tests/components/shared/Tooltip.test.tsx b/tests/components/shared/Tooltip.test.tsx new file mode 100644 index 00000000..331db213 --- /dev/null +++ b/tests/components/shared/Tooltip.test.tsx @@ -0,0 +1,212 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { render, screen, fireEvent } from "@solidjs/testing-library"; +import { Tooltip, InfoTooltip } from "../../../src/app/components/shared/Tooltip"; + +describe("Tooltip", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("renders children correctly", () => { + render(() => ( + + + + )); + expect(screen.getByRole("button", { name: "Click me" })).toBeTruthy(); + }); + + it("trigger has inline-flex class", () => { + const { container } = render(() => ( + + Child + + )); + const trigger = container.querySelector("span.inline-flex"); + expect(trigger).not.toBeNull(); + }); + + it("focusable prop adds tabindex='0' to trigger span", () => { + const { container } = render(() => ( + + Badge + + )); + const trigger = container.querySelector("span.inline-flex"); + expect(trigger?.getAttribute("tabindex")).toBe("0"); + }); + + it("without focusable, trigger span has no tabindex", () => { + const { container } = render(() => ( + + Badge + + )); + const trigger = container.querySelector("span.inline-flex"); + expect(trigger?.hasAttribute("tabindex")).toBe(false); + }); + + it("class prop merges with trigger span classes", () => { + const { container } = render(() => ( + + Child + + )); + const trigger = container.querySelector("span.inline-flex"); + expect(trigger?.classList.contains("inline-flex")).toBe(true); + expect(trigger?.classList.contains("relative")).toBe(true); + expect(trigger?.classList.contains("z-10")).toBe(true); + }); + + it("tooltip content is not visible before hover", () => { + render(() => ( + + Trigger + + )); + expect(document.body.textContent).not.toContain("tooltip text"); + }); + + it("shows tooltip content after 300ms hover delay", () => { + const { container } = render(() => ( + + Trigger + + )); + const trigger = container.querySelector("span.inline-flex")!; + fireEvent.pointerEnter(trigger); + // Content should not be visible before delay fires + expect(document.body.textContent).not.toContain("tooltip text"); + vi.advanceTimersByTime(300); + expect(document.body.textContent).toContain("tooltip text"); + }); + + it("cancels tooltip if pointer leaves before 300ms delay", () => { + const { container } = render(() => ( + + Trigger + + )); + const trigger = container.querySelector("span.inline-flex")!; + fireEvent.pointerEnter(trigger); + vi.advanceTimersByTime(150); + fireEvent.pointerLeave(trigger); + vi.advanceTimersByTime(300); + expect(document.body.textContent).not.toContain("tooltip text"); + }); + + it("closes tooltip state when pointer leaves after it is visible", () => { + const { container } = render(() => ( + + Trigger + + )); + const trigger = container.querySelector("span.inline-flex")!; + fireEvent.pointerEnter(trigger); + vi.advanceTimersByTime(300); + expect(trigger.getAttribute("data-expanded")).toBe(""); + fireEvent.pointerLeave(trigger); + // closeDelay is 100ms. Advance 300ms to cover both the 100ms closeDelay and Kobalte's + // globalSkipDelayTimeout (300ms), so global tooltip state resets before the next test. + vi.advanceTimersByTime(300); + expect(trigger.hasAttribute("data-expanded")).toBe(false); + }); + + it("re-entering during closeDelay cancels the close timer", () => { + const { container } = render(() => ( + + Trigger + + )); + const trigger = container.querySelector("span.inline-flex")!; + fireEvent.pointerEnter(trigger); + vi.advanceTimersByTime(300); + expect(trigger.getAttribute("data-expanded")).toBe(""); + fireEvent.pointerLeave(trigger); + // Re-enter within 100ms closeDelay — should cancel close + vi.advanceTimersByTime(50); + fireEvent.pointerEnter(trigger); + // The tooltip should still be expanded (closeTimer was cancelled before it fired) + expect(trigger.getAttribute("data-expanded")).toBe(""); + // Clean up: close the tooltip so Kobalte's global state resets + fireEvent.pointerLeave(trigger); + vi.advanceTimersByTime(500); + }); + + it("shows tooltip on focusIn (keyboard access)", () => { + const { container } = render(() => ( + + Badge + + )); + const trigger = container.querySelector("span.inline-flex")!; + fireEvent.focusIn(trigger); + expect(document.body.textContent).toContain("focus tooltip"); + }); + + it("closes tooltip state on focusOut", () => { + const { container } = render(() => ( + + Badge + + )); + const trigger = container.querySelector("span.inline-flex")!; + fireEvent.focusIn(trigger); + expect(trigger.getAttribute("data-expanded")).toBe(""); + fireEvent.focusOut(trigger); + expect(trigger.hasAttribute("data-expanded")).toBe(false); + }); + + it("closes tooltip on Escape key via onOpenChange", () => { + const { container } = render(() => ( + + Badge + + )); + const trigger = container.querySelector("span.inline-flex")!; + // Open via focus + fireEvent.focusIn(trigger); + expect(trigger.getAttribute("data-expanded")).toBe(""); + // Escape should close via Kobalte's onOpenChange(false) + fireEvent.keyDown(trigger, { key: "Escape" }); + expect(trigger.hasAttribute("data-expanded")).toBe(false); + // Advance past Kobalte's globalSkipDelayTimeout (300ms) so global state resets + vi.advanceTimersByTime(500); + }); +}); + +describe("InfoTooltip", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("renders (i) button with aria-label 'More information'", () => { + render(() => ); + const btn = screen.getByRole("button", { name: "More information" }); + expect(btn).toBeTruthy(); + expect(btn.textContent?.trim()).toBe("i"); + }); + + it("button has cursor-help class", () => { + render(() => ); + const btn = screen.getByRole("button", { name: "More information" }); + expect(btn.classList.contains("cursor-help")).toBe(true); + }); + + it("shows tooltip content after hover (openDelay=300ms)", () => { + render(() => ); + const btn = screen.getByRole("button", { name: "More information" }); + fireEvent.pointerEnter(btn); + expect(document.body.textContent).not.toContain("helpful info text"); + vi.advanceTimersByTime(300); + expect(document.body.textContent).toContain("helpful info text"); + }); +}); diff --git a/tests/components/shared/UserAvatarBadge.test.tsx b/tests/components/shared/UserAvatarBadge.test.tsx index 4e7239fb..484a302e 100644 --- a/tests/components/shared/UserAvatarBadge.test.tsx +++ b/tests/components/shared/UserAvatarBadge.test.tsx @@ -1,5 +1,5 @@ -import { describe, it, expect } from "vitest"; -import { render, screen } from "@solidjs/testing-library"; +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@solidjs/testing-library"; import UserAvatarBadge from "../../../src/app/components/shared/UserAvatarBadge"; describe("UserAvatarBadge", () => { @@ -59,15 +59,22 @@ describe("UserAvatarBadge", () => { expect(imgs[0].getAttribute("alt")).toBe("tracked1"); }); - it("shows tooltip with login via title attribute", () => { - render(() => ( + it("shows 'Surfaced by' tooltip on hover", () => { + vi.useFakeTimers(); + const { container } = render(() => ( )); - const img = screen.getByRole("img"); - expect(img.getAttribute("title")).toBe("octocat"); + const trigger = container.querySelector("span.inline-flex"); + expect(trigger).not.toBeNull(); + fireEvent.pointerEnter(trigger!); + vi.advanceTimersByTime(300); + expect(document.body.textContent).toContain("Surfaced by octocat"); + fireEvent.pointerLeave(trigger!); + vi.advanceTimersByTime(500); + vi.useRealTimers(); }); it("uses avatarUrl as img src", () => {