Skip to content
This repository was archived by the owner on Jun 22, 2026. It is now read-only.
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
28 changes: 17 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@
- RSS-based app icon/favicon branding
- dark/light mode
- inline expanding search
- refresh control
- reader settings dialog for theme, density, and source visibility
- **Configurable visible sources** stored in `localStorage`
- choose which source buttons are shown
Expand All @@ -56,8 +55,8 @@
- **Debounced client-side search UX** backed by the server API
- **Explicit empty states** for no-result source filters and searches
- **Connectivity indicator** that shows a no-wifi icon while offline and silently refreshes the current view when the browser reconnects
- **Scheduled refresh** every 3 hours on wall-clock boundaries in UTC+7
- **Manual refresh** from the UI updates the current feed list in place without a full page reload and shows a toast-based loading state while refresh + refetch are running
- **Scheduled refresh** every 1 hour on wall-clock boundaries in UTC+7
- **Manual refresh** from the header re-fetches the current feed view from backend stored items only; it does **not** re-fetch upstream sources
- **Persisted visited-link dimming** for feed card titles across reload/reopen using local storage
- **PWA-ready assets and offline caching** including manifest, service worker, touch icons, cached shell assets, and cached `/api/items` responses for previously visited views
- **Reconnect list refresh** re-fetches the current view from backend stored items only; it does **not** refresh upstream sources
Expand Down Expand Up @@ -112,7 +111,7 @@ At a high level:
1. source adapters fetch upstream content
2. items are upserted into SQLite by `(source, external_id)`
3. the web app reads stored items ordered by article date descending
4. the scheduler refreshes on 3-hour clock boundaries
4. the scheduler refreshes on 1-hour clock boundaries by default

Key properties:

Expand Down Expand Up @@ -201,7 +200,7 @@ Dockerized Go toolchain:
docker run --rm -v "$PWD":/src -w /src golang:1.24-bookworm go test ./...
```

### Manual refresh
### On-demand refresh

Host-native:

Expand Down Expand Up @@ -246,7 +245,7 @@ Environment variables:
| Variable | Default | Description |
| ------------------------------------ | ---------------------: | -------------------------------------------------------------- |
| `FEEDREADER_DB_PATH` | `./data/feedreader.db` | SQLite database path |
| `FEEDREADER_REFRESH_INTERVAL_HOURS` | `3` | Refresh interval setting used by the scheduler |
| `FEEDREADER_REFRESH_INTERVAL_HOURS` | `1` | Refresh interval setting used by the scheduler |
| `FEEDREADER_ITEMS_PER_SOURCE` | `20` | Per-source item count used in source dashboard/health contexts |
| `FEEDREADER_REQUEST_TIMEOUT_SECONDS` | `20` | Upstream request timeout |
| `FEEDREADER_USER_AGENT` | `feedreader/0.1` | Outbound fetch user agent |
Expand All @@ -262,10 +261,13 @@ The scheduler runs **inside the app process**.
Behavior:

- aligned to **UTC+7** (`Asia/Ho_Chi_Minh`)
- runs on the next **3-hour wall-clock boundary**
- runs on the next **N-hour wall-clock boundary** based on `FEEDREADER_REFRESH_INTERVAL_HOURS` (default: **1 hour**)
- does **not** perform an immediate refresh just because the container starts

Manual refresh is also available through the UI and CLI.
On-demand refresh is available through:

- the header refresh button for re-fetching the current backend-stored feed view
- the CLI and `POST /api/refresh` for triggering an immediate upstream source refresh

---

Expand All @@ -287,6 +289,10 @@ Query params:
- `limit` — page size
- `offset` — pagination offset

### `POST /api/refresh`

Triggers an immediate upstream refresh across all sources and returns per-source outcomes.

---

## Data model
Expand Down Expand Up @@ -328,7 +334,7 @@ Presentation-layer note:

### Loading and empty states

- first-load bootstrap queries, source filter changes, searches, `View more`, and manual refresh all show an explicit toast-based loading state
- first-load bootstrap queries, source filter changes, searches, `View more`, and header refresh all show an explicit toast-based loading state
- source-filter changes use the generic loading toast text `Loading feed…`
- source-filter and search requests that return zero items replace the list with an empty-state message instead of leaving stale cards on screen
- `View more` disables itself while an append request is in flight and hides itself when the current result set has no further page
Expand All @@ -337,10 +343,10 @@ Presentation-layer note:

- the app shell and previously fetched `GET /api/items` views are cached by the service worker for offline reuse
- this offline/PWA behavior requires a secure-context origin where service workers are available (for example `localhost` or HTTPS); plain HTTP network IP origins such as `http://100.94.224.102:9[...]
- when the browser goes offline, a no-wifi indicator appears before the refresh button instead of showing connectivity toasts
- when the browser goes offline, a no-wifi indicator appears in the header action row instead of showing connectivity toasts
- if an offline view has no cached `/api/items` response yet, the list is replaced with `Offline and no cached items are available for this view yet.`
- when the browser comes back online, the no-wifi indicator disappears and the current view is re-fetched silently from `/api/items`
- reconnect refreshes backend-stored items only; the only UI path that calls `POST /api/refresh` remains the manual refresh button
- reconnect refreshes backend-stored items only; it does not trigger upstream source refetches

### Reader settings dialog

