diff --git a/src/options.html b/src/options.html
index 9db3f24..50990c4 100644
--- a/src/options.html
+++ b/src/options.html
@@ -935,6 +935,31 @@
font-size: 14px;
}
+ .gn-conn-error {
+ padding: 48px 22px;
+ text-align: center;
+ color: var(--text-muted);
+ }
+ .gn-conn-error-icon {
+ font-size: 32px;
+ opacity: 0.4;
+ margin-bottom: 8px;
+ }
+ .gn-conn-error-title {
+ font-size: 15px;
+ font-weight: 600;
+ color: var(--text-secondary);
+ word-break: break-word;
+ }
+ .gn-conn-error-hint {
+ font-size: 13px;
+ margin-top: 6px;
+ line-height: 1.4;
+ }
+ .gn-conn-error .gn-conn-retry {
+ margin-top: 18px;
+ }
+
/* ── View Toggle ── */
.view-toggle {
display: flex;
diff --git a/src/options.ts b/src/options.ts
index 4b1fcef..59773fd 100644
--- a/src/options.ts
+++ b/src/options.ts
@@ -21,6 +21,7 @@ import {
DraftDesired,
PlanItem,
} from './utils/timelogDrafts';
+import { isConnectionError, renderConnectionError } from './utils/connectionError';
interface WeeklyTimelog {
issueIid: number;
@@ -2215,7 +2216,15 @@ async function loadMonth() {
$('weekLabelTotal').textContent = formatDuration(monthTotal);
renderCurrentView();
} catch (err: any) {
- content.innerHTML = `
${escapeHtml(err.message)}
`;
+ if (isConnectionError(err)) {
+ renderConnectionError(content, {
+ url: gitlabUrl,
+ variant: 'options',
+ onRetry: loadMonth,
+ });
+ } else {
+ content.innerHTML = `${escapeHtml(err.message)}
`;
+ }
}
}
@@ -2690,7 +2699,15 @@ async function loadWeek() {
$('weekLabelTotal').textContent = formatDuration(weekTotal);
renderCurrentView();
} catch (err: any) {
- content.innerHTML = `${escapeHtml(err.message)}
`;
+ if (isConnectionError(err)) {
+ renderConnectionError(content, {
+ url: gitlabUrl,
+ variant: 'options',
+ onRetry: loadWeek,
+ });
+ } else {
+ content.innerHTML = `${escapeHtml(err.message)}
`;
+ }
}
}
diff --git a/src/popup.html b/src/popup.html
index 3e4abe5..711acd5 100644
--- a/src/popup.html
+++ b/src/popup.html
@@ -606,6 +606,31 @@
font-weight: 500;
}
+ .gn-conn-error {
+ padding: 32px 18px;
+ text-align: center;
+ color: var(--text-muted);
+ }
+ .gn-conn-error-icon {
+ font-size: 26px;
+ margin-bottom: 8px;
+ opacity: 0.45;
+ }
+ .gn-conn-error-title {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text-secondary);
+ word-break: break-word;
+ }
+ .gn-conn-error-hint {
+ font-size: 12px;
+ margin-top: 4px;
+ line-height: 1.4;
+ }
+ .gn-conn-error .gn-conn-retry {
+ margin-top: 14px;
+ }
+
.today-loading {
padding: 36px 18px;
text-align: center;
diff --git a/src/popup.ts b/src/popup.ts
index d0a2aa7..623663a 100644
--- a/src/popup.ts
+++ b/src/popup.ts
@@ -1,5 +1,6 @@
import { loadThemeMode, ThemeMode } from './utils/themeManager';
import { ESTIMATE_PRESETS, SPENT_PRESETS } from './utils/constants';
+import { isConnectionError, renderConnectionError } from './utils/connectionError';
export {};
@@ -507,7 +508,15 @@ async function loadToday(dateStr?: string) {
const entries = await fetchDayTimelogs(dateStr);
renderTodayList(entries);
} catch (err: any) {
- list.innerHTML = `${escapeHtml(err.message)}
`;
+ if (isConnectionError(err)) {
+ renderConnectionError(list, {
+ url: tabInfo?.gitlabUrl ?? null,
+ variant: 'popup',
+ onRetry: () => loadToday(dateStr),
+ });
+ } else {
+ list.innerHTML = `${escapeHtml(err.message)}
`;
+ }
$('todayTotal').textContent = '--';
} finally {
refreshBtn.disabled = false;
diff --git a/src/utils/connectionError.ts b/src/utils/connectionError.ts
new file mode 100644
index 0000000..0f4cdee
--- /dev/null
+++ b/src/utils/connectionError.ts
@@ -0,0 +1,61 @@
+/**
+ * When the GitLab instance is unreachable (VPN down, host offline, DNS failure)
+ * `fetch()` rejects with a TypeError whose message is the cryptic "Failed to
+ * fetch" rather than a normal HTTP error. These helpers detect that case and
+ * render a human-readable message plus a Retry button, instead of dumping the
+ * raw browser error onto the user.
+ */
+
+/** True when `err` looks like a network/connection failure (not an HTTP error). */
+export function isConnectionError(err: unknown): boolean {
+ if (err instanceof TypeError) return true;
+ const msg = err instanceof Error ? err.message : String(err);
+ return /failed to fetch|networkerror|network error|load failed|err_/i.test(msg);
+}
+
+/** Best-effort host label ("gitlab.example.com") for display. */
+function hostLabel(url: string | null | undefined): string {
+ if (!url) return 'your GitLab instance';
+ try {
+ return new URL(url).host;
+ } catch {
+ return url;
+ }
+}
+
+function escapeHtml(str: string): string {
+ const div = document.createElement('div');
+ div.textContent = str;
+ return div.innerHTML;
+}
+
+export interface ConnectionErrorOptions {
+ /** GitLab base URL — used to show which host could not be reached. */
+ url: string | null | undefined;
+ /** Called when the user clicks Retry. */
+ onRetry: () => void;
+ /** Picks the button class so it matches the host page's styling. */
+ variant: 'popup' | 'options';
+}
+
+/**
+ * Replaces `container`'s contents with a connection-error panel and wires up
+ * its Retry button to `onRetry`.
+ */
+export function renderConnectionError(
+ container: HTMLElement,
+ opts: ConnectionErrorOptions
+): void {
+ const host = hostLabel(opts.url);
+ const btnClass =
+ opts.variant === 'options' ? 'btn-primary' : 'onboarding-btn-primary';
+ container.innerHTML = `
+
+
🔌
+
Could not connect to ${escapeHtml(host)}
+
Make sure it's reachable — check that your VPN is connected.
+
+
`;
+ const btn = container.querySelector('.gn-conn-retry');
+ btn?.addEventListener('click', () => opts.onRetry());
+}