diff --git a/README.md b/README.md index ce26716..5236b4e 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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: @@ -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: @@ -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 | @@ -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 --- @@ -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 @@ -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 @@ -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 diff --git a/docs/assets/feedreader-home.png b/docs/assets/feedreader-home.png index 2dc910c..b66f243 100644 Binary files a/docs/assets/feedreader-home.png and b/docs/assets/feedreader-home.png differ diff --git a/internal/config/config.go b/internal/config/config.go index fd384e2..04bcf81 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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"), diff --git a/internal/service/service.go b/internal/service/service.go index ce2f63c..c924a6d 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -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 @@ -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 { diff --git a/internal/service/service_test.go b/internal/service/service_test.go index 6652518..9bd896c 100644 --- a/internal/service/service_test.go +++ b/internal/service/service_test.go @@ -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) + } +} diff --git a/web/static/app.js b/web/static/app.js index 5181daf..af4da94 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -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; @@ -762,7 +752,6 @@ return false; } finally { refreshInFlight = false; - setFeedLoading(false); setRefreshButtonLoading(false); renderFeedBody(); } @@ -958,7 +947,7 @@ if (viewMoreButton) { viewMoreButton.addEventListener("click", async () => { - if (feedLoading || refreshInFlight) return; + if (feedLoading) return; viewMoreButton.disabled = true; try { await fetchItems({ diff --git a/web/static/service-worker.js b/web/static/service-worker.js index 88512c1..90bbd0e 100644 --- a/web/static/service-worker.js +++ b/web/static/service-worker.js @@ -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", diff --git a/web/templates/index.html b/web/templates/index.html index 5b6fa78..c8a877a 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -16,8 +16,8 @@ - - + +