Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
629f70a
fix(github-api): enforce a 15s timeout on every HTTP request
suradet-ps Aug 7, 2026
8586c71
fix(github-api): capture rate-limit headers from error responses too
suradet-ps Aug 7, 2026
0be5a2c
refactor(repo-detail): route cursor page fetches through GithubClient
suradet-ps Aug 7, 2026
38785e5
fix(dashboard): exclude PRs from paginated issue totals, mark upper b…
suradet-ps Aug 7, 2026
90defea
fix(dashboard): auto-refresh timer no longer reset by unrelated respo…
suradet-ps Aug 7, 2026
a7ef821
fix(repo-detail): discard stale fetch responses after filter changes
suradet-ps Aug 7, 2026
b89e9ce
fix(repo-detail,dashboard): surface fetch errors instead of silent fa…
suradet-ps Aug 7, 2026
de818f9
fix(css): define missing typography tokens, match login class, size s…
suradet-ps Aug 7, 2026
275cb12
feat(repo-detail): show author avatar and last-updated date on issue/…
suradet-ps Aug 7, 2026
2e81962
fix(settings): bind the interval select to state and add the theme se…
suradet-ps Aug 7, 2026
e9eaca7
fix(auth): logout removes only the token, not watchlist and settings
suradet-ps Aug 7, 2026
5070a69
fix(routing): gate every page behind the login state, not just the da…
suradet-ps Aug 7, 2026
b6cf701
test(github-api): unit-test URL construction for every endpoint
suradet-ps Aug 7, 2026
cd8a384
docs(roadmap): reflect reality after the design revert and URL tests
suradet-ps Aug 7, 2026
c8bfac3
style: apply cargo fmt to the new code
suradet-ps Aug 7, 2026
f3b6b8e
refactor(settings): drop the theme section, keep the navbar toggle
suradet-ps Aug 7, 2026
3355c41
refactor(ui): replace skeleton loaders with a simple loading line
suradet-ps Aug 7, 2026
403a870
docs(design): loading state is a text line, not skeletons
suradet-ps Aug 7, 2026
4501b40
fix(css): space out the repo-detail filter controls
suradet-ps Aug 7, 2026
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
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 5 additions & 3 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,10 +215,12 @@ by default.
- Status dot (8px) + remaining count.
- Green when > 50% remaining, amber when 10-50%, red when < 10%.

### Skeleton Loading
### Loading

- Background `{colors.secondary-bg}` with shimmer animation.
- Shape matches the content it replaces.
- Muted "Loading…" text (`{colors-mute}`, `{typography.body-md}`),
centered, while data is being fetched.
- Buttons that trigger work (e.g. "Load more", "Adding…") use the
inline spinner.

## Layout

Expand Down
46 changes: 26 additions & 20 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,16 +44,19 @@ 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.~~
✅ Fixed: DESIGN.md rewritten with Lepo's dark-first monitoring identity. CSS
tokens retuned. Sky blue accent. Zero hardcoded hex.
❌ Partially fixed, then reverted: the dark-first/sky-blue rework landed, but
`294d214` rolled it back to a warm-cream, light-first palette with Pinterest
Red as the only interactive accent. The identity work needs a redo.

**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.~~ ✅ Fixed: 40 offline tests now
cover `map_status`, error display, pagination edge cases, and query-string
encoding. Zero network calls in tests.
~~edge cases, and conversions are untested.~~ ✅ Fixed: 48 offline tests now
cover `map_status`, error display, pagination edge cases, query-string
encoding, and URL construction. Zero network calls in tests. (No trait mock
— URL/query logic lives in pure, unit-tested functions; the `GithubApi`
trait stays mockable for future integration tests.)

---

Expand Down Expand Up @@ -92,36 +95,39 @@ 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.

