Skip to content

[feat] #50,53,54 전략 카드 DnD, 대시보드 DnD, accounts 리다이렉트, 모바일 카드뷰 기본값#91

Closed
fray-cloud wants to merge 0 commit intodevfrom
feat/#50
Closed

[feat] #50,53,54 전략 카드 DnD, 대시보드 DnD, accounts 리다이렉트, 모바일 카드뷰 기본값#91
fray-cloud wants to merge 0 commit intodevfrom
feat/#50

Conversation

@fray-cloud
Copy link
Copy Markdown
Owner

@fray-cloud fray-cloud commented Apr 2, 2026

변경 사항

  • feat(web): 모바일 카드뷰 기본값 — Markets/Portfolio 페이지 (PRO-54 연동)
  • feat(web): /accounts/settings?tab=accounts 리다이렉트 (PRO-54)
  • feat(web): 전략 카드 DnD 순서 변경 구현 (PRO-50)
  • feat(web): 대시보드 위젯 DnD 레이아웃 구현 (PRO-53)
  • fix(web): Sourcery 리뷰 반영 — useMemosortedStrategies 최적화, localStorage SSR guard (PRO-50)

관련 이슈

Closes #85
(covers PRO-50, PRO-53, PRO-54)

테스트 방법

  1. /accounts 접근 → /settings?tab=accounts로 리다이렉트 확인
  2. 전략 목록 → 카드 드래그 앤 드롭 순서 변경 후 새로고침 시 유지 확인
  3. 대시보드 → 위젯 DnD 레이아웃 조정 후 레이아웃 저장 확인
  4. 모바일 브레이크포인트 → Markets/Portfolio 카드뷰 기본 노출 확인

스크린샷 (UI 변경 시)

전략 카드 DnD, 대시보드 위젯 DnD 포함.


참고: 이 PR은 PRO-103 브랜치 정리 작업의 일환으로 제출됩니다.
feat/#53, feat/#54의 변경사항은 모두 이 브랜치(feat/#50)에 포함되어 있습니다.

Summary by Sourcery

Introduce drag-and-drop reordering for strategies and a customizable dashboard layout, update navigation and mobile defaults, and add an accounts settings redirect.

New Features:

  • Add drag-and-drop reordering for strategy cards with persisted order via backend API.
  • Add a new dashboard page with draggable widgets for portfolio, strategies, orders, and markets, with layout persisted in local storage.
  • Introduce mobile-first card views for markets and portfolio assets, including swipe-to-order ticker cards.
  • Expose dashboard navigation entries in both the main navbar and mobile tab bar.

Bug Fixes:

  • Guard dashboard widget-order localStorage access for SSR compatibility and optimize strategy sorting with memoization.

Enhancements:

  • Change the /accounts route to redirect to the accounts tab within the settings page, consolidating account management in one place.

Build:

  • Add @dnd-kit core, sortable, and utilities packages for drag-and-drop interactions.

@sourcery-ai
Copy link
Copy Markdown
Contributor

sourcery-ai Bot commented Apr 2, 2026

Reviewer's Guide

Implements drag-and-drop reordering for strategy cards and a customizable dashboard, introduces mobile-first card views for Markets and Portfolio, adds a dashboard entry point to navigation, and replaces the /accounts page with a redirect to /settings while wiring necessary API and dependency changes.

Sequence diagram for strategy card drag-and-drop reorder

sequenceDiagram
    actor User
    participant StrategiesPage
    participant DndContext
    participant SortableStrategyCard
    participant ApiClient
    participant Backend

    User->>SortableStrategyCard: drag strategy card
    SortableStrategyCard->>DndContext: emit drag events
    DndContext->>StrategiesPage: onDragEnd(event)

    activate StrategiesPage
    StrategiesPage->>StrategiesPage: handleDragEnd(event)
    StrategiesPage->>StrategiesPage: compute oldIndex,newIndex
    StrategiesPage->>StrategiesPage: newOrder = arrayMove(sortedStrategies)
    StrategiesPage->>StrategiesPage: setLocalOrder(newOrder ids)
    StrategiesPage->>ApiClient: reorderStrategies(orders)
    deactivate StrategiesPage

    activate ApiClient
    ApiClient->>Backend: PATCH /strategies/reorder { orders }
    Backend-->>ApiClient: 200 OK
    deactivate ApiClient

    activate StrategiesPage
    ApiClient-->>StrategiesPage: onSuccess callback
    StrategiesPage->>StrategiesPage: invalidateQueries(strategies)
    StrategiesPage->>Backend: refetch strategies
    Backend-->>StrategiesPage: strategies with updated order
    StrategiesPage->>StrategiesPage: sortedStrategies recomputed
    deactivate StrategiesPage
