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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 41 additions & 35 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,37 +15,43 @@ source.
Reading the repo reveals several features that are declared but not working,
or working incorrectly:

**The dashboard lies.** Pagination exists and parses the `Link` header
correctly, but both `DashboardPage` and `RepoDetailPage` throw the result
away. Every repo shows at most 30 issues and 30 PRs. A repo with 200 open
issues looks identical to one with 5. This is the most damaging bug: the
tool's one job is to show you what needs attention, and it can't do that
with incomplete data.

**Auto-refresh is dead code.** The interval is defined, the settings UI has
a dropdown, the timer dependency is in `Cargo.toml`. But nothing wires them
together — the dashboard never re-fetches on its own.

**No latest commit time.** The spec says dashboard cards should show last
commit time. The repo metadata already carries `pushed_at`, but nothing
displays it.

**Repo detail wastes rate limit.** Both issues and PRs fire on mount
regardless of which tab is active. Looking at issues also pays for a PRs
fetch you won't see.

**Missing filters.** Only state (open/closed/all) is filterable on repo
detail. Label, author, and sort are not wired — the data structures support
them, the UI doesn't expose them.
**The dashboard lies.** ~~Pagination exists and parses the `Link` header~~
~~correctly, but both `DashboardPage` and `RepoDetailPage` throw the result~~
~~away.~~ ✅ Fixed: dashboard now shows upper-bound totals derived from the
`Link` header's `rel="last"` page count. Repo detail has a "Load more"
button following `rel="next"`.

**Auto-refresh is dead code.** ~~The interval is defined, the settings UI has~~
~~a dropdown, the timer dependency is in `Cargo.toml`. But nothing wires them~~
~~together — the dashboard never re-fetches on its own.~~ ✅ Fixed:
`RefreshInterval` setting is wired to an actual `setInterval` timer in the
dashboard. Pauses when rate limit is near exhaustion.

**No latest commit time.** ~~The spec says dashboard cards should show last~~
~~commit time. The repo metadata already carries `pushed_at`, but nothing~~
~~displays it.~~ ✅ Already working: `pushed_at` is displayed on both table
and card views via `last_push_label()`.

**Repo detail wastes rate limit.** ~~Both issues and PRs fire on mount~~
~~regardless of which tab is active. Looking at issues also pays for a PRs~~
~~fetch you won't see.~~ ✅ Fixed: only the active tab triggers a fetch.
Switching tabs triggers the fetch for that tab.

**Missing filters.** ~~Only state (open/closed/all) is filterable on repo~~
~~detail. Label, author, and sort are not wired — the data structures support~~
~~them, the UI doesn't expose them.~~ ✅ Fixed: label, author (creator),
sort, and state filters are all wired in the repo detail toolbar.

**Borrowed visual identity.** DESIGN.md describes Pinterest's marketing
surfaces. The CSS tokens are Pinterest's values. Lepo has no look of its own.

**Single breakpoint.** Only 768px. No tablet, no narrow-mobile handling.

**No tests for logic that matters.** 19 unit tests exist, all serde
deserialization. The API layer has no mocks. Error classification, rate-limit
edge cases, and conversions are untested.
**No tests for logic that matters.** ~~19 unit tests exist, all serde~~
~~deserialization. The API layer has no mocks. Error classification, rate-limit~~
~~edge cases, and conversions are untested.~~ ✅ Fixed: 40 offline tests now
cover `map_status`, error display, pagination edge cases, and query-string
encoding. Zero network calls in tests.

---

Expand All @@ -59,20 +65,20 @@ regardless of how good its design system is.

Fix the things that make Lepo show wrong information.

- [ ] **Paginate the dashboard.** Fetch more than 30 items per repo. Follow
- [x] **Paginate the dashboard.** Fetch more than 30 items per repo. Follow
the `Link` header for subsequent pages. Cap at a reasonable limit — beyond
a certain count, the number itself ("200+ open issues") is more useful
than the full list.
- [ ] **Paginate repo detail.** "Load more" button driven by the `Link`
- [x] **Paginate repo detail.** "Load more" button driven by the `Link`
header. Never guess the page number.
- [ ] **Wire auto-refresh.** Connect the existing `RefreshInterval` setting
- [x] **Wire auto-refresh.** Connect the existing `RefreshInterval` setting
to an actual timer. Pause when the rate limit is nearly exhausted;
resume when it recovers.
- [ ] **Show last commit time.** The repo metadata already has `pushed_at`.
Surface it on dashboard cards.
- [ ] **Lazy-load the inactive tab.** Only fetch the tab the user is
- [x] **Show last commit time.** The repo metadata already has `pushed_at`.
Surface it on dashboard cards. (Already working.)
- [x] **Lazy-load the inactive tab.** Only fetch the tab the user is
looking at. Switching tabs triggers the fetch.
- [ ] **Wire the missing filters.** Expose label, author, and sort on the
- [x] **Wire the missing filters.** Expose label, author, and sort on the
repo detail page.