- [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.
- [x] **Test URL construction and query encoding.** Pure URL-builder
functions for every endpoint (`/user`, `/repos/{o}/{r}`, issues, pulls,
workflow runs) with default and full-parameter unit tests.
- [x] **Test error conversions.** Every API error variant maps to the
correct app error variant with a human-readable message.
- [x] **Test edge cases in core types.** Input validation, rate-limit
math at boundary values.

**Acceptance:** `cargo test --workspace --exclude app` passes with
mock-based API tests. No test hits `api.github.com`.
offline unit tests covering URL construction, error mapping, and
pagination. No test hits `api.github.com`.

### 3. Give Lepo its own look

A monitoring dashboard needs to feel dense, fast-scanning, and calm — not
like a social media platform.

- [x] **Rewrite `DESIGN.md`.** ~~Dark-first, information-dense, warm grays,~~
- [ ] **Rewrite `DESIGN.md`.** ~~Dark-first, information-dense, warm grays,~~
~~tight type, tabular numerics for data. One accent color for actions.~~
~~Document why these choices serve a monitoring surface.~~ ✅ Done: dark-first,
cool-neutral palette, sky blue accent, tabular numerics, 44px table rows.
- [x] **Retune CSS tokens.** ~~Replace Pinterest's values. Keep token names.~~
~~Dark mode is default; light mode inverts.~~ ✅ Done: dark is `:root`,
light inverts via `[data-theme="light"]`.
- [x] **Eliminate hardcoded hex.** ~~Every color routes through a token.~~
~~CI enforces this.~~ ✅ Done: all colors via CSS custom properties.
- [x] **Distinct favicon and wordmark.** ✅ Done: SVG brand mark used as
favicon and nav logo.
~~Document why these choices serve a monitoring surface.~~ ❌ Reverted in
`294d214`: DESIGN.md documents a warm-cream, light-first palette with
Pinterest Red as the sole interactive accent.
- [ ] **Retune CSS tokens.** ~~Replace Pinterest's values. Keep token names.~~
~~Dark mode is default; light mode inverts.~~ ❌ Reverted: `:root` is the
light warm-cream palette; dark inverts via `[data-theme="dark"]`.
- [x] **Eliminate hardcoded hex.** ✅ Done: all colors via CSS custom
properties.
- [x] **Distinct favicon and wordmark.** ✅ Done: inline SVG brand mark used
as favicon and nav logo.

**Acceptance:** ~~DESIGN.md describes Lepo; zero inline hex in CSS (CI
enforced); both themes render from tokens alone.~~ ✅ All items complete.
enforced); both themes render from tokens alone.~~ ❌ Not met — the
dark-first/sky-blue identity was reverted in `294d214`; needs a redo.

### 4. Responsive and mobile