Loading

Sequence diagram for dashboard widget drag-and-drop and persistence

sequenceDiagram
    actor User
    participant DashboardPage
    participant DndContext
    participant SortableWidget
    participant LocalStorage

    User->>SortableWidget: drag widget handle
    SortableWidget->>DndContext: drag events
    DndContext->>DashboardPage: onDragEnd(event)

    activate DashboardPage
    DashboardPage->>DashboardPage: handleDragEnd(event)
    DashboardPage->>DashboardPage: arrayMove(widgetOrder)
    DashboardPage->>DashboardPage: setWidgetOrder(newOrder)
    DashboardPage->>LocalStorage: setItem(STORAGE_KEY,newOrder)
    deactivate DashboardPage

    rect rgb(240,240,240)
        Note over DashboardPage,LocalStorage: Next page load
    end

    DashboardPage->>LocalStorage: getItem(STORAGE_KEY)
    LocalStorage-->>DashboardPage: serialized order
    DashboardPage->>DashboardPage: loadWidgetOrder() with fallback
Loading

Class diagram for strategy drag-and-drop ordering

classDiagram
    class StrategyItem {
      +string id
      +string name
      +string symbol
      +boolean enabled
      +number intervalSeconds
      +string candleInterval
      +number order
      +string createdAt
      +string updatedAt
    }

    class ApiClient {
      +Promise~StrategyItem[]~ getStrategies()
      +Promise~void~ toggleStrategy(string id)
      +Promise~void~ deleteStrategy(string id)
      +Promise~void~ reorderStrategies(OrderUpdate[] orders)
    }

    class OrderUpdate {
      +string id
      +number order
    }

    class StrategiesPage {
      -StrategyItem[] strategies
      -string[] localOrder
      -StrategyItem[] sortedStrategies
      +void handleDragEnd(DragEndEvent event)
      +void setLocalOrder(string[] nextOrder)
    }

    class SortableStrategyCard {
      +StrategyItem strategy
      +void onToggle()
      +void onDelete()
    }

    class DndContext {
      +void onDragEnd(DragEndEvent event)
    }

    class SortableContext {
      +string[] items
    }

    class DragEndEvent {
      +string activeId
      +string overId
    }

    StrategiesPage --> StrategyItem : uses
    StrategiesPage --> ApiClient : calls
    StrategiesPage --> DndContext : configures
    DndContext --> StrategiesPage : onDragEnd callback
    StrategiesPage --> SortableContext : wraps_list
    SortableContext --> SortableStrategyCard : provides_sorting
    SortableStrategyCard --> StrategyItem : renders
    ApiClient --> OrderUpdate : uses
Loading

Class diagram for dashboard widgets and mobile card views

