diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index c6a96ec..dae8824 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -989,19 +989,7 @@ function formatDebridLinkCountQuota(info: DebridLinkHostLimitInfo | null | undef return info.note || "Nicht verfügbar"; } -function formatCheckedAgo(checkedAt: number): string { - const deltaMs = Date.now() - checkedAt; - if (!Number.isFinite(deltaMs) || deltaMs < 0) return "gerade eben"; - const min = Math.floor(deltaMs / 60000); - if (min < 1) return "gerade eben"; - if (min < 60) return `vor ${min} Min`; - const hours = Math.floor(min / 60); - if (hours < 24) return `vor ${hours} Std`; - const days = Math.floor(hours / 24); - return `vor ${days} Tag${days === 1 ? "" : "en"}`; -} - -function rotationEventText(ev: { event: string; cooldownSec?: number; next?: string; reason?: string }): string { +function rotationEventText(ev: { event: string; cooldownSec?: number; next?: string; reason?: string }): string { const untilRestart = /bis zum Tagesreset gesperrt/i.test(ev.reason || ""); switch (ev.event) { case "OK": return "erfolgreich"; @@ -5058,7 +5046,7 @@ export function App(): ReactElement { : null; const accountSources = useMemo(() => accountRows.map((row) => { const checkedStatus = row.accountId ? snapshot.settings.debridAccountStatuses?.[row.accountId] : undefined; - const state: AccountRowSource["status"]["state"] = resolveAccountStatusState(row.disabled, checkedStatus); + const state: AccountRowSource["status"]["state"] = resolveAccountStatusState(row.disabled, checkedStatus, runtimeNow); return { identityId: row.accountId || row.rowKey, service: row.entry.service, @@ -5070,6 +5058,7 @@ export function App(): ReactElement { state, message: checkedStatus?.message || row.entry.statusLabel, premiumUntilMs: checkedStatus?.premiumUntilMs ?? null, + checkedAt: checkedStatus?.checkedAt, username: checkedStatus?.username, email: checkedStatus?.email }, @@ -5079,7 +5068,7 @@ export function App(): ReactElement { credentialKind: row.credentialLabel.includes("API") ? "api-key" : row.credentialLabel.includes("•") ? "password" : "protected", canCheck: row.checkable }; - }), [accountRows, snapshot.settings.debridAccountStatuses]); + }), [accountRows, runtimeNow, snapshot.settings.debridAccountStatuses]); const selectedAccountViewIds = useMemo(() => accountRows .filter((row) => selectedAccountRowKeys.has(row.rowKey)) .map(accountRowViewId), [accountRows, selectedAccountRowKeys]); diff --git a/src/renderer/account-ui.ts b/src/renderer/account-ui.ts index ea9fdf2..34a7c66 100644 --- a/src/renderer/account-ui.ts +++ b/src/renderer/account-ui.ts @@ -117,7 +117,8 @@ export function resolveAccountUsername(storedUsername: string, checkedEmail?: st export function resolveAccountStatusState( disabled: boolean, - checkedStatus?: { valid: boolean; isPremium: boolean } + checkedStatus?: { valid: boolean; isPremium: boolean; premiumUntilMs?: number | null }, + nowMs: number = Date.now() ): "disabled" | "unchecked" | "invalid" | "free" | "premium" { if (checkedStatus && !checkedStatus.valid) { return "invalid"; @@ -128,6 +129,13 @@ export function resolveAccountStatusState( if (!checkedStatus) { return "unchecked"; } + if (checkedStatus.isPremium + && typeof checkedStatus.premiumUntilMs === "number" + && Number.isFinite(checkedStatus.premiumUntilMs) + && checkedStatus.premiumUntilMs > 0 + && checkedStatus.premiumUntilMs <= nowMs) { + return "free"; + } return checkedStatus.isPremium ? "premium" : "free"; } diff --git a/src/renderer/i18n.ts b/src/renderer/i18n.ts index 48a9fa2..594df38 100644 --- a/src/renderer/i18n.ts +++ b/src/renderer/i18n.ts @@ -30,10 +30,10 @@ const pairs = [ ["Liste leeren", "Clear list"], ["Sitzung", "Session"], ["Gesamt", "Total"], ["Verbleibend", "Remaining"], ["Bereit", "Ready"], ["Download läuft", "Download running"], ["Wartet", "Waiting"], ["Offline", "Offline"], ["Übersicht", "Overview"], ["Verwendungsregeln", "Usage rules"], ["Laufzeit", "Runtime"], ["Accountverwaltung", "Account management"], ["Accounts hinzufügen, prüfen und verwalten.", "Add, check and manage accounts."], ["Provider-Laufzeit", "Provider runtime"], ["Account-Laufzeit", "Account runtime"], ["Aktive Downloads", "Active downloads"], ["Erfolgsquote · Diese Sitzung", "Success rate · This session"], ["Zuletzt verwendet", "Last used"], ["Cooldown / Grund", "Cooldown / reason"], - ["Noch keine Accounts konfiguriert.", "No accounts configured yet."], ["Noch keine Laufzeitdaten verfügbar.", "No runtime data available yet."], ["Noch nicht in dieser Sitzung", "Not yet in this session"], ["Gerade eben", "Just now"], ["Prüfung", "Checking"], ["Tageslimit", "Daily limit"], ["Cooldown", "Cooldown"], + ["Noch keine Accounts konfiguriert.", "No accounts configured yet."], ["Noch keine Laufzeitdaten verfügbar.", "No runtime data available yet."], ["Noch nicht in dieser Sitzung", "Not yet in this session"], ["Gerade eben", "Just now"], ["gerade eben", "just now"], ["Prüfung", "Checking"], ["Tageslimit", "Daily limit"], ["Cooldown", "Cooldown"], ["aktiver Download", "active download"], ["aktive Downloads", "active downloads"], ["heute", "today"], ["Account deaktiviert", "Account disabled"], ["Tageslimit erreicht", "Daily limit reached"], ["Anmeldung ungültig", "Invalid login"], ["Rate-Limit aktiv", "Rate limit active"], ["Traffic- oder Kontolimit erreicht", "Traffic or account limit reached"], ["Vorübergehender Cooldown", "Temporary cooldown"], ["Provider oder Link vorübergehend nicht verfügbar", "Provider or link temporarily unavailable"], ["Accounts zum Herunterladen verwenden", "Use accounts for downloads"], ["Download-Traffic übrig", "Download traffic remaining"], ["Benutzername", "Username"], ["E-Mail", "Email"], ["Verfallsdatum", "Expiration date"], ["Passwort/Zugang", "Password/access"], - ["Account hinzufügen", "Add account"], ["Ausgewählte prüfen", "Check selected"], ["Ausgewählte entfernen", "Remove selected"], ["Aktivieren", "Enable"], ["Deaktivieren", "Disable"], ["Noch nicht geprüft", "Not checked yet"], + ["Account hinzufügen", "Add account"], ["Ausgewählte prüfen", "Check selected"], ["Ausgewählte entfernen", "Remove selected"], ["Aktivieren", "Enable"], ["Deaktivieren", "Disable"], ["Noch nicht geprüft", "Not checked yet"], ["Geprüft", "Checked"], ["Aktiviert", "Enabled"], ["Aktionen", "Actions"], ["Deaktiviert", "Disabled"], ["Premium aktiv", "Premium active"], ["API-Key aktiv", "API key active"], ["API-Account", "API account"], ["API-Key", "API key"], ["Ungültiger API-Key (nicht autorisiert)", "Invalid API key (not authorized)"], ["Free Account", "Free account"], ["Unbeschränkt", "Unlimited"], ["Keine Accounts eingerichtet", "No accounts configured"], ["Ablauf, Ziel, Tonspur, Ablageform und Leistung.", "Workflow, destination, audio track, layout and performance."], ["Ziel und Ablauf", "Destination and workflow"], ["Entpacken nach", "Extract to"], diff --git a/src/renderer/views/settings/AccountWorkspace.tsx b/src/renderer/views/settings/AccountWorkspace.tsx index 3a34031..d036cba 100644 --- a/src/renderer/views/settings/AccountWorkspace.tsx +++ b/src/renderer/views/settings/AccountWorkspace.tsx @@ -373,9 +373,10 @@ function AccountRow({ {row.mode} - - {row.status.text} - + + {row.status.text} + {row.status.checkedAgo ? Geprüft {row.status.checkedAgo} : null} + {row.traffic} {renderAccountIdentityCell({ className: "settings-account-username", label: "Benutzername", onCopy: actions.onCopyIdentity, value: row.username })} {renderAccountIdentityCell({ className: "settings-account-email", label: "E-Mail", onCopy: actions.onCopyIdentity, value: row.email })} diff --git a/src/renderer/views/settings/settings-model.ts b/src/renderer/views/settings/settings-model.ts index a410d21..79381fe 100644 --- a/src/renderer/views/settings/settings-model.ts +++ b/src/renderer/views/settings/settings-model.ts @@ -140,6 +140,7 @@ export interface AccountRowSource { state: AccountStatusSourceState; message: string; premiumUntilMs: number | null; + checkedAt?: number; username?: string; email?: string; }; @@ -161,6 +162,7 @@ export interface AccountRowViewModel { status: { tone: AccountStatusTone; text: string; + checkedAgo: string; }; traffic: string; username: string; @@ -634,6 +636,26 @@ function formatExpiry(premiumUntilMs: number | null): string { return new Intl.DateTimeFormat("de-DE").format(new Date(premiumUntilMs)); } +function formatCheckedAgo(checkedAt: number | undefined, nowMs: number): string { + if (!checkedAt || !Number.isFinite(checkedAt) || checkedAt <= 0) { + return ""; + } + const deltaMs = Math.max(0, nowMs - checkedAt); + const minutes = Math.floor(deltaMs / 60_000); + if (minutes < 1) { + return "gerade eben"; + } + if (minutes < 60) { + return `vor ${minutes} Min`; + } + const hours = Math.floor(minutes / 60); + if (hours < 24) { + return `vor ${hours} Std`; + } + const days = Math.floor(hours / 24); + return `vor ${days} Tag${days === 1 ? "" : "en"}`; +} + function projectStatus(source: AccountRowSource): { tone: AccountStatusTone; text: string } { if (!source.enabled || source.status.state === "disabled") { return { tone: "disabled", text: "Deaktiviert" }; @@ -691,7 +713,7 @@ export function projectAccountRows( icon: ACCOUNT_SERVICE_ICONS[source.service], enabled: source.enabled, selected: selected.has(id), - status, + status: { ...status, checkedAgo: formatCheckedAgo(source.status.checkedAt, nowMs) }, traffic: formatTraffic(source.dailyLimitBytes, source.dailyUsageBytes), username: identity.username, email: identity.email, diff --git a/src/renderer/views/settings/settings.css b/src/renderer/views/settings/settings.css index e0c7a43..4655a5c 100644 --- a/src/renderer/views/settings/settings.css +++ b/src/renderer/views/settings/settings.css @@ -723,6 +723,20 @@ white-space: nowrap; } +.settings-account-status { + display: grid; + align-content: center; + gap: 2px; +} + +.settings-account-status-checked { + overflow: hidden; + color: var(--ui-text-muted); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + .settings-account-status-badge::before { width: 7px; height: 7px; diff --git a/tests/i18n.test.ts b/tests/i18n.test.ts index 53515b2..31406c1 100644 --- a/tests/i18n.test.ts +++ b/tests/i18n.test.ts @@ -73,6 +73,8 @@ describe("renderer localization", () => { expect(translateUiText("Link kopieren", "en")).toBe("Copy Link"); expect(translateUiText("example.test Klicken zum Kopieren", "en")).toBe("Click to copy example.test"); expect(translateUiText("Klicken zum Kopieren", "en")).toBe("Click to copy"); + expect(translateUiText("Geprüft", "en")).toBe("Checked"); + expect(translateUiText("gerade eben", "en")).toBe("just now"); expect(translateUiText("Geschwindigkeit verschieben", "en")).toBe("Move Speed"); expect(translateUiText("Geschwindigkeit nach links verschieben", "en")).toBe("Move Speed left"); expect(translateUiText("Move Speed right", "de")).toBe("Geschwindigkeit nach rechts verschieben"); diff --git a/tests/settings-view.test.tsx b/tests/settings-view.test.tsx index 9cdb761..2fe472c 100644 --- a/tests/settings-view.test.tsx +++ b/tests/settings-view.test.tsx @@ -138,6 +138,7 @@ function accountSources(): AccountRowSource[] { state: "premium", message: "Premium aktiv", premiumUntilMs: NOW + 7 * 24 * 60 * 60 * 1000, + checkedAt: NOW - 5 * 60 * 1000, email: "verified@example.test" }, dailyLimitBytes: 10 * GIB, @@ -390,10 +391,42 @@ describe("settings model", () => { "Noch nicht geprüft", "Deaktiviert" ]); + expect(rows[0].status.checkedAgo).toBe("vor 5 Min"); expect(JSON.stringify(rows)).not.toContain("test-password"); expect(JSON.stringify(rows)).not.toContain("test-token"); }); + it("marks elapsed premium access as expired instead of active", () => { + expect(resolveAccountStatusState(false, { + valid: true, + isPremium: true, + premiumUntilMs: NOW - 1 + }, NOW)).toBe("free"); + expect(resolveAccountStatusState(false, { + valid: true, + isPremium: true, + premiumUntilMs: NOW + 1 + }, NOW)).toBe("premium"); + expect(resolveAccountStatusState(false, { + valid: true, + isPremium: true, + premiumUntilMs: null + }, NOW)).toBe("premium"); + + const [row] = projectAccountRows([{ + ...accountSources()[0], + status: { + ...accountSources()[0].status, + state: "free", + premiumUntilMs: NOW - 1, + checkedAt: NOW - 60 * 60 * 1000 + } + }], [], NOW); + + expect(row.status).toEqual({ tone: "free", text: "Free Account", checkedAgo: "vor 1 Std" }); + expect(row.premiumUntilMs).toBeNull(); + }); + it("sorts positive premium expirations first and prunes vanished selections", () => { const rows = projectAccountRows(accountSources(), [], NOW); const sorted = sortAccountRows(rows); @@ -675,6 +708,14 @@ describe("account workspace", () => { expect(html.match(/data-sliding-selection-active="true"/g)).toHaveLength(1); }); + it("shows the last account check directly below the status", () => { + const html = renderToStaticMarkup(); + + expect(html).toContain("settings-account-status-checked"); + expect(html).toContain("Geprüft"); + expect(html).toContain("vor 5 Min"); + }); + it("shows a live provider and account overview without exposing raw failure details", () => { const html = renderToStaticMarkup( { it("keeps unchecked single accounts honest without a positive status", () => { const block = sourceBlock(appSource, "const accountSources", "const selectedAccountViewId"); - expect(block).toContain("resolveAccountStatusState(row.disabled, checkedStatus)"); + expect(block).toContain("resolveAccountStatusState(row.disabled, checkedStatus, runtimeNow)"); }); it("stores only the stable account row id in context-menu state", () => { @@ -1255,6 +1296,7 @@ describe("settings App integration", () => { describe("settings geometry", () => { it("uses central focus and semantic status text tokens", () => { + expect(settingsCss).toContain(".settings-account-status-checked"); expect(settingsCss).toMatch(/\.settings-theme-option:focus-visible,[^{]*\.settings-account-picker-row:focus-visible\s*{[^}]*outline:\s*2px solid var\(--ui-focus\);/s); expect(settingsCss).toMatch(/\.settings-save-state\.is-clean,[^{]*\.settings-save-state\.is-saved\s*{[^}]*color:\s*var\(--ui-success-text\);/s); expect(settingsCss).toMatch(/\.settings-save-state\.is-dirty,[^{]*\.settings-save-state\.is-saving\s*{[^}]*color:\s*var\(--ui-warning-text\);/s);