From aefd6cc0350c71f4436b1f15bb8f89fced52c2f2 Mon Sep 17 00:00:00 2001 From: ZProger Date: Fri, 4 Sep 2026 01:39:18 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BF=D0=BE=D1=80=D1=82=D1=84=D0=B5?= =?UTF-8?q?=D0=BB=D0=B8=20=D0=B2=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D0=B5=20=C2=AB=D0=9A=D1=80=D0=B8=D0=BF=D1=82=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D0=BB=D1=8E=D1=82=D0=B0=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Криптовалюту теперь можно разделять на отдельные портфели (например, долгосрочные инвестиции, накопления на недвижимость, спекулятивные монеты) вместо одного общего списка — по тому же принципу, что уже есть у счетов (Account): у портфеля есть имя, цветная метка и архивация, каждая монета обязательно принадлежит одному портфелю, один и тот же токен можно держать в нескольких портфелях сразу. --- .gitignore | 3 +- .../b7d4e9f2a610_add_crypto_portfolios.py | 55 +++++++ backend/app/api/routes/crypto.py | 48 +++++- backend/app/core/config.py | 2 +- backend/app/models/__init__.py | 3 +- backend/app/models/crypto.py | 35 +++- backend/app/schemas/backup.py | 19 ++- backend/app/schemas/crypto.py | 23 +++ backend/app/services/backup_service.py | 43 ++++- backend/app/services/crypto_service.py | 132 +++++++++++++-- backend/tests/test_crypto.py | 155 ++++++++++++++++++ frontend/package.json | 2 +- frontend/src/api/crypto.ts | 39 ++++- .../src/components/crypto/CryptoAddModal.tsx | 42 ++++- .../components/crypto/CryptoHoldingsTable.tsx | 31 +++- .../crypto/CryptoPortfolioFormModal.tsx | 124 ++++++++++++++ .../components/crypto/CryptoPortfolioTabs.tsx | 78 +++++++++ frontend/src/hooks/useCrypto.ts | 57 ++++++- frontend/src/lib/i18n.ts | 22 +++ frontend/src/pages/CryptoPage.tsx | 53 +++++- frontend/src/types/index.ts | 17 ++ 21 files changed, 933 insertions(+), 50 deletions(-) create mode 100644 backend/alembic/versions/b7d4e9f2a610_add_crypto_portfolios.py create mode 100644 frontend/src/components/crypto/CryptoPortfolioFormModal.tsx create mode 100644 frontend/src/components/crypto/CryptoPortfolioTabs.tsx diff --git a/.gitignore b/.gitignore index 3bffad0..b30a988 100644 --- a/.gitignore +++ b/.gitignore @@ -50,4 +50,5 @@ dist/ pgdata/ # Other -UPDATES.md \ No newline at end of file +UPDATES.md +temp/ \ No newline at end of file diff --git a/backend/alembic/versions/b7d4e9f2a610_add_crypto_portfolios.py b/backend/alembic/versions/b7d4e9f2a610_add_crypto_portfolios.py new file mode 100644 index 0000000..a279d10 --- /dev/null +++ b/backend/alembic/versions/b7d4e9f2a610_add_crypto_portfolios.py @@ -0,0 +1,55 @@ +"""add crypto portfolios + +Revision ID: b7d4e9f2a610 +Revises: 7b535ef9e189 +Create Date: 2026-09-04 12:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'b7d4e9f2a610' +down_revision: Union[str, None] = '7b535ef9e189' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + 'crypto_portfolios', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('color', sa.String(length=7), nullable=True), + sa.Column('is_archived', sa.Boolean(), nullable=False, server_default=sa.text('false')), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id'), + ) + # Every crypto holding needs a portfolio_id (see below) — seed one + # default portfolio up front so existing holdings have somewhere to + # land, same "row exists immediately after migrating" reasoning as + # 9f3a2d7c5e11's app_settings seed. + op.execute("INSERT INTO crypto_portfolios (id, name, color, is_archived) VALUES (1, 'Main Portfolio', '#2a78d6', false)") + op.execute("SELECT setval(pg_get_serial_sequence('crypto_portfolios', 'id'), 1)") + + op.add_column('crypto_holdings', sa.Column('portfolio_id', sa.Integer(), nullable=True)) + op.execute("UPDATE crypto_holdings SET portfolio_id = 1") + op.alter_column('crypto_holdings', 'portfolio_id', nullable=False) + op.create_foreign_key( + 'fk_crypto_holdings_portfolio_id', + 'crypto_holdings', + 'crypto_portfolios', + ['portfolio_id'], + ['id'], + ondelete='RESTRICT', + ) + + +def downgrade() -> None: + op.drop_constraint('fk_crypto_holdings_portfolio_id', 'crypto_holdings', type_='foreignkey') + op.drop_column('crypto_holdings', 'portfolio_id') + op.drop_table('crypto_portfolios') diff --git a/backend/app/api/routes/crypto.py b/backend/app/api/routes/crypto.py index 63bb765..f92c19e 100644 --- a/backend/app/api/routes/crypto.py +++ b/backend/app/api/routes/crypto.py @@ -6,6 +6,9 @@ from app.schemas.crypto import ( CryptoHistoryResponse, CryptoHoldingCreate, CryptoHoldingRead, + CryptoPortfolioCreate, + CryptoPortfolioRead, + CryptoPortfolioUpdate, CryptoSearchResult, CryptoSyncResult, CryptoTransactionCreate, @@ -16,31 +19,65 @@ from app.services.crypto_service import ( CRYPTO_RANGE_DAYS, add_transaction, create_holding, + create_portfolio, + delete_portfolio, delete_transaction, get_crypto_history, + list_portfolios, list_transactions, refresh_prices, search_coins, + update_portfolio, update_transaction, ) router = APIRouter(prefix="/crypto", tags=["crypto"]) +@router.get("/portfolios", response_model=list[CryptoPortfolioRead]) +async def read_portfolios( + include_archived: bool = False, session: AsyncSession = Depends(get_session) +) -> list[CryptoPortfolioRead]: + return await list_portfolios(session, include_archived) + + +@router.post("/portfolios", response_model=CryptoPortfolioRead, status_code=201) +async def create_portfolio_route( + payload: CryptoPortfolioCreate, session: AsyncSession = Depends(get_session) +) -> CryptoPortfolioRead: + return await create_portfolio(session, payload) + + +@router.patch("/portfolios/{portfolio_id}", response_model=CryptoPortfolioRead) +async def update_portfolio_route( + portfolio_id: int, payload: CryptoPortfolioUpdate, session: AsyncSession = Depends(get_session) +) -> CryptoPortfolioRead: + return await update_portfolio(session, portfolio_id, payload) + + +@router.delete("/portfolios/{portfolio_id}", status_code=204) +async def delete_portfolio_route(portfolio_id: int, session: AsyncSession = Depends(get_session)) -> None: + await delete_portfolio(session, portfolio_id) + + @router.get("/holdings", response_model=CryptoSyncResult) -async def read_holdings(session: AsyncSession = Depends(get_session)) -> CryptoSyncResult: +async def read_holdings( + portfolio_id: int | None = None, session: AsyncSession = Depends(get_session) +) -> CryptoSyncResult: """Opening the Crypto tab lands here — this is also where the lazy once-a-day auto-refresh happens (see services/crypto_service.py): prices only actually get re-fetched from CoinGecko if 24h have passed since the last sync, otherwise this just reads the current cache.""" - return await refresh_prices(session, force=False) + return await refresh_prices(session, force=False, portfolio_id=portfolio_id) @router.post("/refresh", response_model=CryptoSyncResult) -async def refresh_holdings(session: AsyncSession = Depends(get_session)) -> CryptoSyncResult: +async def refresh_holdings( + portfolio_id: int | None = None, session: AsyncSession = Depends(get_session) +) -> CryptoSyncResult: """The "Refresh prices" button — always hits CoinGecko regardless of the once-a-day window.""" - return await refresh_prices(session, force=True) + return await refresh_prices(session, force=True, portfolio_id=portfolio_id) @router.post("/holdings", response_model=CryptoHoldingRead, status_code=201) @@ -89,6 +126,7 @@ _HISTORY_RANGE_PATTERN = f"^({'|'.join(_HISTORY_RANGES)})$" @router.get("/history", response_model=CryptoHistoryResponse) async def read_crypto_history( range: str = Query(default="30d", pattern=_HISTORY_RANGE_PATTERN), + portfolio_id: int | None = None, session: AsyncSession = Depends(get_session), ) -> CryptoHistoryResponse: - return await get_crypto_history(session, range) + return await get_crypto_history(session, range, portfolio_id) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 05ff433..0031624 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -7,7 +7,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict # title, /api/health (which the frontend reads to show it in Settings), and # embedded in exported backups so an old file can be told apart from a # current one. -APP_VERSION = "1.1.1" +APP_VERSION = "1.1.2" class Settings(BaseSettings): diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index baec4ec..30cb56a 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -2,7 +2,7 @@ from app.models.account import Account from app.models.asset import Asset, AssetValuation from app.models.budget import Budget from app.models.category import Category -from app.models.crypto import CryptoHolding, CryptoSyncState, CryptoTransaction +from app.models.crypto import CryptoHolding, CryptoPortfolio, CryptoSyncState, CryptoTransaction from app.models.goal import Goal, GoalContribution from app.models.recurring import RecurringTransaction from app.models.settings import AppSettings @@ -17,6 +17,7 @@ __all__ = [ "Budget", "Category", "CryptoHolding", + "CryptoPortfolio", "CryptoSyncState", "CryptoTransaction", "Goal", diff --git a/backend/app/models/crypto.py b/backend/app/models/crypto.py index a779da8..4973359 100644 --- a/backend/app/models/crypto.py +++ b/backend/app/models/crypto.py @@ -9,11 +9,16 @@ CryptoTransaction log (same "you record it, we derive it" shape as Goal/GoalContribution), because a sell doesn't just subtract quantity, it also has to leave the average cost basis of what's still held unchanged (see services/crypto_service.py's _compute_position). + +Every holding also belongs to a CryptoPortfolio (below) — a user-defined +group like "long-term" or "memes" — so the same coin can be tracked +separately in more than one portfolio (each as its own CryptoHolding row, +since coingecko_id is deliberately not unique). """ from datetime import date as date_ from datetime import datetime -from sqlalchemy import Date, DateTime, Enum, ForeignKey, Numeric, String, Text +from sqlalchemy import Boolean, Date, DateTime, Enum, ForeignKey, Numeric, String, Text from sqlalchemy.orm import Mapped, mapped_column, relationship from app.db.base import Base @@ -22,6 +27,26 @@ from app.models.enums import CryptoTransactionType from app.models.mixins import TimestampMixin +class CryptoPortfolio(Base, TimestampMixin): + """A user-defined grouping of crypto holdings (e.g. "long-term", + "real estate fund", "memes") — same shape as Account (id, name, color, + is_archived), but scoped to the Crypto tab instead of Transactions. + Every CryptoHolding belongs to exactly one portfolio (see + CryptoHolding.portfolio_id below); there's no "ungrouped" state.""" + + __tablename__ = "crypto_portfolios" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(100), nullable=False) + # Hex color for the portfolio's tab dot — auto-assigned from the app's + # categorical palette at creation time (see services/crypto_service.py's + # _next_portfolio_color), not user-editable, same as Account.color. + color: Mapped[str | None] = mapped_column(String(7), nullable=True) + is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + + holdings: Mapped[list["CryptoHolding"]] = relationship(back_populates="portfolio") + + class CryptoHolding(Base, TimestampMixin): """1:1 extension of an Asset (asset_class=CRYPTO) — asset_id doubles as this table's own primary key rather than a separate id+unique @@ -33,6 +58,13 @@ class CryptoHolding(Base, TimestampMixin): __tablename__ = "crypto_holdings" asset_id: Mapped[int] = mapped_column(ForeignKey("assets.id", ondelete="CASCADE"), primary_key=True) + # Which portfolio this coin is tracked under (see CryptoPortfolio above). + # RESTRICT, not CASCADE: deleting a non-empty portfolio is blocked at the + # service layer (services/crypto_service.py's delete_portfolio) rather + # than silently taking its holdings down with it. + portfolio_id: Mapped[int] = mapped_column( + ForeignKey("crypto_portfolios.id", ondelete="RESTRICT"), nullable=False + ) # CoinGecko's stable coin id (e.g. "bitcoin") — NOT the ticker symbol, # which collides across unrelated coins (CoinGecko lists dozens of @@ -53,6 +85,7 @@ class CryptoHolding(Base, TimestampMixin): price_change_7d: Mapped[Numeric | None] = mapped_column(Numeric(10, 4), nullable=True) asset: Mapped["Asset"] = relationship() + portfolio: Mapped["CryptoPortfolio"] = relationship(back_populates="holdings") transactions: Mapped[list["CryptoTransaction"]] = relationship( back_populates="holding", cascade="all, delete-orphan", order_by="CryptoTransaction.date" ) diff --git a/backend/app/schemas/backup.py b/backend/app/schemas/backup.py index 6b05eec..ffca788 100644 --- a/backend/app/schemas/backup.py +++ b/backend/app/schemas/backup.py @@ -106,6 +106,15 @@ class AssetValuationBackup(BaseModel): as_of_date: date_ +class CryptoPortfolioBackup(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + name: str + color: str | None + is_archived: bool + + class CryptoHoldingBackup(BaseModel): model_config = ConfigDict(from_attributes=True) @@ -114,6 +123,11 @@ class CryptoHoldingBackup(BaseModel): # quantity here — it's derived from crypto_transactions below, not # stored. asset_id: int + # Defaulted so a backup exported before crypto portfolios existed still + # imports cleanly under the same format version — restore_backup() + # resolves a missing/unknown portfolio_id to an auto-created fallback + # portfolio (see services/backup_service.py). + portfolio_id: int | None = None coingecko_id: str symbol: str name: str @@ -217,8 +231,9 @@ class BackupPayload(BaseModel): transaction_splits: list[TransactionSplitBackup] = Field(default_factory=list) assets: list[AssetBackup] asset_valuations: list[AssetValuationBackup] - # Defaulted so a backup exported before crypto holdings existed still - # imports cleanly under the same format version. + # Defaulted so a backup exported before crypto holdings/portfolios + # existed still imports cleanly under the same format version. + crypto_portfolios: list[CryptoPortfolioBackup] = Field(default_factory=list) crypto_holdings: list[CryptoHoldingBackup] = Field(default_factory=list) crypto_transactions: list[CryptoTransactionBackup] = Field(default_factory=list) # Defaulted so a backup exported before budgets existed still imports diff --git a/backend/app/schemas/crypto.py b/backend/app/schemas/crypto.py index 06f6b43..b9cd019 100644 --- a/backend/app/schemas/crypto.py +++ b/backend/app/schemas/crypto.py @@ -39,7 +39,29 @@ class CryptoTransactionRead(BaseModel): note: str | None +class CryptoPortfolioCreate(BaseModel): + name: str = Field(min_length=1, max_length=100) + + +class CryptoPortfolioUpdate(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=100) + is_archived: bool | None = None + + +class CryptoPortfolioRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + name: str + color: str | None + is_archived: bool + + class CryptoHoldingCreate(BaseModel): + # Which portfolio to file this coin under — omit to use the default + # portfolio (the earliest-created one, auto-created if none exists yet; + # see services/crypto_service.py's get_or_create_default_portfolio). + portfolio_id: int | None = None coingecko_id: str = Field(min_length=1, max_length=100) symbol: str = Field(min_length=1, max_length=20) name: str = Field(min_length=1, max_length=150) @@ -54,6 +76,7 @@ class CryptoHoldingCreate(BaseModel): class CryptoHoldingRead(BaseModel): asset_id: int + portfolio_id: int coingecko_id: str symbol: str name: str diff --git a/backend/app/services/backup_service.py b/backend/app/services/backup_service.py index 846443e..5c939a2 100644 --- a/backend/app/services/backup_service.py +++ b/backend/app/services/backup_service.py @@ -21,7 +21,7 @@ from app.models.account import Account from app.models.asset import Asset, AssetValuation from app.models.budget import Budget from app.models.category import Category -from app.models.crypto import CryptoHolding, CryptoTransaction +from app.models.crypto import CryptoHolding, CryptoPortfolio, CryptoTransaction from app.models.goal import Goal, GoalContribution from app.models.recurring import RecurringTransaction from app.models.settings import AppSettings @@ -36,6 +36,7 @@ from app.schemas.backup import ( BudgetBackup, CategoryBackup, CryptoHoldingBackup, + CryptoPortfolioBackup, CryptoTransactionBackup, GoalBackup, GoalContributionBackup, @@ -56,6 +57,7 @@ async def build_backup(session: AsyncSession) -> BackupPayload: transaction_splits = (await session.execute(select(TransactionSplit))).scalars().all() assets = (await session.execute(select(Asset))).scalars().all() valuations = (await session.execute(select(AssetValuation))).scalars().all() + crypto_portfolios = (await session.execute(select(CryptoPortfolio))).scalars().all() crypto_holdings = (await session.execute(select(CryptoHolding))).scalars().all() crypto_transactions = (await session.execute(select(CryptoTransaction))).scalars().all() budgets = (await session.execute(select(Budget))).scalars().all() @@ -80,6 +82,7 @@ async def build_backup(session: AsyncSession) -> BackupPayload: transaction_splits=[TransactionSplitBackup.model_validate(row) for row in transaction_splits], assets=[AssetBackup.model_validate(row) for row in assets], asset_valuations=[AssetValuationBackup.model_validate(row) for row in valuations], + crypto_portfolios=[CryptoPortfolioBackup.model_validate(row) for row in crypto_portfolios], crypto_holdings=[CryptoHoldingBackup.model_validate(row) for row in crypto_holdings], crypto_transactions=[CryptoTransactionBackup.model_validate(row) for row in crypto_transactions], budgets=[BudgetBackup.model_validate(row) for row in budgets], @@ -124,10 +127,18 @@ def _validate_references(payload: BackupPayload) -> None: if v.asset_id not in asset_ids: raise HTTPException(400, f"Asset valuation {v.id} references unknown asset_id {v.asset_id}") + crypto_portfolio_ids = {p.id for p in payload.crypto_portfolios} crypto_holding_asset_ids = {h.asset_id for h in payload.crypto_holdings} for h in payload.crypto_holdings: if h.asset_id not in asset_ids: raise HTTPException(400, f"Crypto holding {h.asset_id} references unknown asset_id {h.asset_id}") + # portfolio_id is allowed to be None (a pre-portfolios backup) — that + # case is resolved to an auto-created fallback portfolio at restore + # time, not validated here. + if h.portfolio_id is not None and h.portfolio_id not in crypto_portfolio_ids: + raise HTTPException( + 400, f"Crypto holding {h.asset_id} references unknown portfolio_id {h.portfolio_id}" + ) for tx in payload.crypto_transactions: if tx.asset_id not in crypto_holding_asset_ids: @@ -182,6 +193,7 @@ async def restore_backup(session: AsyncSession, payload: BackupPayload) -> None: await session.execute(delete(AssetValuation)) await session.execute(delete(CryptoTransaction)) await session.execute(delete(CryptoHolding)) + await session.execute(delete(CryptoPortfolio)) await session.execute(delete(Budget)) await session.execute(delete(GoalContribution)) await session.execute(delete(Goal)) @@ -223,7 +235,30 @@ async def restore_backup(session: AsyncSession, payload: BackupPayload) -> None: session.add_all(TransactionSplit(**row.model_dump()) for row in payload.transaction_splits) session.add_all(AssetValuation(**row.model_dump()) for row in payload.asset_valuations) - session.add_all(CryptoHolding(**row.model_dump()) for row in payload.crypto_holdings) + + session.add_all(CryptoPortfolio(**row.model_dump()) for row in payload.crypto_portfolios) + # A pre-portfolios backup has no crypto_portfolios and every holding's + # portfolio_id is None — give those holdings a freshly created + # fallback portfolio instead of leaving the NOT NULL column unset. + # Wired up via the `portfolio` relationship (not a raw portfolio_id + # int) because the fallback row has no id yet — same "assign the + # relationship, not the id column, before flush" reasoning as + # transactions_by_id/tags_by_id above. + fallback_portfolio: CryptoPortfolio | None = None + if any(row.portfolio_id is None for row in payload.crypto_holdings): + fallback_portfolio = CryptoPortfolio(name="Main Portfolio", color="#2a78d6") + session.add(fallback_portfolio) + + crypto_holdings = [] + for row in payload.crypto_holdings: + holding = CryptoHolding(**row.model_dump(exclude={"portfolio_id"})) + if row.portfolio_id is not None: + holding.portfolio_id = row.portfolio_id + else: + holding.portfolio = fallback_portfolio + crypto_holdings.append(holding) + session.add_all(crypto_holdings) + session.add_all(CryptoTransaction(**row.model_dump()) for row in payload.crypto_transactions) session.add_all(Budget(**row.model_dump()) for row in payload.budgets) session.add_all(Goal(**row.model_dump()) for row in payload.goals) @@ -244,6 +279,10 @@ async def restore_backup(session: AsyncSession, payload: BackupPayload) -> None: await _reset_sequence(session, "transactions", payload.transactions) await _reset_sequence(session, "transaction_splits", payload.transaction_splits) await _reset_sequence(session, "asset_valuations", payload.asset_valuations) + # Only needed for explicit-id portfolios from payload — a fallback + # portfolio (no payload row) already got its id from the sequence + # itself, so the sequence is already correctly positioned for it. + await _reset_sequence(session, "crypto_portfolios", payload.crypto_portfolios) await _reset_sequence(session, "crypto_transactions", payload.crypto_transactions) await _reset_sequence(session, "budgets", payload.budgets) await _reset_sequence(session, "goals", payload.goals) diff --git a/backend/app/services/crypto_service.py b/backend/app/services/crypto_service.py index 84caa37..5be1f44 100644 --- a/backend/app/services/crypto_service.py +++ b/backend/app/services/crypto_service.py @@ -34,13 +34,16 @@ from sqlalchemy.orm import selectinload from app.core.config import get_settings from app.models.asset import Asset, AssetValuation -from app.models.crypto import CryptoHolding, CryptoSyncState, CryptoTransaction +from app.models.crypto import CryptoHolding, CryptoPortfolio, CryptoSyncState, CryptoTransaction from app.models.enums import AssetClass, CapitalRole, CryptoTransactionType, RiskLevel from app.schemas.crypto import ( CryptoHistoryPoint, CryptoHistoryResponse, CryptoHoldingCreate, CryptoHoldingRead, + CryptoPortfolioCreate, + CryptoPortfolioRead, + CryptoPortfolioUpdate, CryptoSearchResult, CryptoSyncResult, CryptoTransactionCreate, @@ -48,6 +51,21 @@ from app.schemas.crypto import ( ) from app.services.settings_service import get_or_create_app_settings +# Same 8-hue, colorblind-safe categorical set app/db/seed.py assigns default +# categories from — reused here (cycling by creation order) so portfolio tab +# dots are visually distinct out of the box, with no manual color picker to +# build for a v1 feature. +PORTFOLIO_PALETTE = [ + "#2a78d6", + "#eb6834", + "#1baf7a", + "#eda100", + "#e87ba4", + "#008300", + "#4a3aa7", + "#e34948", +] + COINGECKO_BASE_URL = "https://api.coingecko.com/api/v3" # CoinGecko's own Demo-tier data only refreshes every 60s server-side # (see docs.coingecko.com/reference/simple-price) — polling more often than @@ -179,6 +197,7 @@ def _to_read(holding: CryptoHolding) -> CryptoHoldingRead: profit_loss_percent = float(profit_loss / cost_basis * 100) if profit_loss is not None and cost_basis else None return CryptoHoldingRead( asset_id=holding.asset_id, + portfolio_id=holding.portfolio_id, coingecko_id=holding.coingecko_id, symbol=holding.symbol, name=holding.name, @@ -196,8 +215,77 @@ def _to_read(holding: CryptoHolding) -> CryptoHoldingRead: ) -async def list_holdings(session: AsyncSession) -> list[CryptoHolding]: - result = await session.execute(select(CryptoHolding).options(*_EAGER).order_by(CryptoHolding.name)) +def _portfolio_to_read(portfolio: CryptoPortfolio) -> CryptoPortfolioRead: + return CryptoPortfolioRead.model_validate(portfolio) + + +async def list_portfolios(session: AsyncSession, include_archived: bool) -> list[CryptoPortfolioRead]: + stmt = select(CryptoPortfolio).order_by(CryptoPortfolio.id) + if not include_archived: + stmt = stmt.where(CryptoPortfolio.is_archived.is_(False)) + portfolios = (await session.execute(stmt)).scalars().all() + return [_portfolio_to_read(p) for p in portfolios] + + +async def get_or_create_default_portfolio(session: AsyncSession) -> CryptoPortfolio: + """The portfolio a new holding lands in when the caller doesn't specify + one (see CryptoHoldingCreate.portfolio_id) — the earliest-created + portfolio, auto-created the first time it's needed. Same self-healing + "create on first use" shape as seed_default_app_settings: a portfolio can + later be deleted once empty (see delete_portfolio), so this can't just + assume row id=1 always exists.""" + existing = ( + await session.execute(select(CryptoPortfolio).order_by(CryptoPortfolio.id).limit(1)) + ).scalar_one_or_none() + if existing is not None: + return existing + portfolio = CryptoPortfolio(name="Main Portfolio", color=PORTFOLIO_PALETTE[0]) + session.add(portfolio) + await session.flush() + return portfolio + + +async def create_portfolio(session: AsyncSession, payload: CryptoPortfolioCreate) -> CryptoPortfolioRead: + count = (await session.execute(select(CryptoPortfolio.id))).scalars().all() + color = PORTFOLIO_PALETTE[len(count) % len(PORTFOLIO_PALETTE)] + portfolio = CryptoPortfolio(name=payload.name, color=color) + session.add(portfolio) + await session.commit() + await session.refresh(portfolio) + return _portfolio_to_read(portfolio) + + +async def update_portfolio( + session: AsyncSession, portfolio_id: int, payload: CryptoPortfolioUpdate +) -> CryptoPortfolioRead: + portfolio = await session.get(CryptoPortfolio, portfolio_id) + if portfolio is None: + raise HTTPException(status_code=404, detail="Crypto portfolio not found") + for field, value in payload.model_dump(exclude_unset=True).items(): + setattr(portfolio, field, value) + await session.commit() + await session.refresh(portfolio) + return _portfolio_to_read(portfolio) + + +async def delete_portfolio(session: AsyncSession, portfolio_id: int) -> None: + portfolio = await session.get(CryptoPortfolio, portfolio_id) + if portfolio is None: + raise HTTPException(status_code=404, detail="Crypto portfolio not found") + has_holding = ( + await session.execute(select(CryptoHolding.asset_id).where(CryptoHolding.portfolio_id == portfolio_id).limit(1)) + ).first() + if has_holding is not None: + raise HTTPException(status_code=400, detail="Move or delete its coins before deleting a portfolio") + await session.delete(portfolio) + await session.commit() + + +async def list_holdings(session: AsyncSession, portfolio_id: int | None = None) -> list[CryptoHolding]: + stmt = select(CryptoHolding).options(*_EAGER).order_by(CryptoHolding.name) + if portfolio_id is not None: + stmt = stmt.where(CryptoHolding.portfolio_id == portfolio_id) + result = await session.execute(stmt) return list(result.scalars().all()) @@ -236,12 +324,17 @@ async def _upsert_valuation(session: AsyncSession, asset_id: int, value: Decimal await session.execute(upsert_stmt) -async def refresh_prices(session: AsyncSession, *, force: bool) -> CryptoSyncResult: +async def refresh_prices(session: AsyncSession, *, force: bool, portfolio_id: int | None = None) -> CryptoSyncResult: + """Sync always covers every holding regardless of `portfolio_id` — a + stale price on a coin the user isn't currently looking at would still be + wrong the next time they switch tabs. `portfolio_id` only narrows what's + returned in the response's `holdings` list, for the Crypto tab's + portfolio filter.""" state = await get_or_create_sync_state(session) now = datetime.now(timezone.utc) if not force and state.last_synced_at is not None and now - state.last_synced_at < AUTO_REFRESH_INTERVAL: - holdings = await list_holdings(session) + holdings = await list_holdings(session, portfolio_id) return CryptoSyncResult(synced=False, last_synced_at=state.last_synced_at, holdings=[_to_read(h) for h in holdings]) holdings = await list_holdings(session) @@ -277,17 +370,26 @@ async def refresh_prices(session: AsyncSession, *, force: bool) -> CryptoSyncRes # holdings were mutated in place above (ordinary ORM attribute # assignment, not a raw Core write) — no need to re-query, they already # reflect what was just committed. + visible = holdings if portfolio_id is None else [h for h in holdings if h.portfolio_id == portfolio_id] return CryptoSyncResult( synced=error_key is None, last_synced_at=state.last_synced_at, error_key=error_key, - holdings=[_to_read(h) for h in holdings], + holdings=[_to_read(h) for h in visible], ) async def create_holding(session: AsyncSession, payload: CryptoHoldingCreate) -> CryptoHoldingRead: settings = await get_or_create_app_settings(session) + if payload.portfolio_id is not None: + portfolio = await session.get(CryptoPortfolio, payload.portfolio_id) + if portfolio is None: + raise HTTPException(status_code=400, detail="Crypto portfolio not found") + portfolio_id = portfolio.id + else: + portfolio_id = (await get_or_create_default_portfolio(session)).id + asset = Asset( name=payload.name, asset_class=AssetClass.CRYPTO, @@ -303,6 +405,7 @@ async def create_holding(session: AsyncSession, payload: CryptoHoldingCreate) -> holding = CryptoHolding( asset_id=asset.id, + portfolio_id=portfolio_id, coingecko_id=payload.coingecko_id, symbol=payload.symbol.upper(), name=payload.name, @@ -440,19 +543,28 @@ async def delete_transaction(session: AsyncSession, transaction_id: int) -> None await session.commit() -async def get_crypto_history(session: AsyncSession, range_key: str) -> CryptoHistoryResponse: +async def get_crypto_history( + session: AsyncSession, range_key: str, portfolio_id: int | None = None +) -> CryptoHistoryResponse: """Total crypto holdings value over time, for the History chart on the Crypto tab. Same "cumulative point events, forward-filled" approach as net_worth_service.py's own asset series — duplicated here rather than imported, so this can't destabilize the already-tested Net Worth engine, and scoped to crypto-class assets only (a deleted holding's AssetValuation rows are gone via cascade, so its history naturally - drops out here too, same as it already does for Net Worth).""" + drops out here too, same as it already does for Net Worth). Further + scoped to one portfolio's assets when `portfolio_id` is given, for the + Crypto tab's portfolio filter.""" today = date_.today() - crypto_asset_ids = set( - (await session.execute(select(Asset.id).where(Asset.asset_class == AssetClass.CRYPTO))).scalars().all() + asset_stmt = ( + select(Asset.id) + .join(CryptoHolding, CryptoHolding.asset_id == Asset.id) + .where(Asset.asset_class == AssetClass.CRYPTO) ) + if portfolio_id is not None: + asset_stmt = asset_stmt.where(CryptoHolding.portfolio_id == portfolio_id) + crypto_asset_ids = set((await session.execute(asset_stmt)).scalars().all()) if not crypto_asset_ids: return CryptoHistoryResponse(range=range_key, current=Decimal("0"), change_amount=Decimal("0"), change_percent=None, series=[]) diff --git a/backend/tests/test_crypto.py b/backend/tests/test_crypto.py index a69eec2..fc5f43d 100644 --- a/backend/tests/test_crypto.py +++ b/backend/tests/test_crypto.py @@ -273,6 +273,59 @@ async def test_backup_roundtrip_preserves_holding_and_transaction_log(client: As assert money(restored["value"]) == money("100000") # last_price survived too (2 * 50000) +async def test_backup_roundtrip_preserves_portfolio_assignment(client: AsyncClient, monkeypatch): + monkeypatch.setattr(crypto_service, "_fetch_market_data", _fake_fetch({"bitcoin": _point("50000")})) + portfolio = (await client.post("/crypto/portfolios", json={"name": "Long-term"})).json() + resp = await client.post( + "/crypto/holdings", + json={ + "portfolio_id": portfolio["id"], + "coingecko_id": "bitcoin", + "symbol": "btc", + "name": "Bitcoin", + "quantity": "1", + "price_per_unit": "40000", + "date": "2026-01-01", + }, + ) + assert resp.status_code == 201, resp.text + + payload = (await client.get("/backup/export")).json() + assert len(payload["crypto_portfolios"]) == 1 + assert payload["crypto_holdings"][0]["portfolio_id"] == portfolio["id"] + + import_resp = await client.post("/backup/import", json=payload) + assert import_resp.status_code == 200, import_resp.text + + restored_portfolios = (await client.get("/crypto/portfolios")).json() + assert len(restored_portfolios) == 1 + assert restored_portfolios[0]["name"] == "Long-term" + restored_holding = (await client.get("/crypto/holdings")).json()["holdings"][0] + assert restored_holding["portfolio_id"] == restored_portfolios[0]["id"] + + +async def test_restoring_a_pre_portfolios_backup_falls_back_to_a_default_portfolio(client: AsyncClient, monkeypatch): + """A backup exported before crypto portfolios existed has no + crypto_portfolios list and every holding's portfolio_id is absent — + restore_backup() must still produce a valid NOT NULL portfolio_id + instead of failing the import.""" + monkeypatch.setattr(crypto_service, "_fetch_market_data", _fake_fetch({"bitcoin": _point("50000")})) + holding = await _add_bitcoin(client, "1", "40000") + + payload = (await client.get("/backup/export")).json() + payload["crypto_portfolios"] = [] + payload["crypto_holdings"][0]["portfolio_id"] = None + + import_resp = await client.post("/backup/import", json=payload) + assert import_resp.status_code == 200, import_resp.text + + restored_portfolios = (await client.get("/crypto/portfolios")).json() + assert len(restored_portfolios) == 1 + restored_holding = (await client.get("/crypto/holdings")).json()["holdings"][0] + assert restored_holding["asset_id"] == holding["asset_id"] + assert restored_holding["portfolio_id"] == restored_portfolios[0]["id"] + + async def test_history_reflects_current_total_crypto_value(client: AsyncClient, monkeypatch): monkeypatch.setattr(crypto_service, "_fetch_market_data", _fake_fetch({"bitcoin": _point("50000")})) await _add_bitcoin(client, "1", "40000") @@ -358,3 +411,105 @@ async def test_update_transaction_404_for_unknown_id(client: AsyncClient): resp = await client.patch("/crypto/transactions/999999", json={"quantity": "1"}) assert resp.status_code == 404 + + +async def test_holding_without_portfolio_id_lands_in_an_auto_created_default(client: AsyncClient, monkeypatch): + monkeypatch.setattr(crypto_service, "_fetch_market_data", _fake_fetch({"bitcoin": _point("50000")})) + holding = await _add_bitcoin(client) + + portfolios = (await client.get("/crypto/portfolios")).json() + assert len(portfolios) == 1 + assert portfolios[0]["name"] == "Main Portfolio" + assert holding["portfolio_id"] == portfolios[0]["id"] + + +async def test_create_portfolio_and_file_a_holding_under_it(client: AsyncClient, monkeypatch): + monkeypatch.setattr(crypto_service, "_fetch_market_data", _fake_fetch({"bitcoin": _point("50000")})) + + created = await client.post("/crypto/portfolios", json={"name": "Long-term"}) + assert created.status_code == 201, created.text + portfolio = created.json() + assert portfolio["name"] == "Long-term" + assert portfolio["is_archived"] is False + + resp = await client.post( + "/crypto/holdings", + json={ + "portfolio_id": portfolio["id"], + "coingecko_id": "bitcoin", + "symbol": "btc", + "name": "Bitcoin", + "quantity": "0.5", + "price_per_unit": "40000", + "date": "2026-01-01", + }, + ) + assert resp.status_code == 201, resp.text + assert resp.json()["portfolio_id"] == portfolio["id"] + + +async def test_same_coin_can_be_tracked_separately_in_two_portfolios(client: AsyncClient, monkeypatch): + monkeypatch.setattr(crypto_service, "_fetch_market_data", _fake_fetch({"bitcoin": _point("50000")})) + memes = (await client.post("/crypto/portfolios", json={"name": "Memes"})).json() + longterm = (await client.post("/crypto/portfolios", json={"name": "Long-term"})).json() + + async def add(portfolio_id, quantity): + resp = await client.post( + "/crypto/holdings", + json={ + "portfolio_id": portfolio_id, + "coingecko_id": "bitcoin", + "symbol": "btc", + "name": "Bitcoin", + "quantity": quantity, + "price_per_unit": "40000", + "date": "2026-01-01", + }, + ) + assert resp.status_code == 201, resp.text + return resp.json() + + await add(memes["id"], "1") + await add(longterm["id"], "2") + + all_holdings = (await client.get("/crypto/holdings")).json()["holdings"] + assert len(all_holdings) == 2 + + memes_holdings = (await client.get(f"/crypto/holdings?portfolio_id={memes['id']}")).json()["holdings"] + assert len(memes_holdings) == 1 + assert money(memes_holdings[0]["quantity"]) == money("1") + + longterm_history = (await client.get(f"/crypto/history?range=30d&portfolio_id={longterm['id']}")).json() + assert money(longterm_history["current"]) == money("100000") # 2 * 50000, memes' 1 BTC excluded + + +async def test_deleting_a_nonempty_portfolio_is_rejected(client: AsyncClient, monkeypatch): + monkeypatch.setattr(crypto_service, "_fetch_market_data", _fake_fetch({"bitcoin": _point("50000")})) + holding = await _add_bitcoin(client) + portfolio_id = holding["portfolio_id"] + + resp = await client.delete(f"/crypto/portfolios/{portfolio_id}") + + assert resp.status_code == 400 + + +async def test_archived_portfolios_are_excluded_by_default(client: AsyncClient): + portfolio = (await client.post("/crypto/portfolios", json={"name": "Old fund"})).json() + + await client.patch(f"/crypto/portfolios/{portfolio['id']}", json={"is_archived": True}) + + visible = (await client.get("/crypto/portfolios")).json() + assert visible == [] + + with_archived = (await client.get("/crypto/portfolios?include_archived=true")).json() + assert len(with_archived) == 1 + assert with_archived[0]["is_archived"] is True + + +async def test_empty_archived_portfolio_can_be_deleted(client: AsyncClient): + portfolio = (await client.post("/crypto/portfolios", json={"name": "Unused"})).json() + + resp = await client.delete(f"/crypto/portfolios/{portfolio['id']}") + + assert resp.status_code == 204 + assert (await client.get("/crypto/portfolios")).json() == [] diff --git a/frontend/package.json b/frontend/package.json index c45bd32..cbd808e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "aurum-frontend", "private": true, - "version": "1.1.1", + "version": "1.1.2", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/crypto.ts b/frontend/src/api/crypto.ts index 082989e..a93a145 100644 --- a/frontend/src/api/crypto.ts +++ b/frontend/src/api/crypto.ts @@ -3,6 +3,8 @@ import type { CryptoHistoryResponse, CryptoHolding, CryptoHoldingCreateInput, + CryptoPortfolio, + CryptoPortfolioInput, CryptoRange, CryptoSearchResult, CryptoSyncResult, @@ -10,14 +12,34 @@ import type { CryptoTransactionInput, } from "@/types"; -// Also triggers the lazy once-a-day auto-refresh server-side — see -// services/crypto_service.py. -export function fetchCryptoHoldings() { - return api.get("/crypto/holdings"); +export function fetchCryptoPortfolios(includeArchived = false) { + return api.get(`/crypto/portfolios?include_archived=${includeArchived}`); } -export function refreshCryptoPrices() { - return api.post("/crypto/refresh", {}); +export function createCryptoPortfolio(input: CryptoPortfolioInput) { + return api.post("/crypto/portfolios", input); +} + +export function updateCryptoPortfolio(portfolioId: number, input: Partial) { + return api.patch(`/crypto/portfolios/${portfolioId}`, input); +} + +export function deleteCryptoPortfolio(portfolioId: number) { + return api.delete(`/crypto/portfolios/${portfolioId}`); +} + +// Also triggers the lazy once-a-day auto-refresh server-side — see +// services/crypto_service.py. `portfolioId` only narrows what comes back in +// `holdings` — the sync itself (and the 24h window) always covers every +// portfolio, see refresh_prices' own docstring. +export function fetchCryptoHoldings(portfolioId?: number | null) { + const query = portfolioId != null ? `?portfolio_id=${portfolioId}` : ""; + return api.get(`/crypto/holdings${query}`); +} + +export function refreshCryptoPrices(portfolioId?: number | null) { + const query = portfolioId != null ? `?portfolio_id=${portfolioId}` : ""; + return api.post(`/crypto/refresh${query}`, {}); } export function createCryptoHolding(input: CryptoHoldingCreateInput) { @@ -52,6 +74,7 @@ export function searchCryptoCoins(query: string) { return api.get(`/crypto/search?q=${encodeURIComponent(query)}`); } -export function fetchCryptoHistory(range: CryptoRange) { - return api.get(`/crypto/history?range=${range}`); +export function fetchCryptoHistory(range: CryptoRange, portfolioId?: number | null) { + const query = portfolioId != null ? `&portfolio_id=${portfolioId}` : ""; + return api.get(`/crypto/history?range=${range}${query}`); } diff --git a/frontend/src/components/crypto/CryptoAddModal.tsx b/frontend/src/components/crypto/CryptoAddModal.tsx index cae75a7..4673fc9 100644 --- a/frontend/src/components/crypto/CryptoAddModal.tsx +++ b/frontend/src/components/crypto/CryptoAddModal.tsx @@ -3,28 +3,34 @@ import { ApiError } from "@/api/client"; import { searchCryptoCoins } from "@/api/crypto"; import { Button } from "@/components/ui/Button"; import { Dialog } from "@/components/ui/Dialog"; -import { Input, Label } from "@/components/ui/Input"; -import { useCreateCryptoHolding } from "@/hooks/useCrypto"; +import { Input, Label, Select } from "@/components/ui/Input"; +import { useCreateCryptoHolding, useCryptoPortfolios } from "@/hooks/useCrypto"; import { useTranslation } from "@/lib/i18n"; import type { CryptoSearchResult } from "@/types"; interface CryptoAddModalProps { open: boolean; onClose: () => void; + // The portfolio tab active on the Crypto page when "+" was clicked — pre- + // selects that portfolio here instead of always defaulting to the first + // one. Null (the "All" tab) falls back to the first portfolio in the list. + defaultPortfolioId?: number | null; } /** Two phases in one dialog: search CoinGecko for a coin, then pick one and * enter how much of it is held. No separate multi-step wizard — unlike CSV * import, there's nothing here worth a dedicated page for. */ -export function CryptoAddModal({ open, onClose }: CryptoAddModalProps) { +export function CryptoAddModal({ open, onClose, defaultPortfolioId }: CryptoAddModalProps) { const { t } = useTranslation(); const createHolding = useCreateCryptoHolding(); + const { data: portfolios } = useCryptoPortfolios(); const [query, setQuery] = useState(""); const [results, setResults] = useState([]); const [searching, setSearching] = useState(false); const [searchError, setSearchError] = useState(null); const [selected, setSelected] = useState(null); + const [portfolioId, setPortfolioId] = useState(null); const [quantity, setQuantity] = useState(""); const [pricePerUnit, setPricePerUnit] = useState(""); const [date, setDate] = useState(() => new Date().toISOString().slice(0, 10)); @@ -42,6 +48,18 @@ export function CryptoAddModal({ open, onClose }: CryptoAddModalProps) { setSaveError(null); }, [open]); + // Re-picked every time the dialog opens or the portfolio list loads — + // covers both "opened from a specific tab" and "opened from All before + // any portfolio existed yet, then the default one got created". + useEffect(() => { + if (!open) return; + if (defaultPortfolioId != null) { + setPortfolioId(defaultPortfolioId); + } else if (portfolios && portfolios.length > 0) { + setPortfolioId((current) => (current !== null && portfolios.some((p) => p.id === current) ? current : portfolios[0].id)); + } + }, [open, defaultPortfolioId, portfolios]); + useEffect(() => { if (!open || selected) return; const trimmed = query.trim(); @@ -73,6 +91,7 @@ export function CryptoAddModal({ open, onClose }: CryptoAddModalProps) { setSaveError(null); try { await createHolding.mutateAsync({ + portfolio_id: portfolioId, coingecko_id: selected.coingecko_id, symbol: selected.symbol, name: selected.name, @@ -145,6 +164,23 @@ export function CryptoAddModal({ open, onClose }: CryptoAddModalProps) { + {portfolios && portfolios.length > 1 && ( +
+ + +
+ )} +
diff --git a/frontend/src/components/crypto/CryptoHoldingsTable.tsx b/frontend/src/components/crypto/CryptoHoldingsTable.tsx index 0e55d13..0c84557 100644 --- a/frontend/src/components/crypto/CryptoHoldingsTable.tsx +++ b/frontend/src/components/crypto/CryptoHoldingsTable.tsx @@ -1,7 +1,7 @@ import { Bitcoin, Plus, Trash2 } from "lucide-react"; import { formatCurrency, maskAmount } from "@/lib/format"; import { useTranslation } from "@/lib/i18n"; -import type { CryptoHolding } from "@/types"; +import type { CryptoHolding, CryptoPortfolio } from "@/types"; interface CryptoHoldingsTableProps { items: CryptoHolding[]; @@ -9,6 +9,11 @@ interface CryptoHoldingsTableProps { onTrade: (holding: CryptoHolding) => void; onViewHistory: (holding: CryptoHolding) => void; onDelete: (holding: CryptoHolding) => void; + // Only passed while viewing the "All" tab — renders each row's portfolio + // as a small colored badge under the coin name so it's still clear which + // portfolio it belongs to. Omitted while a single portfolio is selected, + // since every row would carry the same badge. + portfoliosById?: Map; } function PercentCell({ value }: { value: string | null }) { @@ -23,7 +28,14 @@ function PercentCell({ value }: { value: string | null }) { ); } -export function CryptoHoldingsTable({ items, hidden, onTrade, onViewHistory, onDelete }: CryptoHoldingsTableProps) { +export function CryptoHoldingsTable({ + items, + hidden, + onTrade, + onViewHistory, + onDelete, + portfoliosById, +}: CryptoHoldingsTableProps) { const { t } = useTranslation(); if (items.length === 0) { @@ -75,7 +87,20 @@ export function CryptoHoldingsTable({ items, hidden, onTrade, onViewHistory, onD )} {holding.name} - {holding.symbol} + + {holding.symbol} + {portfoliosById && ( + + + {portfoliosById.get(holding.portfolio_id)?.name} + + )} +
diff --git a/frontend/src/components/crypto/CryptoPortfolioFormModal.tsx b/frontend/src/components/crypto/CryptoPortfolioFormModal.tsx new file mode 100644 index 0000000..31318b4 --- /dev/null +++ b/frontend/src/components/crypto/CryptoPortfolioFormModal.tsx @@ -0,0 +1,124 @@ +import { type FormEvent, useEffect, useState } from "react"; +import { Button } from "@/components/ui/Button"; +import { Dialog } from "@/components/ui/Dialog"; +import { Input, Label } from "@/components/ui/Input"; +import { useCreateCryptoPortfolio, useDeleteCryptoPortfolio, useUpdateCryptoPortfolio } from "@/hooks/useCrypto"; +import { useTranslation } from "@/lib/i18n"; +import type { CryptoPortfolio } from "@/types"; + +interface CryptoPortfolioFormModalProps { + open: boolean; + onClose: () => void; + portfolio?: CryptoPortfolio | null; + // Called after a successful delete — lets CryptoPage fall back to the + // "All" tab when the portfolio it was showing just disappeared. + onDeleted?: () => void; +} + +/** Create/rename a portfolio, plus (when editing) archive and delete — + * folded into one dialog rather than a separate management page, since a + * portfolio is a lightweight grouping with only a name to edit. */ +export function CryptoPortfolioFormModal({ open, onClose, portfolio, onDeleted }: CryptoPortfolioFormModalProps) { + const { t } = useTranslation(); + const createPortfolio = useCreateCryptoPortfolio(); + const updatePortfolio = useUpdateCryptoPortfolio(); + const deletePortfolio = useDeleteCryptoPortfolio(); + + const [name, setName] = useState(""); + const [saveError, setSaveError] = useState(null); + const [deleteError, setDeleteError] = useState(null); + + useEffect(() => { + if (!open) return; + setName(portfolio?.name ?? ""); + setSaveError(null); + setDeleteError(null); + }, [open, portfolio]); + + const isSaving = createPortfolio.isPending || updatePortfolio.isPending; + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + setSaveError(null); + try { + if (portfolio) { + await updatePortfolio.mutateAsync({ id: portfolio.id, input: { name } }); + } else { + await createPortfolio.mutateAsync({ name }); + } + onClose(); + } catch { + setSaveError(t("crypto.portfolio.form.saveError")); + } + } + + function handleToggleArchived() { + if (!portfolio) return; + updatePortfolio.mutate({ id: portfolio.id, input: { is_archived: !portfolio.is_archived } }); + } + + async function handleDelete() { + if (!portfolio) return; + if (!window.confirm(t("crypto.portfolio.confirmDelete", { name: portfolio.name }))) return; + setDeleteError(null); + try { + await deletePortfolio.mutateAsync(portfolio.id); + onDeleted?.(); + onClose(); + } catch { + // 400 when the portfolio still has coins in it — the only realistic + // failure mode here (see services/crypto_service.py's delete_portfolio). + setDeleteError(t("crypto.portfolio.deleteError")); + } + } + + return ( + +
+
+ + setName(event.target.value)} + /> +
+ + {saveError &&

{saveError}

} + +
+ + +
+ + {portfolio && ( +
+ + +
+ )} + {deleteError &&

{deleteError}

} +
+
+ ); +} diff --git a/frontend/src/components/crypto/CryptoPortfolioTabs.tsx b/frontend/src/components/crypto/CryptoPortfolioTabs.tsx new file mode 100644 index 0000000..2ff379f --- /dev/null +++ b/frontend/src/components/crypto/CryptoPortfolioTabs.tsx @@ -0,0 +1,78 @@ +import { Pencil, Plus } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useTranslation } from "@/lib/i18n"; +import type { CryptoPortfolio } from "@/types"; + +interface CryptoPortfolioTabsProps { + portfolios: CryptoPortfolio[]; + activeId: number | null; + onChange: (id: number | null) => void; + onAdd: () => void; + onEdit: (portfolio: CryptoPortfolio) => void; +} + +/** Horizontally-scrolling pill row (no wrap) so a long list of portfolios + * stays usable on a phone screen instead of pushing the rest of the tab + * down. "All" plus one pill per portfolio, a pencil to edit whichever one + * is active, and a dashed "+" pill to create another. */ +export function CryptoPortfolioTabs({ portfolios, activeId, onChange, onAdd, onEdit }: CryptoPortfolioTabsProps) { + const { t } = useTranslation(); + const active = portfolios.find((p) => p.id === activeId) ?? null; + + return ( +
+ + {portfolios.map((portfolio) => { + const isActive = portfolio.id === activeId; + return ( + + ); + })} + {active && ( + + )} + +
+ ); +} diff --git a/frontend/src/hooks/useCrypto.ts b/frontend/src/hooks/useCrypto.ts index 9b18902..e2b1fe3 100644 --- a/frontend/src/hooks/useCrypto.ts +++ b/frontend/src/hooks/useCrypto.ts @@ -2,15 +2,19 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { addCryptoTransaction, createCryptoHolding, + createCryptoPortfolio, deleteCryptoHolding, + deleteCryptoPortfolio, deleteCryptoTransaction, fetchCryptoHistory, fetchCryptoHoldings, + fetchCryptoPortfolios, fetchCryptoTransactions, refreshCryptoPrices, + updateCryptoPortfolio, updateCryptoTransaction, } from "@/api/crypto"; -import type { CryptoHoldingCreateInput, CryptoRange, CryptoTransactionInput } from "@/types"; +import type { CryptoHoldingCreateInput, CryptoPortfolioInput, CryptoRange, CryptoTransactionInput } from "@/types"; function useInvalidateCrypto() { const queryClient = useQueryClient(); @@ -24,12 +28,50 @@ function useInvalidateCrypto() { }; } -export function useCryptoHoldings() { - return useQuery({ queryKey: ["crypto-holdings"], queryFn: fetchCryptoHoldings }); +export function useCryptoPortfolios(includeArchived = false) { + return useQuery({ + queryKey: ["crypto-portfolios", includeArchived], + queryFn: () => fetchCryptoPortfolios(includeArchived), + }); } -export function useCryptoHistory(range: CryptoRange) { - return useQuery({ queryKey: ["crypto-history", range], queryFn: () => fetchCryptoHistory(range) }); +export function useCreateCryptoPortfolio() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (input: CryptoPortfolioInput) => createCryptoPortfolio(input), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["crypto-portfolios"] }), + }); +} + +export function useUpdateCryptoPortfolio() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, input }: { id: number; input: Partial }) => + updateCryptoPortfolio(id, input), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["crypto-portfolios"] }), + }); +} + +export function useDeleteCryptoPortfolio() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: number) => deleteCryptoPortfolio(id), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["crypto-portfolios"] }), + }); +} + +export function useCryptoHoldings(portfolioId?: number | null) { + return useQuery({ + queryKey: ["crypto-holdings", portfolioId ?? null], + queryFn: () => fetchCryptoHoldings(portfolioId), + }); +} + +export function useCryptoHistory(range: CryptoRange, portfolioId?: number | null) { + return useQuery({ + queryKey: ["crypto-history", range, portfolioId ?? null], + queryFn: () => fetchCryptoHistory(range, portfolioId), + }); } export function useCryptoTransactions(assetId: number | null) { @@ -42,7 +84,10 @@ export function useCryptoTransactions(assetId: number | null) { export function useRefreshCryptoPrices() { const invalidate = useInvalidateCrypto(); - return useMutation({ mutationFn: refreshCryptoPrices, onSuccess: invalidate }); + // Always a bare call — sync covers every portfolio regardless of which + // tab is active (see refresh_prices' own docstring), so there's no + // portfolio_id variable for callers to pass here. + return useMutation({ mutationFn: () => refreshCryptoPrices(), onSuccess: invalidate }); } export function useCreateCryptoHolding() { diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index f3abaf6..c84336e 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -162,6 +162,17 @@ const ru = { "crypto.stats.bestPerformer": "Лучший рост", "crypto.stats.worstPerformer": "Худшее падение", "crypto.stats.change24h": "изменение цены за 24ч", + "crypto.portfolio.all": "Все портфели", + "crypto.portfolio.addLabel": "Добавить портфель", + "crypto.portfolio.archiveLabel": "Архивировать портфель", + "crypto.portfolio.unarchiveLabel": "Вернуть из архива", + "crypto.portfolio.confirmDelete": "Удалить портфель «{{name}}»?", + "crypto.portfolio.deleteError": "Сначала переместите или удалите монеты из этого портфеля.", + "crypto.portfolio.form.newTitle": "Новый портфель", + "crypto.portfolio.form.editTitle": "Портфель", + "crypto.portfolio.form.nameLabel": "Название портфеля", + "crypto.portfolio.form.namePlaceholder": "Например, «Долгосрочные инвестиции»", + "crypto.portfolio.form.saveError": "Не удалось сохранить. Проверьте данные и попробуйте снова.", "roi.calculator.title": "Калькулятор ROI", "roi.calculator.investmentLabel": "Сумма инвестиции", @@ -645,6 +656,17 @@ const en: Record = { "crypto.stats.bestPerformer": "Best Performer", "crypto.stats.worstPerformer": "Worst Performer", "crypto.stats.change24h": "24h price change", + "crypto.portfolio.all": "All portfolios", + "crypto.portfolio.addLabel": "Add portfolio", + "crypto.portfolio.archiveLabel": "Archive portfolio", + "crypto.portfolio.unarchiveLabel": "Unarchive", + "crypto.portfolio.confirmDelete": "Delete portfolio «{{name}}»?", + "crypto.portfolio.deleteError": "Move or delete its coins before deleting a portfolio.", + "crypto.portfolio.form.newTitle": "New portfolio", + "crypto.portfolio.form.editTitle": "Portfolio", + "crypto.portfolio.form.nameLabel": "Portfolio name", + "crypto.portfolio.form.namePlaceholder": "e.g. \"Long-term investments\"", + "crypto.portfolio.form.saveError": "Couldn't save. Check the details and try again.", "roi.calculator.title": "ROI Calculator", "roi.calculator.investmentLabel": "Investment amount", diff --git a/frontend/src/pages/CryptoPage.tsx b/frontend/src/pages/CryptoPage.tsx index 75c1d1d..4d613ec 100644 --- a/frontend/src/pages/CryptoPage.tsx +++ b/frontend/src/pages/CryptoPage.tsx @@ -1,17 +1,25 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { Plus, RefreshCw } from "lucide-react"; import { Button } from "@/components/ui/Button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; import { CryptoAddModal } from "@/components/crypto/CryptoAddModal"; import { CryptoHoldingsTable } from "@/components/crypto/CryptoHoldingsTable"; import { CryptoOverviewCard } from "@/components/crypto/CryptoOverviewCard"; +import { CryptoPortfolioFormModal } from "@/components/crypto/CryptoPortfolioFormModal"; +import { CryptoPortfolioTabs } from "@/components/crypto/CryptoPortfolioTabs"; import { CryptoStatsRow } from "@/components/crypto/CryptoStatsRow"; import { CryptoTransactionHistoryModal } from "@/components/crypto/CryptoTransactionHistoryModal"; import { CryptoTransactionModal } from "@/components/crypto/CryptoTransactionModal"; -import { useCryptoHistory, useCryptoHoldings, useDeleteCryptoHolding, useRefreshCryptoPrices } from "@/hooks/useCrypto"; +import { + useCryptoHistory, + useCryptoHoldings, + useCryptoPortfolios, + useDeleteCryptoHolding, + useRefreshCryptoPrices, +} from "@/hooks/useCrypto"; import { getIntlLocale } from "@/lib/format"; import { useTranslation } from "@/lib/i18n"; -import type { CryptoHolding, CryptoRange, CryptoTransaction } from "@/types"; +import type { CryptoHolding, CryptoPortfolio, CryptoRange, CryptoTransaction } from "@/types"; function formatSyncedAt(iso: string): string { return new Intl.DateTimeFormat(getIntlLocale(), { dateStyle: "medium", timeStyle: "short" }).format(new Date(iso)); @@ -25,12 +33,17 @@ export function CryptoPage() { // history) masks together instead of the toggle only affecting whichever // component happened to own it. const [hidden, setHidden] = useState(false); - const { data, isLoading } = useCryptoHoldings(); - const { data: history, isLoading: isHistoryLoading } = useCryptoHistory(range); + // null = "All portfolios" tab. + const [portfolioFilter, setPortfolioFilter] = useState(null); + const { data: portfolios } = useCryptoPortfolios(); + const { data, isLoading } = useCryptoHoldings(portfolioFilter); + const { data: history, isLoading: isHistoryLoading } = useCryptoHistory(range, portfolioFilter); const refresh = useRefreshCryptoPrices(); const deleteHolding = useDeleteCryptoHolding(); const [addOpen, setAddOpen] = useState(false); + const [editingPortfolio, setEditingPortfolio] = useState(null); + const [portfolioModalOpen, setPortfolioModalOpen] = useState(false); const [tradingHolding, setTradingHolding] = useState(null); const [historyHolding, setHistoryHolding] = useState(null); // Set together with tradingHolding when editing an entry from the history @@ -41,6 +54,17 @@ export function CryptoPage() { const holdings = data?.holdings ?? []; const totalValue = holdings.reduce((sum, holding) => sum + (holding.value !== null ? Number(holding.value) : 0), 0); + const portfoliosById = useMemo(() => new Map((portfolios ?? []).map((p) => [p.id, p])), [portfolios]); + + function openAddPortfolioModal() { + setEditingPortfolio(null); + setPortfolioModalOpen(true); + } + + function openEditPortfolioModal(portfolio: CryptoPortfolio) { + setEditingPortfolio(portfolio); + setPortfolioModalOpen(true); + } function handleDelete(holding: CryptoHolding) { if (window.confirm(t("crypto.confirmDelete", { name: holding.name }))) { @@ -66,6 +90,14 @@ export function CryptoPage() { return (
+ + )} - setAddOpen(false)} /> + setAddOpen(false)} defaultPortfolioId={portfolioFilter} /> + setPortfolioModalOpen(false)} + portfolio={editingPortfolio} + onDeleted={() => { + if (editingPortfolio && portfolioFilter === editingPortfolio.id) setPortfolioFilter(null); + }} + />