classDiagram
    class DashboardPage {
      -WidgetId[] widgetOrder
      +void handleDragEnd(DragEndEvent event)
      +WidgetId[] loadWidgetOrder()
      +void saveWidgetOrder(WidgetId[] order)
    }

    class SortableWidget {
      +WidgetId id
    }

    class PortfolioWidget {
      +void render()
    }

    class StrategiesWidget {
      +void render()
    }

    class OrdersWidget {
      +void render()
    }

    class MarketsWidget {
      +void render()
    }

    class WidgetId {
      <<enumeration>>
      portfolio
      strategies
      orders
      markets
    }

    class TickerCardList {
      +Ticker[] tickers
      +void setFilter(string value)
    }

    class SwipableTickerCard {
      +Ticker ticker
      +void onQuickOrder(Ticker ticker)
    }

    class AssetCardList {
      +PortfolioAsset[] assets
      +void setSearchText(string value)
    }

    class AssetCard {
      +PortfolioAsset asset
    }

    class Ticker {
      +string exchange
      +string symbol
      +string price
      +string changePercent24h
      +string high24h
      +string low24h
    }

    class PortfolioAsset {
      +string exchange
      +string currency
      +number quantity
      +number valueKrw
      +number avgCost
      +number currentPrice
      +number pnl
    }

    class QuickOrderPanel {
      +Ticker ticker
      +void onClose()
    }

    DashboardPage --> SortableWidget : renders
    DashboardPage --> WidgetId : manages_order
    SortableWidget --> WidgetId : identifies
    SortableWidget --> PortfolioWidget : may_render
    SortableWidget --> StrategiesWidget : may_render
    SortableWidget --> OrdersWidget : may_render
    SortableWidget --> MarketsWidget : may_render

    TickerCardList --> SwipableTickerCard : renders_list
    TickerCardList --> Ticker : uses
    SwipableTickerCard --> Ticker : displays
    TickerCardList --> QuickOrderPanel : controls

    AssetCardList --> AssetCard : renders_list
    AssetCardList --> PortfolioAsset : filters
    AssetCard --> PortfolioAsset : displays
Loading

File-Level Changes

Change Details Files
Replace /accounts management UI with a redirect to the new accounts settings tab.
  • Remove client-side accounts management UI and react-query mutations from the accounts page.
  • Use next/navigation redirect in the accounts page component to immediately navigate to /settings?tab=accounts.
apps/web/src/app/accounts/page.tsx
Add drag-and-drop reordering for strategies using dnd-kit and persist order to the backend.
  • Introduce a SortableStrategyCard wrapper that wires dnd-kit sortable hooks into StrategyCard rendering with a drag handle.
  • Track a localOrder state and derive sortedStrategies with useMemo, falling back to server-provided order when no local override exists.
  • Handle drag end events to reorder the local list with arrayMove and call a new reorderStrategies API mutation, resetting localOrder on create/delete success.
  • Register sensors (pointer/keyboard) and wrap the strategies list in DndContext and SortableContext for vertical list sorting.
apps/web/src/app/strategies/page.tsx
apps/web/src/lib/api-client.ts
apps/web/package.json
pnpm-lock.yaml
Introduce a configurable, draggable dashboard composed of portfolio, strategies, orders, and markets widgets with client-side persisted layout.
  • Create a DashboardPage client component that renders widgets in a CSS grid and wraps them in DndContext/SortableContext with rectSortingStrategy.
  • Implement load/save helpers that guard against SSR and store widget layout in localStorage using a fixed DEFAULT_WIDGET_ORDER.
  • Define individual widget components (PortfolioWidget, StrategiesWidget, OrdersWidget, MarketsWidget) that consume existing hooks and present condensed summaries with links to detail pages.
  • Implement SortableWidget, which applies dnd-kit sortable behavior and a drag handle per widget.
apps/web/src/app/dashboard/page.tsx
apps/web/package.json
pnpm-lock.yaml
Enhance navigation to expose the new dashboard and extend the mobile tab bar ‘More’ menu.
  • Add a Dashboard link with icon to the main NavBar for authenticated users.
  • Extend MORE_ITEMS in the mobile tab bar to include a Dashboard entry and support items with either static labels or translation keys, adjusting rendering accordingly.
apps/web/src/components/nav-bar.tsx
apps/web/src/components/mobile-tab-bar.tsx
Provide mobile-optimized card views for Markets and Portfolio while retaining existing desktop table views.
  • In MarketsPage, render a new TickerCardList for mobile (md:hidden) and keep the TickerTable+QuickOrderPanel combination behind a desktop-only wrapper.
  • In PortfolioPage, render an AssetCardList-based mobile card section with empty-state handling and keep the existing table inside a desktop-only Card.
  • Implement TickerCardList as a searchable, swipeable mobile ticker list that integrates QuickOrderPanel and uses exchange/base-currency context for price formatting.
  • Implement AssetCardList as a searchable mobile asset list using card layout, with per-asset stats and P&L via PnlValue.