Expand Down
44 changes: 29 additions & 15 deletions crates/app/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,22 @@ use leptos_router::{

use crate::components::rate_limit_badge::RateLimitBadge;
use crate::pages::{DashboardPage, LoginPage, RepoDetailPage, SettingsPage};
use crate::state::{AuthState, RateLimitState, SettingsState, Theme, WatchlistState};
use crate::state::{AuthState, AuthStatus, RateLimitState, SettingsState, Theme, WatchlistState};

/// Renders `children` only while logged in; otherwise shows the login page.
#[component]
fn AuthedView(children: ChildrenFn) -> impl IntoView {
let auth = expect_context::<AuthState>();
view! {
{move || {
if matches!(auth.status.get(), AuthStatus::LoggedIn(_)) {
children().into_any()
} else {
view! { <LoginPage/> }.into_any()
}
}}
}
}

/// The root component. Provides global state via context and renders the router.
#[component]
Expand All @@ -20,7 +35,6 @@ pub fn App() -> impl IntoView {
provide_context(SettingsState::from_storage());
provide_context(RateLimitState::new());

let auth = expect_context::<AuthState>();
let settings = expect_context::<SettingsState>();

// Reflect the theme onto the document root so `:root[data-theme]` CSS applies.
Expand Down Expand Up @@ -105,21 +119,21 @@ pub fn App() -> impl IntoView {
<Route
path=path!("/")
view=move || {
view! {
<Show
when=move || matches!(
auth.status.get(),
crate::state::AuthStatus::LoggedIn(_)
)
fallback=|| view! { <LoginPage/> }
>
<DashboardPage/>
</Show>
}
view! { <AuthedView><DashboardPage/></AuthedView> }
}
/>
<Route
path=path!("/repo/:owner/:repo")
view=move || {
view! { <AuthedView><RepoDetailPage/></AuthedView> }
}
/>
<Route
path=path!("/settings")
view=move || {
view! { <AuthedView><SettingsPage/></AuthedView> }
}
/>
<Route path=path!("/repo/:owner/:repo") view=RepoDetailPage/>
<Route path=path!("/settings") view=SettingsPage/>
</Routes>
</main>
</Router>
Expand Down
20 changes: 19 additions & 1 deletion crates/app/src/components/issue_row.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ use leptos::prelude::*;

use models::Issue;

fn format_updated(updated_at: Option<chrono::DateTime<chrono::Utc>>) -> String {
updated_at
.map(|ts| ts.format("%Y-%m-%d").to_string())
.unwrap_or_default()
}

/// Renders one issue as a clickable row.
#[component]
pub fn IssueRow(issue: Issue) -> impl IntoView {
Expand All @@ -16,9 +22,15 @@ pub fn IssueRow(issue: Issue) -> impl IntoView {
.as_ref()
.map(|u| u.login.clone())
.unwrap_or_default();
let avatar = issue
.user
.as_ref()
.and_then(|u| u.avatar_url.clone())
.unwrap_or_default();
let comments = issue.comments;
let labels = issue.labels.clone();
let url = issue.html_url;
let updated = format_updated(issue.updated_at);

view! {
<a class="row row--issue" href=url target="_blank" rel="noopener noreferrer">
Expand All @@ -30,8 +42,14 @@ pub fn IssueRow(issue: Issue) -> impl IntoView {
.map(|l| view! { <span class="label">{l.name}</span> })
.collect_view()}
</span>
<span class="row-author">{author}</span>
<span class="row-author">
{(!avatar.is_empty()).then(|| {
view! { <img class="row-avatar" src=avatar alt="" /> }
})}
{author}
</span>
<span class="row-comments">{comments} "comments"</span>
<span class="row-updated">{updated}</span>
</a>
}
}
20 changes: 19 additions & 1 deletion crates/app/src/components/pr_row.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ use leptos::prelude::*;

use models::PullRequest;

fn format_updated(updated_at: Option<chrono::DateTime<chrono::Utc>>) -> String {
updated_at
.map(|ts| ts.format("%Y-%m-%d").to_string())
.unwrap_or_default()
}

/// Renders one pull request as a clickable row.
#[component]
pub fn PrRow(pr: PullRequest) -> impl IntoView {
Expand All @@ -14,10 +20,16 @@ pub fn PrRow(pr: PullRequest) -> impl IntoView {
.as_ref()
.map(|u| u.login.clone())
.unwrap_or_default();
let avatar = pr
.user
.as_ref()
.and_then(|u| u.avatar_url.clone())
.unwrap_or_default();
let comments = pr.comments;
let draft = pr.draft;
let labels = pr.labels.clone();
let url = pr.html_url;
let updated = format_updated(pr.updated_at);

view! {
<a class="row row--pr" href=url target="_blank" rel="noopener noreferrer">
Expand All @@ -34,8 +46,14 @@ pub fn PrRow(pr: PullRequest) -> impl IntoView {
.map(|l| view! { <span class="label">{l.name}</span> })
.collect_view()}
</span>
<span class="row-author">{author}</span>
<span class="row-author">
{(!avatar.is_empty()).then(|| {
view! { <img class="row-avatar" src=avatar alt="" /> }
})}
{author}
</span>
<span class="row-comments">{comments} "comments"</span>
<span class="row-updated">{updated}</span>
</a>
}
}
29 changes: 24 additions & 5 deletions crates/app/src/components/repo_card.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,14 @@ 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()`).
/// Estimated number of open issues across all pages (0 = use `issues.len()`).
pub total_open_issues: usize,
/// Total number of open PRs across all pages (0 = use `pulls.len()`).
/// Estimated number of open PRs across all pages (0 = use `pulls.len()`).
pub total_open_prs: usize,
/// Whether `total_open_issues` is an upper bound derived from pagination.
pub open_issues_estimate: bool,
/// Whether `total_open_prs` is an upper bound derived from pagination.
pub open_prs_estimate: bool,
}

impl RepoCardData {
Expand Down Expand Up @@ -77,6 +81,15 @@ impl RepoCardData {
}
}

/// Formats a count, appending "+" when it is an upper bound from pagination.
pub fn count_label(count: usize, estimated: bool) -> String {
if estimated {
format!("{count}+")
} else {
count.to_string()
}
}

/// Formats a timestamp as a short relative label (e.g. "3d ago").
fn format_relative(ts: &chrono::DateTime<chrono::Utc>) -> String {
let now = crate::time::now();
Expand All @@ -95,13 +108,15 @@ fn format_relative(ts: &chrono::DateTime<chrono::Utc>) -> String {
}

/// A single repo summary card. Shows star/fork counts, open issue count
/// (excluding PRs), and open PR count. Clicking navigates to the detail page.
/// (excluding PRs), open PR count, and last push. Clicking navigates to the
/// detail page.
#[component]
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 open_issues = count_label(data.open_issue_count(), data.open_issues_estimate);
let open_prs = count_label(data.open_prs_count(), data.open_prs_estimate);
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 last_push = data.last_push_label();
let ref_str = data.r#ref.to_string();

view! {
Expand Down Expand Up @@ -141,6 +156,10 @@ pub fn RepoCard(data: RepoCardData) -> impl IntoView {
}
}}
</div>
<div class="repo-card-push">
<span class="repo-card-push-label">"Last push"</span>
<span class="repo-card-push-value">{last_push}</span>
</div>
</div>
}
}
Loading