Expand Down
Binary file modified docs/assets/feedreader-home.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ type Config struct {
func Load() (Config, error) {
cfg := Config{
DBPath: envOrDefault("FEEDREADER_DB_PATH", "./data/feedreader.db"),
RefreshIntervalHours: envInt("FEEDREADER_REFRESH_INTERVAL_HOURS", 3),
RefreshIntervalHours: envInt("FEEDREADER_REFRESH_INTERVAL_HOURS", 1),
ItemsPerSource: envInt("FEEDREADER_ITEMS_PER_SOURCE", 20),
RequestTimeoutSec: envFloat("FEEDREADER_REQUEST_TIMEOUT_SECONDS", 20),
UserAgent: envOrDefault("FEEDREADER_USER_AGENT", "feedreader/0.1"),
Expand Down
15 changes: 7 additions & 8 deletions internal/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ func (s *FeedService) StartScheduler(ctx context.Context) {
location := loadScheduleLocation()
for {
now := time.Now().In(location)
next := nextScheduledRefresh(now)
next := nextScheduledRefresh(now, s.cfg.RefreshIntervalHours)
wait := time.Until(next)
if wait < 0 {
wait = time.Second
Expand Down Expand Up @@ -436,15 +436,14 @@ func loadScheduleLocation() *time.Location {
return time.FixedZone("UTC+7", 7*60*60)
}

func nextScheduledRefresh(now time.Time) time.Time {
func nextScheduledRefresh(now time.Time, intervalHours int) time.Time {
if intervalHours < 1 {
intervalHours = 1
}
location := now.Location()
base := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, location)
nextHour := ((now.Hour() / 3) + 1) * 3
if nextHour >= 24 {
base = time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, location)
nextHour = 0
}
return time.Date(base.Year(), base.Month(), base.Day(), nextHour, 0, 0, 0, location)
hoursUntilNext := intervalHours - (now.Hour() % intervalHours)
return base.Add(time.Duration(hoursUntilNext) * time.Hour)
}

func fmtSprintf(format string, values ...any) string {
Expand Down
18 changes: 18 additions & 0 deletions internal/service/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,3 +199,21 @@ func TestBuildCardsWithoutStatsStillCarriesDateParts(t *testing.T) {
t.Fatalf("unexpected brief: %q", *cards[0].Brief)
}
}

func TestNextScheduledRefreshHourlyBoundary(t *testing.T) {
location := time.FixedZone("UTC+7", 7*60*60)
now := time.Date(2026, time.June, 21, 10, 14, 35, 0, location)
want := time.Date(2026, time.June, 21, 11, 0, 0, 0, location)
if got := nextScheduledRefresh(now, 1); !got.Equal(want) {
t.Fatalf("nextScheduledRefresh(%v, 1) = %v, want %v", now, got, want)
}
}

func TestNextScheduledRefreshRespectsConfiguredInterval(t *testing.T) {
location := time.FixedZone("UTC+7", 7*60*60)
now := time.Date(2026, time.June, 21, 10, 14, 35, 0, location)
want := time.Date(2026, time.June, 21, 12, 0, 0, 0, location)
if got := nextScheduledRefresh(now, 3); !got.Equal(want) {
t.Fatalf("nextScheduledRefresh(%v, 3) = %v, want %v", now, got, want)
}
}
15 changes: 2 additions & 13 deletions web/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -736,24 +736,14 @@

async function refreshFeedList() {
syncConnectivityState();
if (refreshInFlight) {
return false;
}
if (!browserOnline) {
if (!browserOnline || refreshInFlight) {
return false;
}
refreshInFlight = true;
cancelPendingSearch();
setFeedLoading(true, { mode: "replace", message: "Refreshing feed…" });
setRefreshButtonLoading(true);
renderFeedBody();
try {
const response = await fetch("/api/refresh", { method: "POST" });
const payload = await response.json().catch(() => ({}));
if (!response.ok || !payload.ok) {
showToast("Refresh completed with errors", "error");
return false;
}
await refetchCurrentView({ loadingMessage: "Refreshing feed…" });
showToast("Feed refreshed", "success");
return true;
Expand All @@ -762,7 +752,6 @@
return false;
} finally {
refreshInFlight = false;
setFeedLoading(false);
setRefreshButtonLoading(false);
renderFeedBody();
}
Expand Down Expand Up @@ -958,7 +947,7 @@

if (viewMoreButton) {
viewMoreButton.addEventListener("click", async () => {
if (feedLoading || refreshInFlight) return;
if (feedLoading) return;
viewMoreButton.disabled = true;
try {
await fetchItems({
Expand Down
6 changes: 3 additions & 3 deletions web/static/service-worker.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
const SHELL_CACHE = 'reader-shell-v32';
const SHELL_CACHE = 'reader-shell-v34';
const ITEMS_CACHE = "reader-items-v22";
const CORE_ASSETS = [
"/",
'/static/style.css?v=37',
"/static/app.js?v=28",
'/static/style.css?v=38',
"/static/app.js?v=30",
"/static/source-icons/hackernews.svg",
"/static/source-icons/github.svg",
"/static/source-icons/huggingface.svg",
Expand Down
4 changes: 2 additions & 2 deletions web/templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@
<link rel="icon" href="/favicon.svg?v=8" sizes="any" type="image/svg+xml" />
<link rel="shortcut icon" href="/favicon.svg?v=8" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png?v=8" />
<link rel="stylesheet" href="/static/style.css?v=37" />
<script src="/static/app.js?v=28" defer></script>
<link rel="stylesheet" href="/static/style.css?v=38" />
<script src="/static/app.js?v=30" defer></script>
</head>
<body>
<header class="shell page-header">
Expand Down
Loading