**Acceptance:** a repo with many issues shows them all (paginated);
Expand All @@ -84,12 +90,12 @@ a fetch; label/author/sort filters exist on repo detail.
The pagination refactor and filter additions touch the API layer heavily.
Tests catch regressions before users do.

- [ ] **Mock the API layer.** Hand-rolled mock of the `GithubApi` trait for
- [x] **Mock the API layer.** Hand-rolled mock of the `GithubApi` trait for
tests. Cover URL construction, query-string encoding, error
classification, and pagination edge cases. All tests run offline.
- [ ] **Test error conversions.** Every API error variant maps to the
- [x] **Test error conversions.** Every API error variant maps to the
correct app error variant with a human-readable message.
- [ ] **Test edge cases in core types.** Input validation, rate-limit
- [x] **Test edge cases in core types.** Input validation, rate-limit
math at boundary values.

**Acceptance:** `cargo test --workspace --exclude app` passes with
Expand Down
13 changes: 6 additions & 7 deletions crates/app/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,13 @@ pub fn App() -> impl IntoView {

// Reflect the theme onto the document root so `:root[data-theme]` CSS applies.
let theme_attr = move || settings.theme.get().as_attr();
leptos::prelude::create_effect(move |_| {
leptos::prelude::Effect::new(move |_| {
let value = theme_attr();
if let Some(win) = web_sys::window() {
if let Some(doc) = win.document() {
if let Some(root) = doc.document_element() {
let _ = root.set_attribute("data-theme", value);
}
}
if let Some(win) = web_sys::window()
&& let Some(doc) = win.document()
&& let Some(root) = doc.document_element()
{
let _ = root.set_attribute("data-theme", value);
}
});

Expand Down
2 changes: 1 addition & 1 deletion crates/app/src/components/issue_row.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ pub fn IssueRow(issue: Issue) -> impl IntoView {
.unwrap_or_default();
let comments = issue.comments;
let labels = issue.labels.clone();
let url = issue.html_url.clone();
let url = issue.html_url;

view! {
<a class="row row--issue" href=url target="_blank" rel="noopener noreferrer">
Expand Down
2 changes: 1 addition & 1 deletion crates/app/src/components/pr_row.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ pub fn PrRow(pr: PullRequest) -> impl IntoView {
let comments = pr.comments;
let draft = pr.draft;
let labels = pr.labels.clone();
let url = pr.html_url.clone();
let url = pr.html_url;

view! {
<a class="row row--pr" href=url target="_blank" rel="noopener noreferrer">
Expand Down
22 changes: 12 additions & 10 deletions crates/app/src/components/rate_limit_badge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@ pub fn RateLimitBadge() -> impl IntoView {
let rate = expect_context::<RateLimitState>();

let meter = move || {
let (level, text, title) = match rate.limit.get() {
None => (
"rl-meter-unknown".to_string(),
"API limit ?".to_string(),
String::new(),
),
Some(rl) => {
let (level, text, title) = rate.limit.get().map_or_else(
|| {
(
"rl-meter-unknown".to_string(),
"API limit ?".to_string(),
String::new(),
)
},
|rl| {
let pct = rl.fraction_remaining();
let level = if rl.remaining == 0 || pct < 0.1 {
"rl-meter-red".to_string()
Expand All @@ -36,7 +38,7 @@ pub fn RateLimitBadge() -> impl IntoView {
if delta <= 0 {
"resets now".to_string()
} else if delta < 60 {
format!("resets in {}s", delta)
format!("resets in {delta}s")
} else if delta < 3600 {
format!("resets in {}m", delta / 60)
} else {
Expand All @@ -50,8 +52,8 @@ pub fn RateLimitBadge() -> impl IntoView {
rl.remaining, rl.limit, reset_label
);
(level, format!("{} left", rl.remaining), title)
}
};
},
);
view! {
<span class=level title=title>
<span class="rl-dot"></span>
Expand Down
46 changes: 29 additions & 17 deletions crates/app/src/components/repo_card.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use models::{Issue, PullRequest, Repo, WorkflowConclusion, WorkflowRun, Workflow
use crate::state::RepoRef;

/// Data needed to render a [`RepoCard`].
#[derive(Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct RepoCardData {
/// The repo reference.
pub r#ref: RepoRef,
Expand All @@ -19,17 +19,31 @@ pub struct RepoCardData {
pub pulls: Vec<PullRequest>,
/// Latest CI status (most recent workflow run), if any.
pub ci: Option<WorkflowRun>,
/// Total number of open issues across all pages (0 = use `issues.len()`).
#[serde(default)]
pub total_open_issues: usize,
/// Total number of open PRs across all pages (0 = use `pulls.len()`).
#[serde(default)]
pub total_open_prs: usize,
}

impl RepoCardData {
/// Open issues excluding pull requests.
/// Open issues excluding pull requests. Uses the total count when available.
pub fn open_issues(&self) -> usize {
self.issues.iter().filter(|i| !i.is_pr()).count()
if self.total_open_issues > 0 {
self.total_open_issues
} else {
self.issues.iter().filter(|i| !i.is_pr()).count()
}
}

/// Open pull requests.
pub fn open_prs(&self) -> usize {
self.pulls.len()
/// Open pull requests. Uses the total count when available.
pub const fn open_prs(&self) -> usize {
if self.total_open_prs > 0 {
self.total_open_prs
} else {
self.pulls.len()
}
}

/// Human-readable "last push" label from the repo metadata.
Expand All @@ -38,15 +52,15 @@ impl RepoCardData {
.repo
.as_ref()
.and_then(|r| r.pushed_at.as_ref())
.map(format_relative)
.unwrap_or_else(|| "—".to_string())
.map_or_else(|| "—".to_string(), format_relative)
}

/// CI status as a (dot-class, label) pair for the badge.
pub fn ci_badge(&self) -> (&'static str, &'static str) {
match &self.ci {
None => ("ci-dot--none", "—"),
Some(run) => match run.status {
self
.ci
.as_ref()
.map_or(("ci-dot--none", "—"), |run| match run.status {
WorkflowStatus::Completed => match run.conclusion {
Some(WorkflowConclusion::Success) => ("ci-dot--pass", "Pass"),
Some(WorkflowConclusion::Failure) => ("ci-dot--fail", "Fail"),
Expand All @@ -61,8 +75,7 @@ impl RepoCardData {
}
WorkflowStatus::Cancelled => ("ci-dot--run", "Cancel"),
WorkflowStatus::Other => ("ci-dot--none", "—"),
},
}
})
}
}

Expand All @@ -89,8 +102,8 @@ fn format_relative(ts: &chrono::DateTime<chrono::Utc>) -> String {
pub fn RepoCard(data: RepoCardData) -> impl IntoView {
let open_issues = data.issues.iter().filter(|i| !i.is_pr()).count();
let open_prs = data.pulls.len();
let stars = data.repo.as_ref().map(|r| r.stargazers_count).unwrap_or(0);
let forks = data.repo.as_ref().map(|r| r.forks_count).unwrap_or(0);
let stars = data.repo.as_ref().map_or(0, |r| r.stargazers_count);
let forks = data.repo.as_ref().map_or(0, |r| r.forks_count);
let ref_str = data.r#ref.as_str();
let ref_str_clone = ref_str.clone();

Expand All @@ -105,8 +118,7 @@ pub fn RepoCard(data: RepoCardData) -> impl IntoView {
href=data
.repo
.as_ref()
.map(|r| r.html_url.clone())
.unwrap_or_else(|| format!("https://github.com/{}", ref_str_clone))
.map_or_else(|| format!("https://github.com/{ref_str_clone}"), |r| r.html_url.clone())
target="_blank"
rel="noopener noreferrer"
title="Open on GitHub"
Expand Down
2 changes: 1 addition & 1 deletion crates/app/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,6 @@ pub enum AppError {

impl From<github_api::ApiError> for AppError {
fn from(e: github_api::ApiError) -> Self {
AppError::Api(e.to_string())
Self::Api(e.to_string())
}
}
Loading