apps/web/src/app/markets/page.tsx
apps/web/src/app/portfolio/page.tsx
apps/web/src/components/markets/ticker-card-list.tsx
apps/web/src/components/portfolio/asset-card-list.tsx

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues, and left some high level feedback:

  • Several user-facing strings are hard-coded in the new components (e.g., dashboard link label, dashboard widget titles, TickerCardList messages like Order, ← Swipe left for quick order, and the no-results text), so it would be better to move these into next-intl translations for consistency with the rest of the app.
  • The DnD implementations for strategy cards and dashboard widgets share very similar patterns (sensors setup, useSortable, drag handle UI, arrayMove logic); consider extracting a small reusable sortable wrapper/hook to reduce duplication and make future DnD behavior changes easier to apply across both areas.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- Several user-facing strings are hard-coded in the new components (e.g., dashboard link label, dashboard widget titles, TickerCardList messages like `Order`, `← Swipe left for quick order`, and the no-results text), so it would be better to move these into next-intl translations for consistency with the rest of the app.
- The DnD implementations for strategy cards and dashboard widgets share very similar patterns (sensors setup, useSortable, drag handle UI, arrayMove logic); consider extracting a small reusable sortable wrapper/hook to reduce duplication and make future DnD behavior changes easier to apply across both areas.

## Individual Comments

### Comment 1
<location path="apps/web/src/app/strategies/page.tsx" line_range="102-106" />
<code_context>
+    onSuccess: () => queryClient.invalidateQueries({ queryKey: ['strategies'] }),
+  });
+
+  const sortedStrategies = useMemo(() => {
+    if (!localOrder) return [...strategies].sort((a, b) => a.order - b.order);
+    return localOrder
+      .map((id) => strategies.find((s) => s.id === id))
+      .filter(Boolean) as StrategyItem[];
+  }, [strategies, localOrder]);
+
</code_context>
<issue_to_address>
**issue (bug_risk):** New strategies coming from the server can be dropped when a local drag order is active.

Because `sortedStrategies` only includes IDs from `localOrder` once it’s set, any new items from `strategies` that weren’t in the drag at the time will never show up, and removed items can linger as long as their IDs remain in `localOrder`.

Consider either (1) always including strategies whose IDs aren’t in `localOrder` (e.g. append them using backend `order`), and/or (2) reconciling `localOrder` whenever `strategies` changes (e.g. filter out missing IDs and append new ones in a `useEffect`) so the UI stays aligned with server state while preserving drag order.
</issue_to_address>

