diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index 671f3f6..716d4de 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -448,6 +448,12 @@ const cryptoFallbackLocales = { }, portfolio: { total: 'Total portfolio', + performance: 'Profit / loss', + return: 'Total return', + current: 'Current position', + history: 'Performance', + forecast: 'Forecast', + chartLabel: 'Profit and loss performance with dashed forecast', cash: 'Available cash', invested: 'Invested assets', allocation: 'Portfolio allocation', diff --git a/frontend/src/views/apps/CryptoApp.contract.test.ts b/frontend/src/views/apps/CryptoApp.contract.test.ts index 83ab2ae..4364c3b 100644 --- a/frontend/src/views/apps/CryptoApp.contract.test.ts +++ b/frontend/src/views/apps/CryptoApp.contract.test.ts @@ -63,6 +63,16 @@ describe('VaultX crypto app contracts', () => { expect(source).not.toContain(':aria-label="t(\'refresh\')"') }) + it('anchors portfolio performance history and forecast at the current position', () => { + expect(source).toContain('const portfolioProfitLoss = computed') + expect(source).toContain('class="portfolio-history"') + expect(source).toContain('class="portfolio-forecast"') + expect(source).toContain(':cx="portfolioChart.currentX"') + expect(source).toContain(':cy="portfolioChart.currentY"') + expect(source).toContain("t('portfolio.current')") + expect(source).toContain('stroke-dasharray: 7 7') + }) + it('uses the compact Flare-style navigation and polished overlay transitions', () => { expect(source).toContain('class="vault-navbar"') expect(source).toContain('class="vault-header-logo"') diff --git a/frontend/src/views/apps/CryptoApp.vue b/frontend/src/views/apps/CryptoApp.vue index 4c08c90..dd1d475 100644 --- a/frontend/src/views/apps/CryptoApp.vue +++ b/frontend/src/views/apps/CryptoApp.vue @@ -80,13 +80,6 @@ const activities = computed(() => : ['deposit', 'withdrawal'].includes(item.type)), ), ) -const portfolioLine = computed(() => markets.value[0]?.sparkline ?? []) -const portfolioChange = computed(() => - markets.value.length - ? markets.value.reduce((sum, market) => sum + market.changePercent, 0) / - markets.value.length - : 0, -) const investedValue = computed(() => Math.max( 0, @@ -94,6 +87,109 @@ const investedValue = computed(() => Number(crypto.data?.cashBalance ?? 0), ), ) +const portfolioCostBasis = computed(() => + holdings.value.reduce( + (sum, holding) => + sum + Number(holding.quantity) * Number(holding.averagePrice), + 0, + ), +) +const portfolioProfitLoss = computed( + () => investedValue.value - portfolioCostBasis.value, +) +const portfolioProfitPercent = computed(() => + portfolioCostBasis.value > 0 + ? (portfolioProfitLoss.value / portfolioCostBasis.value) * 100 + : 0, +) +const portfolioChart = computed(() => { + const width = 320 + const height = 126 + const startX = 18 + const currentX = 224 + const forecastEndX = 306 + const sampleCount = 10 + const weightedMarkets = holdings.value + .map((holding) => ({ + cost: Number(holding.quantity) * Number(holding.averagePrice), + market: markets.value.find((item) => item.id === holding.assetId), + })) + .filter((item): item is { cost: number; market: CryptoMarket } => + Boolean(item.market?.sparkline.length), + ) + const totalWeight = + weightedMarkets.reduce((sum, item) => sum + item.cost, 0) || 1 + const reference = Array.from({ length: sampleCount }, (_, index) => + weightedMarkets.reduce((sum, item) => { + const sourceIndex = Math.round( + (index / (sampleCount - 1)) * (item.market.sparkline.length - 1), + ) + return ( + sum + item.market.sparkline[sourceIndex] * (item.cost / totalWeight) + ) + }, 0), + ) + const currentProfit = portfolioProfitLoss.value + const referenceEnd = reference[reference.length - 1] ?? 0.5 + const amplitude = Math.max( + portfolioCostBasis.value * 0.045, + Math.abs(currentProfit) * 0.38, + 40, + ) + const historyValues = reference.map( + (value) => currentProfit + (value - referenceEnd) * amplitude, + ) + historyValues[historyValues.length - 1] = currentProfit + + const recentMomentum = + currentProfit - (historyValues[historyValues.length - 3] ?? currentProfit) + const forecastValues = [ + currentProfit, + currentProfit + recentMomentum * 0.42, + currentProfit + recentMomentum * 0.58 + amplitude * 0.035, + currentProfit + recentMomentum * 0.48 - amplitude * 0.018, + ] + const extent = Math.max( + 50, + ...historyValues.map((value) => Math.abs(value)), + ...forecastValues.map((value) => Math.abs(value)), + ) + const scale = Math.ceil((extent * 1.18) / 50) * 50 + const chartTop = 15 + const chartBottom = height - 16 + const chartRange = chartBottom - chartTop + const y = (value: number) => + chartTop + ((scale - value) / (scale * 2)) * chartRange + const historyPoints = historyValues + .map( + (value, index) => + `${ + startX + index * ((currentX - startX) / (historyValues.length - 1)) + },${y(value)}`, + ) + .join(' ') + const forecastPoints = forecastValues + .map( + (value, index) => + `${ + currentX + + index * ((forecastEndX - currentX) / (forecastValues.length - 1)) + },${y(value)}`, + ) + .join(' ') + + return { + currentX, + currentY: y(currentProfit), + forecastPoints, + height, + historyPoints, + labelY: Math.max(3, Math.min(height - 29, y(currentProfit) - 27)), + scale, + width, + zeroY: y(0), + } +}) const cashShare = computed(() => { const total = Number(crypto.data?.portfolioValue ?? 0) return total > 0 @@ -123,9 +219,18 @@ function money(value: string | number) { style: 'currency', }).format(Number(value) || 0) } +function signedMoney(value: number) { + const formatted = money(Math.abs(value)) + if (value > 0) return `+${formatted}` + if (value < 0) return `−${formatted}` + return formatted +} function privateMoney(value: string | number) { return profile.value?.hideBalances ? '••••••' : money(value) } +function privateSignedMoney(value: number) { + return profile.value?.hideBalances ? '••••••' : signedMoney(value) +} function quantity(value: string) { return new Intl.NumberFormat(locale.value, { maximumFractionDigits: 6, @@ -515,34 +620,105 @@ onMounted(() => void crypto.load())
- {{ t('portfolio.total') }} - {{ t('markets.live') }} + {{ t('portfolio.performance') }} + {{ t('portfolio.forecast') }}
- {{ - privateMoney(crypto.data?.portfolioValue ?? '0') - }} - - {{ portfolioChange >= 0 ? '↗' : '↘' }} - {{ Math.abs(portfolioChange).toFixed(2) }}% - {{ t('marketDetail.today') }} + + {{ privateSignedMoney(portfolioProfitLoss) }} + + + {{ portfolioProfitPercent >= 0 ? '↗' : '↘' }} + {{ Math.abs(portfolioProfitPercent).toFixed(2) }}% + {{ t('portfolio.return') }}
-
- - - - - - - - +
+ {{ t('portfolio.history') }} + {{ t('portfolio.forecast') }} +
+ -
@@ -1846,6 +2022,15 @@ onMounted(() => void crypto.load()) .portfolio-topline svg { color: var(--vault-mint); } +.forecast-pill { + display: inline-flex; + align-items: center; + padding: 5px 8px; + color: #b9c8ff; + background: rgba(112, 143, 255, 0.09); + border: 1px solid rgba(140, 169, 255, 0.18); + border-radius: var(--sky-radius-pill); +} .live-pill, .profile-status { display: inline-flex; @@ -1889,15 +2074,67 @@ onMounted(() => void crypto.load()) } .portfolio-chart { position: relative; - height: 112px; - margin: 2px -17px 0; + height: 148px; + margin: 8px -8px 0; } .portfolio-chart svg { + display: block; width: 100%; - height: 100%; + height: 126px; overflow: visible; } -.portfolio-chart polyline, +.portfolio-chart__legend { + display: flex; + justify-content: flex-end; + gap: 12px; + height: 22px; + padding-right: 7px; + color: var(--muted); + font-size: 7px; + font-weight: 700; +} +.portfolio-chart__legend span { + display: flex; + align-items: center; + gap: 4px; +} +.portfolio-chart__legend i { + width: 13px; + height: 2px; + border-radius: 4px; + background: var(--vault-mint); +} +.portfolio-chart__legend i.is-forecast { + background: repeating-linear-gradient( + 90deg, + #8ca9ff 0 4px, + transparent 4px 7px + ); +} +.portfolio-chart__forecast-zone { + fill: rgba(112, 143, 255, 0.055); +} +.portfolio-chart__grid, +.portfolio-chart__zero, +.portfolio-chart__boundary { + vector-effect: non-scaling-stroke; +} +.portfolio-chart__grid { + stroke: rgba(255, 255, 255, 0.055); + stroke-width: 1; +} +.portfolio-chart__zero { + stroke: rgba(255, 255, 255, 0.2); + stroke-width: 1; + stroke-dasharray: 2 5; +} +.portfolio-chart__boundary { + stroke: rgba(140, 169, 255, 0.22); + stroke-width: 1; + stroke-dasharray: 3 5; +} +.portfolio-history, +.portfolio-forecast, .featured-market polyline { fill: none; stroke: #dffff6; @@ -1907,18 +2144,34 @@ onMounted(() => void crypto.load()) vector-effect: non-scaling-stroke; filter: drop-shadow(0 0 8px rgba(101, 251, 210, 0.5)); } -.chart-orb { - position: absolute; - right: 11%; - top: 28%; - width: 9px; - height: 9px; - border: 2px solid #fff; - border-radius: 50%; - background: var(--vault-mint); - box-shadow: - 0 0 0 5px rgba(101, 251, 210, 0.12), - 0 0 17px var(--vault-mint); +.portfolio-forecast { + stroke: #8ca9ff; + stroke-dasharray: 7 7; + filter: drop-shadow(0 0 7px rgba(112, 143, 255, 0.4)); +} +.portfolio-current-dot { + fill: var(--vault-mint); + stroke: #fff; + stroke-width: 2; + vector-effect: non-scaling-stroke; + filter: drop-shadow(0 0 6px var(--vault-mint)); +} +.portfolio-current-label rect { + fill: #101923; + stroke: rgba(101, 251, 210, 0.45); + stroke-width: 1; + vector-effect: non-scaling-stroke; +} +.portfolio-current-label text { + fill: #fff; + font-size: 7px; + font-weight: 800; + letter-spacing: 0.01em; +} +.portfolio-scale-label { + fill: rgba(255, 255, 255, 0.46); + font-size: 7px; + font-weight: 700; } .portfolio-metrics { position: relative; @@ -2654,8 +2907,7 @@ onMounted(() => void crypto.load()) border: 1px solid rgba(255, 255, 255, 0.07); } @media (prefers-reduced-motion: reduce) { - .portfolio-shell::after, - .chart-orb { + .portfolio-shell::after { display: none; } .vault-view { diff --git a/sky_phone/config/locales/de.lua b/sky_phone/config/locales/de.lua index dea88b7..ad88962 100644 --- a/sky_phone/config/locales/de.lua +++ b/sky_phone/config/locales/de.lua @@ -1055,7 +1055,7 @@ Locales["de"] = { eyebrow = "Charaktergebundene Börse", loginTitle = "Willkommen zurück", registerTitle = "Erstelle dein VaultX-Profil", body = "Handle fiktive digitale Assets mit dem Ingame-Geld deines Charakters.", login = "Einloggen", register = "Registrieren", handle = "VaultX-Benutzername", handlePlaceholder = "3–20 Buchstaben oder Zahlen", password = "Passwort", passwordPlaceholder = "8–72 Zeichen", showPassword = "Passwort anzeigen", hidePassword = "Passwort ausblenden", security = "Nutze ein In-Character-Passwort. Verwende niemals ein echtes Passwort erneut.", loginAction = "Portfolio öffnen", registerAction = "Sicheres Profil erstellen", }, tabs = { portfolio = "Portfolio", markets = "Märkte", activity = "Aktivität", profile = "Profil" }, - portfolio = { total = "Gesamtes Portfolio", cash = "Verfügbares Guthaben", invested = "Investierte Assets", allocation = "Portfolio-Verteilung", inMarket = "in Märkten", assets = "Assets", holdings = "Deine Assets", empty = "Noch keine Assets", emptyBody = "Öffne Märkte und fordere ein geschütztes Angebot an.", avg = "Ø", insightTitle = "Portfolio-Einblick", insightBody = "Deine Bestände werden mit den neuesten synthetischen Serverkursen bewertet." }, + portfolio = { total = "Gesamtes Portfolio", performance = "Gewinn / Verlust", return = "Gesamtrendite", current = "Jetziger Stand", history = "Entwicklung", forecast = "Prognose", chartLabel = "Gewinn- und Verlustentwicklung mit gestrichelter Prognose", cash = "Verfügbares Guthaben", invested = "Investierte Assets", allocation = "Portfolio-Verteilung", inMarket = "in Märkten", assets = "Assets", holdings = "Deine Assets", empty = "Noch keine Assets", emptyBody = "Öffne Märkte und fordere ein geschütztes Angebot an.", avg = "Ø", insightTitle = "Portfolio-Einblick", insightBody = "Deine Bestände werden mit den neuesten synthetischen Serverkursen bewertet." }, quick = { trade = "Handeln", more = "Mehr" }, actions = { deposit = "Einzahlen", withdraw = "Auszahlen" }, markets = { title = "Fiktive Märkte", live = "Serverpreise", noticeTitle = "Ingame-Börse", notice = "Die Kurse sind synthetisch und haben keinen realen Wert. Jeder Handel wird gegen die begrenzte Liquidität der Börse geprüft.", eyebrow = "Marktpuls", movers = "Top-Beweger", today = "Heute" }, diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua index b929908..017b622 100644 --- a/sky_phone/config/locales/en.lua +++ b/sky_phone/config/locales/en.lua @@ -1055,7 +1055,7 @@ Locales["en"] = { eyebrow = "Character-bound exchange", loginTitle = "Welcome back", registerTitle = "Create your VaultX profile", body = "Trade fictional digital assets with your character's in-game funds.", login = "Log in", register = "Register", handle = "VaultX handle", handlePlaceholder = "3–20 letters or numbers", password = "Password", passwordPlaceholder = "8–72 characters", showPassword = "Show password", hidePassword = "Hide password", security = "Use an in-character password. Never reuse a real password.", loginAction = "Open portfolio", registerAction = "Create secure profile", }, tabs = { portfolio = "Portfolio", markets = "Markets", activity = "Activity", profile = "Profile" }, - portfolio = { total = "Total portfolio", cash = "Available cash", invested = "Invested assets", allocation = "Portfolio allocation", inMarket = "in markets", assets = "assets", holdings = "Your assets", empty = "No assets yet", emptyBody = "Open Markets to request a protected quote.", avg = "Avg.", insightTitle = "Portfolio insight", insightBody = "Your holdings are valued against the latest synthetic server prices." }, + portfolio = { total = "Total portfolio", performance = "Profit / loss", return = "Total return", current = "Current position", history = "Performance", forecast = "Forecast", chartLabel = "Profit and loss performance with dashed forecast", cash = "Available cash", invested = "Invested assets", allocation = "Portfolio allocation", inMarket = "in markets", assets = "assets", holdings = "Your assets", empty = "No assets yet", emptyBody = "Open Markets to request a protected quote.", avg = "Avg.", insightTitle = "Portfolio insight", insightBody = "Your holdings are valued against the latest synthetic server prices." }, quick = { trade = "Trade", more = "More" }, actions = { deposit = "Deposit", withdraw = "Withdraw" }, markets = { title = "Fictional markets", live = "Server priced", noticeTitle = "In-game exchange", notice = "Prices are synthetic and have no real-world value. Every trade is checked against finite exchange liquidity.", eyebrow = "Market pulse", movers = "Top movers", today = "Today" },