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
25 changes: 25 additions & 0 deletions src/options.html
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
21 changes: 19 additions & 2 deletions src/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
DraftDesired,
PlanItem,
} from './utils/timelogDrafts';
import { isConnectionError, renderConnectionError } from './utils/connectionError';

interface WeeklyTimelog {
issueIid: number;
Expand Down Expand Up @@ -2215,7 +2216,15 @@ async function loadMonth() {
$('weekLabelTotal').textContent = formatDuration(monthTotal);
renderCurrentView();
} catch (err: any) {
content.innerHTML = `<div class="empty-state"><div class="empty-state-text" style="color:var(--red-500)">${escapeHtml(err.message)}</div></div>`;
if (isConnectionError(err)) {
renderConnectionError(content, {
url: gitlabUrl,
variant: 'options',
onRetry: loadMonth,
});
} else {
content.innerHTML = `<div class="empty-state"><div class="empty-state-text" style="color:var(--red-500)">${escapeHtml(err.message)}</div></div>`;
}
}
}

Expand Down Expand Up @@ -2690,7 +2699,15 @@ async function loadWeek() {
$('weekLabelTotal').textContent = formatDuration(weekTotal);
renderCurrentView();
} catch (err: any) {
content.innerHTML = `<div class="empty-state"><div class="empty-state-text" style="color:var(--red-500)">${escapeHtml(err.message)}</div></div>`;
if (isConnectionError(err)) {
renderConnectionError(content, {
url: gitlabUrl,
variant: 'options',
onRetry: loadWeek,
});
} else {
content.innerHTML = `<div class="empty-state"><div class="empty-state-text" style="color:var(--red-500)">${escapeHtml(err.message)}</div></div>`;
}
}
}

Expand Down
25 changes: 25 additions & 0 deletions src/popup.html
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
11 changes: 10 additions & 1 deletion src/popup.ts
Original file line number Diff line number Diff line change
@@ -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 {};

Expand Down Expand Up @@ -507,7 +508,15 @@ async function loadToday(dateStr?: string) {
const entries = await fetchDayTimelogs(dateStr);
renderTodayList(entries);
} catch (err: any) {
list.innerHTML = `<div class="today-empty"><div class="today-empty-text" style="color:var(--red-500)">${escapeHtml(err.message)}</div></div>`;
if (isConnectionError(err)) {
renderConnectionError(list, {
url: tabInfo?.gitlabUrl ?? null,
variant: 'popup',
onRetry: () => loadToday(dateStr),
});
} else {
list.innerHTML = `<div class="today-empty"><div class="today-empty-text" style="color:var(--red-500)">${escapeHtml(err.message)}</div></div>`;
}
$('todayTotal').textContent = '--';
} finally {
refreshBtn.disabled = false;
Expand Down
61 changes: 61 additions & 0 deletions src/utils/connectionError.ts
Original file line number Diff line number Diff line change
@@ -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 = `
<div class="gn-conn-error">
<div class="gn-conn-error-icon">&#128268;</div>
<div class="gn-conn-error-title">Could not connect to ${escapeHtml(host)}</div>
<div class="gn-conn-error-hint">Make sure it's reachable — check that your VPN is connected.</div>
<button type="button" class="${btnClass} gn-conn-retry">Retry</button>
</div>`;
const btn = container.querySelector<HTMLButtonElement>('.gn-conn-retry');
btn?.addEventListener('click', () => opts.onRetry());
}
Loading