From 99f7f58fcbe981960498206248fb40e2cdea023c Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Sat, 11 Jul 2026 09:49:55 +0300 Subject: [PATCH] Add subscription info refresh endpoint and UI stats --- src/server/index.js | 15 +- src/server/subscription.js | 17 +- src/web/App.jsx | 7 + src/web/api.js | 1 + src/web/components/ClientOverviewPage.jsx | 177 +++++++++++-- src/web/styles.css | 296 +++++++++++++++++++++- src/web/utils/clientControls.js | 28 ++ test/web/client-controls.test.js | 20 ++ 8 files changed, 521 insertions(+), 40 deletions(-) diff --git a/src/server/index.js b/src/server/index.js index c378869..3a2665d 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -4,7 +4,7 @@ import path from "node:path"; import crypto from "node:crypto"; import { spawn, spawnSync } from "node:child_process"; import { settings } from "./config.js"; -import { fetchSubscription } from "./subscription.js"; +import { fetchSubscription, fetchSubscriptionInfo } from "./subscription.js"; import os from "node:os"; import { buildGatewayConfig, @@ -1424,6 +1424,19 @@ async function handleApi(req, res) { return sendJson(res, 200, { success: true, ...parsed }); } + if (req.method === "POST" && req.url === "/api/subscription/refresh-info") { + const prevState = readJson(settings.statePath, {}); + if (!prevState.subscriptionUrl) { + return sendJson(res, 400, { success: false, error: "Подписка не настроена" }); + } + + const info = await fetchSubscriptionInfo(prevState.subscriptionUrl); + writeJson(settings.statePath, { ...prevState, ...info }); + const cached = readJson(settings.subscriptionCachePath, null); + if (cached) writeJson(settings.subscriptionCachePath, { ...cached, ...info }); + return sendJson(res, 200, { success: true, ...info }); + } + if (req.method === "DELETE" && req.url === "/api/subscription") { if (fs.existsSync(settings.subscriptionCachePath)) fs.rmSync(settings.subscriptionCachePath); diff --git a/src/server/subscription.js b/src/server/subscription.js index 39621df..a3a7529 100644 --- a/src/server/subscription.js +++ b/src/server/subscription.js @@ -136,7 +136,7 @@ export function parseSubscriptionBody(body) { return { config: parsedConfig, servers }; } -export async function fetchSubscription(url) { +async function requestSubscription(url) { let parsedUrl; try { parsedUrl = new URL(url); @@ -157,6 +157,21 @@ export async function fetchSubscription(url) { throw new Error(`Subscription request failed: HTTP ${response.status}`); } + return response; +} + +export async function fetchSubscriptionInfo(url) { + const response = await requestSubscription(url); + await response.body?.cancel(); + return { + userInfo: parseUserInfo(response.headers.get('subscription-userinfo')), + fetchedAt: new Date().toISOString(), + }; +} + +export async function fetchSubscription(url) { + const response = await requestSubscription(url); + const body = await response.text(); const userInfo = parseUserInfo(response.headers.get('subscription-userinfo')); const parsed = parseSubscriptionBody(body); diff --git a/src/web/App.jsx b/src/web/App.jsx index 9b5670c..1a43f66 100644 --- a/src/web/App.jsx +++ b/src/web/App.jsx @@ -133,6 +133,12 @@ function App() { }); } + async function refreshSubscriptionInfo() { + const data = await api.subscription.refreshInfo(); + setState((prev) => prev ? { ...prev, userInfo: data.userInfo, fetchedAt: data.fetchedAt } : prev); + return data; + } + async function forgetSubscription() { if (!confirm('Удалить подписку и остановить sing-box?')) return; return withBusy('Подписка удалена', async () => { @@ -426,6 +432,7 @@ function App() { pendingTag={pendingTag} setPendingTag={setPendingTag} onFetchSubscription={fetchSubscription} + onRefreshSubscriptionInfo={refreshSubscriptionInfo} onApply={applyServer} onRestart={restartSingbox} onStop={() => stopSingbox(false)} diff --git a/src/web/api.js b/src/web/api.js index 8d39055..24cdd9e 100644 --- a/src/web/api.js +++ b/src/web/api.js @@ -65,6 +65,7 @@ export const api = { method: "POST", body: JSON.stringify({ url }), }), + refreshInfo: () => request("/api/subscription/refresh-info", { method: "POST" }), forget: () => request("/api/subscription", { method: "DELETE" }), }, diff --git a/src/web/components/ClientOverviewPage.jsx b/src/web/components/ClientOverviewPage.jsx index 83e95e3..fa99ca8 100644 --- a/src/web/components/ClientOverviewPage.jsx +++ b/src/web/components/ClientOverviewPage.jsx @@ -5,7 +5,10 @@ import { formatConnectionDuration, localProxyUrls, subscriptionDomain, + subscriptionDaysLeft, + subscriptionUsage, } from '../utils/clientControls.js'; +import { formatBytes } from '../utils/format.js'; export function ClientOverviewPage({ state, @@ -16,6 +19,7 @@ export function ClientOverviewPage({ pendingTag, setPendingTag, onFetchSubscription, + onRefreshSubscriptionInfo, onApply, onRestart, onStop, @@ -27,9 +31,19 @@ export function ClientOverviewPage({ const [editingSubscription, setEditingSubscription] = useState(!state?.hasSubscription); const [pings, setPings] = useState({}); const [copiedProxy, setCopiedProxy] = useState(''); + const [refreshingInfo, setRefreshingInfo] = useState(false); + const [usageUpdated, setUsageUpdated] = useState(false); + const [serverRevealVersion, setServerRevealVersion] = useState(0); + const [serversLeaving, setServersLeaving] = useState(false); const subscriptionInputRef = useRef(null); + const subscriptionRef = useRef(null); const serverKey = servers.map((server) => `${server.tag}:${server.server}:${server.server_port}`).join('|'); const proxyUrls = localProxyUrls(state?.proxyPort); + const usage = subscriptionUsage(state?.userInfo); + const [displayedUsed, setDisplayedUsed] = useState(usage.used); + const hasUsage = Boolean( + state?.userInfo && ['upload', 'download', 'total', 'expire'].some((key) => key in state.userInfo), + ); useEffect(() => { setNow(Date.now()); @@ -74,6 +88,41 @@ export function ClientOverviewPage({ return () => clearTimeout(timer); }, [editingSubscription, state?.hasSubscription, subscriptionUrl]); + useEffect(() => { + if (!editingSubscription) return undefined; + const closeOnOutsideClick = (event) => { + if (subscriptionRef.current?.contains(event.target)) return; + setSubscriptionUrl(''); + setEditingSubscription(false); + }; + document.addEventListener('pointerdown', closeOnOutsideClick); + return () => document.removeEventListener('pointerdown', closeOnOutsideClick); + }, [editingSubscription, setSubscriptionUrl]); + + useEffect(() => { + if (!state?.hasSubscription) return undefined; + const refresh = () => onRefreshSubscriptionInfo().catch(() => {}); + refresh(); + const timer = setInterval(refresh, 60_000); + return () => clearInterval(timer); + }, [state?.hasSubscription]); + + useEffect(() => { + const from = displayedUsed; + const to = usage.used; + if (from === to) return undefined; + const startedAt = performance.now(); + let frame; + const tick = (now) => { + const progress = Math.min(1, (now - startedAt) / 900); + const eased = 1 - Math.pow(1 - progress, 4); + setDisplayedUsed(from + (to - from) * eased); + if (progress < 1) frame = requestAnimationFrame(tick); + }; + frame = requestAnimationFrame(tick); + return () => cancelAnimationFrame(frame); + }, [usage.used]); + async function toggleConnection() { const action = connectionAction({ connected, selectedTag, configExists: state?.configExists }); if (action?.type === 'stop') return onStop(); @@ -98,10 +147,30 @@ export function ClientOverviewPage({ try { await navigator.clipboard.writeText(proxyUrls[kind]); setCopiedProxy(kind); - setTimeout(() => setCopiedProxy(''), 1400); + setTimeout(() => setCopiedProxy(''), 800); } catch { setCopiedProxy('error'); - setTimeout(() => setCopiedProxy(''), 1400); + setTimeout(() => setCopiedProxy(''), 800); + } + } + + async function refreshInfo() { + const startedAt = performance.now(); + setRefreshingInfo(true); + try { + await onRefreshSubscriptionInfo(); + setUsageUpdated(false); + requestAnimationFrame(() => setUsageUpdated(true)); + setTimeout(() => setUsageUpdated(false), 900); + setServersLeaving(true); + await new Promise((resolve) => setTimeout(resolve, 420 + Math.max(0, servers.length - 1) * 90)); + setServerRevealVersion((version) => version + 1); + setServersLeaving(false); + } finally { + const elapsed = performance.now() - startedAt; + const completeCyclesAt = Math.max(900, Math.ceil(elapsed / 900) * 900); + await new Promise((resolve) => setTimeout(resolve, completeCyclesAt - elapsed)); + setRefreshingInfo(false); } } @@ -122,16 +191,21 @@ export function ClientOverviewPage({ -
-

{connected ? 'VPN включён' : 'VPN выключен'}

- {connected && ( - - )} - {!connected && ( -

{canStart ? 'Нажмите, чтобы подключиться' : 'Добавьте ссылку и выберите сервер'}

- )} +
+

+ {connected ? 'VPN включён' : 'VPN выключен'} +

+
+ {connected ? ( + + ) : ( +

+ {canStart ? 'Нажмите, чтобы включить' : 'Добавьте ссылку и выберите сервер'} +

+ )} +
@@ -151,8 +225,8 @@ export function ClientOverviewPage({ aria-label={`Скопировать ${label}: ${proxyUrls[kind]}`} onClick={() => copyProxy(kind)} > - {label} - {copiedProxy === kind && } + {label} + {copiedProxy === kind && Copied} ))}
@@ -160,18 +234,39 @@ export function ClientOverviewPage({
-
- +
+ Ваша подписка + +
+ +
+ {hasUsage && ( +
+ Использовано + + {formatBytes(displayedUsed)} + / {usage.total ? formatBytes(usage.total) : 'без лимита'} + + {usage.percent !== null && ( +
+ +
+ )} +
+ {usage.expiresAt && !Number.isNaN(usage.expiresAt.getTime()) && ( + + до {usage.expiresAt.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long' })} + {' · '}{subscriptionDaysLeft(usage.expiresAt)} + + )} +
+
+ )} +
-
- {servers.map((server) => { +
+ {servers.map((server, index) => { const ping = pings[server.tag]; const selected = server.tag === selectedTag; const pingText = ping?.checking @@ -229,6 +357,7 @@ export function ClientOverviewPage({ key={server.tag} disabled={busy} aria-pressed={selected} + style={{ '--server-index': index }} onClick={() => selectServer(server.tag)} > {server.tag} diff --git a/src/web/styles.css b/src/web/styles.css index ed9bfd9..74de0d8 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -893,11 +893,12 @@ code, .mono { text-align: center; } -.client-power-section > div { +.client-state-copy { min-height: 54px; } .client-power { + position: relative; width: 96px; height: 96px; display: grid; @@ -907,12 +908,30 @@ code, .mono { background: transparent; color: var(--client-muted); cursor: pointer; - transition: color 600ms cubic-bezier(0.16, 1, 0.3, 1), transform 600ms cubic-bezier(0.16, 1, 0.3, 1); + transition: color 800ms cubic-bezier(0.16, 1, 0.3, 1), opacity 600ms ease, transform 600ms cubic-bezier(0.16, 1, 0.3, 1); +} + +.client-power::before { + content: ''; + position: absolute; + width: 58px; + height: 58px; + border-radius: 50%; + background: radial-gradient(circle, color-mix(in oklch, var(--client-accent) 42%, transparent), transparent 70%); + opacity: 0; + transform: scale(0.55); + transition: opacity 900ms cubic-bezier(0.16, 1, 0.3, 1), transform 900ms cubic-bezier(0.16, 1, 0.3, 1); + pointer-events: none; +} + +.client-power[aria-checked='true']::before { + opacity: 0.75; + transform: scale(1.75); } .client-power:hover:not(:disabled) { transform: translateY(-3px) scale(1.08); - color: var(--client-accent); + color: var(--client-text); } .client-power[aria-checked='true'] { @@ -930,6 +949,7 @@ code, .mono { } .client-power svg { + position: relative; width: 40px; height: 40px; fill: none; @@ -941,15 +961,27 @@ code, .mono { } .client-power:hover:not(:disabled) svg { + filter: drop-shadow(0 0 6px color-mix(in oklch, var(--client-muted) 55%, transparent)) drop-shadow(0 0 18px color-mix(in oklch, var(--client-muted) 22%, transparent)); +} + +.client-power[aria-checked='true']:hover:not(:disabled) { + color: var(--client-accent); +} + +.client-power[aria-checked='true']:hover:not(:disabled) svg { filter: drop-shadow(0 0 6px color-mix(in oklch, var(--client-accent) 75%, transparent)) drop-shadow(0 0 18px color-mix(in oklch, var(--client-accent) 35%, transparent)); } .client-power:active:not(:disabled) svg { transform: scale(0.88); - filter: drop-shadow(0 0 10px var(--client-accent)) drop-shadow(0 0 26px color-mix(in oklch, var(--client-accent) 55%, transparent)); + filter: drop-shadow(0 0 10px var(--client-muted)) drop-shadow(0 0 26px color-mix(in oklch, var(--client-muted) 45%, transparent)); transition-duration: 220ms; } +.client-power[aria-checked='true']:active:not(:disabled) svg { + filter: drop-shadow(0 0 10px var(--client-accent)) drop-shadow(0 0 26px color-mix(in oklch, var(--client-accent) 55%, transparent)); +} + .client-power[aria-checked='true'] svg { filter: drop-shadow(0 0 5px color-mix(in oklch, var(--client-accent) 65%, transparent)) drop-shadow(0 0 14px color-mix(in oklch, var(--client-accent) 28%, transparent)); } @@ -957,11 +989,24 @@ code, .mono { .client-power-section h2 { font: 700 18px/1.3 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace; letter-spacing: -0.04em; + animation: client-state-reveal 700ms cubic-bezier(0.16, 1, 0.3, 1); +} + +.client-state-detail { + min-height: 20px; + display: grid; + place-items: center; + margin-top: 8px; +} + +.client-state-detail > * { + grid-area: 1 / 1; + margin: 0; + animation: client-state-reveal 850ms 80ms cubic-bezier(0.16, 1, 0.3, 1) both; } .client-duration { display: block; - margin-top: 8px; color: var(--client-text); font-size: 14px; font-variant-numeric: tabular-nums; @@ -969,8 +1014,13 @@ code, .mono { } .client-power-section p { - min-height: 17px; - margin-top: 5px; + min-height: 0; + line-height: 20px; +} + +@keyframes client-state-reveal { + 0% { opacity: 0; filter: blur(5px); } + 100% { opacity: 1; filter: blur(0); } } .client-form { @@ -982,12 +1032,59 @@ code, .mono { } .client-subscription { + position: relative; min-height: 88px; display: grid; align-items: center; justify-items: center; } +.client-subscription-refresh { + width: 16px; + height: 16px; + display: grid; + place-items: center; + padding: 0; + border: 0; + border-radius: 50%; + background: transparent; + color: var(--client-muted); + cursor: pointer; + transition: color 250ms ease, filter 500ms ease, opacity 300ms ease, transform 600ms cubic-bezier(0.16, 1, 0.3, 1); +} + +.client-subscription-refresh svg { + width: 14px; + height: 14px; + fill: none; + stroke: currentColor; + stroke-width: 1.7; + stroke-linecap: round; + stroke-linejoin: round; +} + +.client-subscription-refresh:hover:not(:disabled) { + color: var(--client-accent); + filter: drop-shadow(0 0 6px color-mix(in oklch, var(--client-accent) 55%, transparent)); + transform: rotate(90deg); +} + +.client-subscription-refresh:disabled { + color: var(--client-accent); + cursor: wait; + animation: client-spin 900ms linear infinite; +} + +.client-subscription-refresh:focus-visible { + outline: 2px solid var(--client-accent); + outline-offset: 2px; +} + +.client-subscription.is-editing .client-subscription-refresh { + opacity: 0; + pointer-events: none; +} + .client-subscription-edit, .client-subscription-summary { grid-area: 1 / 1; @@ -1030,6 +1127,21 @@ code, .mono { box-shadow: 0 0 8px var(--client-accent), 0 0 18px var(--client-accent-soft); } +.client-subscription.is-timing-out .client-subscription-edit::after { + animation: client-subscription-timeout 5s linear forwards; +} + +@keyframes client-subscription-timeout { + 0% { + opacity: 0.9; + box-shadow: 0 0 8px var(--client-accent), 0 0 18px var(--client-accent-soft); + } + 100% { + opacity: 0.08; + box-shadow: 0 0 0 transparent; + } +} + .client-subscription.is-editing .client-subscription-edit { opacity: 1; filter: blur(0); @@ -1046,7 +1158,7 @@ code, .mono { outline: 0; background: transparent; color: var(--client-text); - caret-color: var(--client-accent); + caret-color: transparent; font-size: 12px; text-align: center; } @@ -1094,6 +1206,20 @@ code, .mono { color: var(--client-accent); font-size: 19px; font-weight: 700; +} + +.client-subscription-heading { + display: flex; + align-items: center; + gap: 8px; +} + +.client-subscription-domain-button { + padding: 0; + border: 0; + background: transparent; + color: inherit; + font: inherit; cursor: pointer; } @@ -1117,13 +1243,13 @@ code, .mono { pointer-events: none; } -.client-subscription-summary span { +.client-subscription-heading > span { font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; } -.client-subscription-summary strong { +.client-subscription-domain-button strong { color: var(--client-text); font-size: 28px; font-weight: 600; @@ -1133,12 +1259,89 @@ code, .mono { transition: opacity 300ms cubic-bezier(0.16, 1, 0.3, 1), text-shadow 300ms cubic-bezier(0.16, 1, 0.3, 1), transform 300ms cubic-bezier(0.16, 1, 0.3, 1); } -.client-subscription-summary:hover strong { +.client-subscription-domain-button:hover strong { opacity: 1; transform: translateY(-2px); text-shadow: 0 0 28px oklch(0.7 0.08 151 / 0.42); } +.client-usage { + width: min(100%, 250px); + display: grid; + gap: 7px; + margin: -10px auto 0; + color: var(--client-muted); + text-align: center; +} + +.client-usage > span { + font-size: 9px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +@keyframes client-spin { + to { transform: rotate(360deg); } +} + +.client-usage > strong { + color: var(--client-text); + font-size: 14px; + font-weight: 600; + transition: color 700ms ease, filter 700ms ease, text-shadow 700ms ease; +} + +.client-usage.is-updated { + animation: client-usage-glow 1100ms cubic-bezier(0.16, 1, 0.3, 1); +} + +.client-usage.is-updated > strong { + color: var(--client-accent); + filter: brightness(1.25); + text-shadow: 0 0 8px var(--client-accent), 0 0 22px color-mix(in oklch, var(--client-accent) 70%, transparent); +} + +.client-usage.is-updated .client-usage-bar i { + animation: client-bar-flare 1100ms cubic-bezier(0.16, 1, 0.3, 1); +} + +@keyframes client-usage-glow { + 30% { filter: drop-shadow(0 0 18px color-mix(in oklch, var(--client-accent) 78%, transparent)); } +} + +@keyframes client-bar-flare { + 30% { box-shadow: 0 0 6px var(--client-accent), 0 0 20px var(--client-accent); filter: brightness(1.45); } +} + +.client-usage > strong small { + color: var(--client-muted); + font-size: 10px; + font-weight: 500; +} + +.client-usage-bar { + height: 2px; + overflow: hidden; + background: var(--client-border); +} + +.client-usage-bar i { + display: block; + height: 100%; + background: var(--client-accent); + box-shadow: 0 0 8px var(--client-accent); + transition: width 600ms cubic-bezier(0.16, 1, 0.3, 1); +} + +.client-usage-details { + display: flex; + justify-content: center; + gap: 12px; + font-size: 9px; + white-space: nowrap; +} + .client-servers { display: block; } @@ -1166,9 +1369,44 @@ code, .mono { color: var(--client-text); text-align: center; cursor: pointer; + animation: client-server-enter 760ms calc(var(--server-index) * 110ms) cubic-bezier(0.16, 1, 0.3, 1) both; transition: border-color 160ms ease, background 160ms ease, transform 160ms ease; } +.client-server-grid.is-leaving { + pointer-events: none; +} + +.client-server-grid.is-leaving .client-server { + animation: client-server-leave 420ms calc(var(--server-index) * 90ms) cubic-bezier(0.4, 0, 1, 1) forwards; +} + +@keyframes client-server-leave { + 0% { + opacity: 1; + filter: blur(0); + transform: translateY(0); + } + 100% { + opacity: 0; + filter: blur(3px); + transform: translateY(8px); + } +} + +@keyframes client-server-enter { + 0% { + opacity: 0; + filter: blur(3px); + transform: translateY(-8px); + } + 100% { + opacity: 1; + filter: blur(0); + transform: translateY(0); + } +} + .client-server:hover:not(:disabled) { transform: translateY(-2px); background: transparent; @@ -1209,7 +1447,7 @@ code, .mono { } .client-subscription-edit button:focus-visible, -.client-subscription-summary:focus-visible, +.client-subscription-domain-button:focus-visible, .client-server:focus-visible, .client-power:focus-visible { outline: 2px solid var(--client-accent); @@ -1259,9 +1497,26 @@ code, .mono { transition: color 220ms ease, opacity 220ms ease, transform 220ms cubic-bezier(0.16, 1, 0.3, 1); } -.client-copy-check { +.client-copy-label { + transition: opacity 100ms ease; +} + +.client-proxies button.is-copied .client-copy-label { + opacity: 0; +} + +.client-copy-feedback { position: absolute; - right: 10px; + inset: 0; + display: grid; + place-items: center; + color: var(--client-accent); + animation: client-copy-fade 800ms cubic-bezier(0.4, 0, 1, 1) forwards; +} + +@keyframes client-copy-fade { + 0%, 18% { opacity: 1; filter: drop-shadow(0 0 5px var(--client-accent)); } + 100% { opacity: 0; filter: drop-shadow(0 0 0 transparent); } } .client-proxies button:hover { @@ -1320,13 +1575,26 @@ code, .mono { @media (prefers-reduced-motion: reduce) { .client-power, + .client-power::before, .client-power svg, + .client-usage-bar i, + .client-subscription-refresh, + .client-usage, + .client-usage > strong, .client-server, .client-proxies button, + .client-copy-feedback, .client-subscription-edit, + .client-subscription-edit::after, .client-subscription-summary, .client-subscription-summary strong { transition: none; + animation: none; + } + + .client-state-copy h2, + .client-state-detail > * { + animation: none; } } diff --git a/src/web/utils/clientControls.js b/src/web/utils/clientControls.js index 0a5f385..72ce44c 100644 --- a/src/web/utils/clientControls.js +++ b/src/web/utils/clientControls.js @@ -34,3 +34,31 @@ export function localProxyUrls(port = 8082) { http: `http://127.0.0.1:${port}`, }; } + +export function subscriptionUsage(userInfo = {}) { + const upload = Math.max(0, Number(userInfo.upload) || 0); + const download = Math.max(0, Number(userInfo.download) || 0); + const total = Math.max(0, Number(userInfo.total) || 0); + const used = upload + download; + + return { + upload, + download, + total, + used, + percent: total ? Math.min(100, (used / total) * 100) : null, + expiresAt: userInfo.expire ? new Date(Number(userInfo.expire) * 1000) : null, + }; +} + +export function subscriptionDaysLeft(expiresAt, now = Date.now()) { + const days = Math.ceil((expiresAt?.getTime() - now) / 86_400_000); + if (!Number.isFinite(days)) return ''; + if (days <= 0) return 'срок истёк'; + const mod10 = days % 10; + const mod100 = days % 100; + const unit = mod10 === 1 && mod100 !== 11 + ? 'день' + : mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14) ? 'дня' : 'дней'; + return `${days === 1 ? 'остался' : 'осталось'} ${days} ${unit}`; +} diff --git a/test/web/client-controls.test.js b/test/web/client-controls.test.js index 1ab43cd..ca55cd9 100644 --- a/test/web/client-controls.test.js +++ b/test/web/client-controls.test.js @@ -6,6 +6,8 @@ import { formatConnectionDuration, localProxyUrls, subscriptionDomain, + subscriptionDaysLeft, + subscriptionUsage, } from '../../src/web/utils/clientControls.js'; test('connection button chooses the only valid client action', () => { @@ -44,3 +46,21 @@ test('local proxy defaults to the macOS client port', () => { http: 'http://127.0.0.1:8082', }); }); + +test('subscription usage combines traffic and caps progress', () => { + assert.deepEqual(subscriptionUsage({ upload: 30, download: 80, total: 100, expire: 2 }), { + upload: 30, + download: 80, + total: 100, + used: 110, + percent: 100, + expiresAt: new Date(2000), + }); +}); + +test('subscription expiry uses Russian day forms', () => { + const now = Date.parse('2026-07-11T00:00:00Z'); + assert.equal(subscriptionDaysLeft(new Date('2026-07-12T00:00:00Z'), now), 'остался 1 день'); + assert.equal(subscriptionDaysLeft(new Date('2026-07-13T00:00:00Z'), now), 'осталось 2 дня'); + assert.equal(subscriptionDaysLeft(new Date('2026-07-16T00:00:00Z'), now), 'осталось 5 дней'); +});