### Comment 2
<location path="apps/web/src/components/markets/ticker-card-list.tsx" line_range="56-59" />
<code_context>
+
+const SWIPE_THRESHOLD = 60;
+
+function SwipableTickerCard({
+  ticker,
+  onQuickOrder,
+}: {
+  ticker: Ticker;
+  onQuickOrder: (ticker: Ticker) => void;
</code_context>
<issue_to_address>
**suggestion (performance):** Per-card hooks for exchange rate and base currency can be lifted to reduce redundant work.

`SwipableTickerCard` calls `useExchangeRate` and `useBaseCurrency` for every card, even though these values are global. On large lists this adds unnecessary hook work and re-renders.

Consider moving these hooks to `TickerCardList` and passing `krwPerUsd` and `baseCurrency` as props to each card to avoid repeated calls and improve list performance.

Suggested implementation:

```typescript
const SWIPE_THRESHOLD = 60;

type SwipableTickerCardProps = {
  ticker: Ticker;
  onQuickOrder: (ticker: Ticker) => void;
  krwPerUsd: number | null;
  baseCurrency: CurrencyCode; // adjust to the existing currency type if different
};

function SwipableTickerCard({
  ticker,
  onQuickOrder,
  krwPerUsd,
  baseCurrency,
}: SwipableTickerCardProps) {
  const touchStartX = useRef<number | null>(null);

```

```typescript
function TickerCardList({
  tickers,
  onQuickOrder,
}: {
  tickers: Ticker[];
  onQuickOrder: (ticker: Ticker) => void;
}) {
  const { krwPerUsd } = useExchangeRate();
  const { currency: baseCurrency } = useBaseCurrency();

```

```typescript
      {tickers.map((ticker) => (
        <SwipableTickerCard
          key={ticker.symbol}
          ticker={ticker}
          onQuickOrder={onQuickOrder}
          krwPerUsd={krwPerUsd}
          baseCurrency={baseCurrency}
        />
      ))}

```

1. Ensure `CurrencyCode` (used in `SwipableTickerCardProps`) is an existing type in this file or imported from your shared types. If the codebase uses a different type (e.g. `Currency`, `BaseCurrency`, or just `string`), update the `baseCurrency` type accordingly.
2. If `TickerCardList` currently has a different props shape or additional props (e.g. filters, layout options), merge the added hooks into the existing function body without removing other logic.
3. If multiple list components render `SwipableTickerCard` from this file, each must now pass `krwPerUsd` and `baseCurrency` (or delegate to a shared list component that does).
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +102 to +106
const sortedStrategies = useMemo(() => {
if (!localOrder) return [...strategies].sort((a, b) => a.order - b.order);
return localOrder
.map((id) => strategies.find((s) => s.id === id))
.filter(Boolean) as StrategyItem[];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): New strategies coming from the server can be dropped when a local drag order is active.

Because sortedStrategies only includes IDs from localOrder once it’s set, any new items from strategies that weren’t in the drag at the time will never show up, and removed items can linger as long as their IDs remain in localOrder.

Consider either (1) always including strategies whose IDs aren’t in localOrder (e.g. append them using backend order), and/or (2) reconciling localOrder whenever strategies changes (e.g. filter out missing IDs and append new ones in a useEffect) so the UI stays aligned with server state while preserving drag order.

Comment on lines +56 to +59
function SwipableTickerCard({
ticker,
onQuickOrder,
}: {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (performance): Per-card hooks for exchange rate and base currency can be lifted to reduce redundant work.

SwipableTickerCard calls useExchangeRate and useBaseCurrency for every card, even though these values are global. On large lists this adds unnecessary hook work and re-renders.

Consider moving these hooks to TickerCardList and passing krwPerUsd and baseCurrency as props to each card to avoid repeated calls and improve list performance.

Suggested implementation:

const SWIPE_THRESHOLD = 60;

type SwipableTickerCardProps = {
  ticker: Ticker;
  onQuickOrder: (ticker: Ticker) => void;
  krwPerUsd: number | null;
  baseCurrency: CurrencyCode; // adjust to the existing currency type if different
};

function SwipableTickerCard({
  ticker,
  onQuickOrder,
  krwPerUsd,
  baseCurrency,
}: SwipableTickerCardProps) {
  const touchStartX = useRef<number | null>(null);
function TickerCardList({
  tickers,
  onQuickOrder,
}: {
  tickers: Ticker[];
  onQuickOrder: (ticker: Ticker) => void;
}) {
  const { krwPerUsd } = useExchangeRate();
  const { currency: baseCurrency } = useBaseCurrency();
      {tickers.map((ticker) => (
        <SwipableTickerCard
          key={ticker.symbol}
          ticker={ticker}
          onQuickOrder={onQuickOrder}
          krwPerUsd={krwPerUsd}
          baseCurrency={baseCurrency}
        />
      ))}
  1. Ensure CurrencyCode (used in SwipableTickerCardProps) is an existing type in this file or imported from your shared types. If the codebase uses a different type (e.g. Currency, BaseCurrency, or just string), update the baseCurrency type accordingly.
  2. If TickerCardList currently has a different props shape or additional props (e.g. filters, layout options), merge the added hooks into the existing function body without removing other logic.
  3. If multiple list components render SwipableTickerCard from this file, each must now pass krwPerUsd and baseCurrency (or delegate to a shared list component that does).

@fray-cloud
Copy link
Copy Markdown
Owner Author

모든 커밋이 이미 dev에 반영되어 있음 (squash merge로 포함됨). 브랜치를 삭제하고 PR을 닫습니다.

@fray-cloud fray-cloud deleted the feat/#50 branch April 2, 2026 01:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant