Compare commits

...

2 Commits

Author SHA1 Message Date
2af2ff32c1 feat: finalize table-migration — fix DataTable types, remove legacy helpers, update docs
Some checks failed
CI / ci (pull_request) Failing after 12m13s
CI / ci (push) Failing after 11m35s
2026-06-24 15:24:56 +03:00
2ed356fbcd docs: document MCP tools setup (code-index-mcp, serena, graphify)
- Add code-index-mcp and serena sections to AGENTS.md
- Update AGENTS.md MCP tools description in obligatory approach
- Add graphify hooks (post-checkout, post-commit) for auto graph rebuild
- Add serena project config
- Add graphify-out knowledge graph artifacts
- Ignore dev.db and graphify-out/cache/ in .gitignore
2026-06-24 13:32:10 +03:00
32 changed files with 109163 additions and 665 deletions

3
.gitignore vendored
View File

@ -12,3 +12,6 @@ apps/docs/.docusaurus/
apps/docs/build/
.idea
.playwright-mcp
.opencode
dev.db
graphify-out/cache/

139
.husky/post-checkout Executable file
View File

@ -0,0 +1,139 @@
#!/bin/sh
# graphify-checkout-hook-start
# Auto-rebuilds the knowledge graph (code only) when switching branches.
# Installed by: graphify hook install
# Deterministic clustering: networkx louvain iterates string-keyed sets whose
# order is randomized per-process by PYTHONHASHSEED, so community assignments
# churn run-to-run. Pinning it makes graphify-out reproducible.
export PYTHONHASHSEED=0
PREV_HEAD=$1
NEW_HEAD=$2
BRANCH_SWITCH=$3
# Only run on branch switches, not file checkouts
if [ "$BRANCH_SWITCH" != "1" ]; then
exit 0
fi
# Only run if graphify-out/ exists (graph has been built before)
if [ ! -d "graphify-out" ]; then
exit 0
fi
# Skip during rebase/merge/cherry-pick
GIT_DIR=$(git rev-parse --git-dir 2>/dev/null)
[ -d "$GIT_DIR/rebase-merge" ] && exit 0
[ -d "$GIT_DIR/rebase-apply" ] && exit 0
[ -f "$GIT_DIR/MERGE_HEAD" ] && exit 0
[ -f "$GIT_DIR/CHERRY_PICK_HEAD" ] && exit 0
# Detect the correct Python interpreter (handles uv tool, pipx, venv, system installs).
# _PINNED was recorded at hook-install time; tried first so the hook works even
# when the graphify launcher is not on PATH (common in GUI clients and CI).
GRAPHIFY_PYTHON=""
_PINNED='/Users/ksv741/.local/share/uv/tools/graphifyy/bin/python'
if [ -n "$_PINNED" ] && [ -x "$_PINNED" ] && "$_PINNED" -c "import graphify" 2>/dev/null; then
GRAPHIFY_PYTHON="$_PINNED"
fi
# Second probe: read graphify-out/.graphify_python (written by the skill and
# CLI; survives uv-tool reinstalls and is the same source the README documents).
if [ -z "$GRAPHIFY_PYTHON" ]; then
_GFY_PYTHON_FILE="graphify-out/.graphify_python"
if [ -f "$_GFY_PYTHON_FILE" ]; then
_FROM_FILE=$(cat "$_GFY_PYTHON_FILE" 2>/dev/null | tr -d '[:space:]')
case "$_FROM_FILE" in
*[!a-zA-Z0-9/_.@:\-]*) _FROM_FILE="" ;; # allowlist (covers Windows paths)
esac
if [ -n "$_FROM_FILE" ] && [ -x "$_FROM_FILE" ] && "$_FROM_FILE" -c "import graphify" 2>/dev/null; then
GRAPHIFY_PYTHON="$_FROM_FILE"
fi
fi
fi
# Third probe: resolve via the graphify launcher on PATH (shebang probe).
if [ -z "$GRAPHIFY_PYTHON" ]; then
GRAPHIFY_BIN=$(command -v graphify 2>/dev/null)
if [ -n "$GRAPHIFY_BIN" ]; then
case "$GRAPHIFY_BIN" in
*.exe) _SHEBANG="" ;;
*) _SHEBANG=$(head -1 "$GRAPHIFY_BIN" | sed 's/^#![[:space:]]*//') ;;
esac
case "$_SHEBANG" in
*/env\ *) GRAPHIFY_PYTHON="${_SHEBANG#*/env }" ;;
*) GRAPHIFY_PYTHON="$_SHEBANG" ;;
esac
# Allowlist: only keep characters valid in a filesystem path to prevent
# injection if the shebang contains shell metacharacters.
case "$GRAPHIFY_PYTHON" in
*[!a-zA-Z0-9/_.@-]*) GRAPHIFY_PYTHON="" ;;
esac
if [ -n "$GRAPHIFY_PYTHON" ] && ! "$GRAPHIFY_PYTHON" -c "import graphify" 2>/dev/null; then
GRAPHIFY_PYTHON=""
fi
fi
fi
# Last resort: try python3 / python (works for system/venv installs on PATH).
if [ -z "$GRAPHIFY_PYTHON" ]; then
if command -v python3 >/dev/null 2>&1 && python3 -c "import graphify" 2>/dev/null; then
GRAPHIFY_PYTHON="python3"
elif command -v python >/dev/null 2>&1 && python -c "import graphify" 2>/dev/null; then
GRAPHIFY_PYTHON="python"
else
echo "[graphify hook] could not locate a Python with graphify installed. Add the graphify bin dir to PATH or re-run 'graphify hook install' from the env where graphify lives." >&2
exit 0
fi
fi
_GRAPHIFY_LOG="${HOME}/.cache/graphify-rebuild.log"
mkdir -p "$(dirname "$_GRAPHIFY_LOG")"
export GRAPHIFY_REBUILD_LOG="$_GRAPHIFY_LOG"
echo "[graphify] Branch switched - launching background rebuild (log: $_GRAPHIFY_LOG)"
"$GRAPHIFY_PYTHON" -c "import os, subprocess, sys
_src = '''
from graphify.watch import _rebuild_code, _apply_resource_limits
from pathlib import Path
import os, signal, sys
try:
_apply_resource_limits()
_timeout = int(os.environ.get('GRAPHIFY_REBUILD_TIMEOUT', '600'))
if _timeout > 0 and hasattr(signal, 'SIGALRM'):
signal.signal(signal.SIGALRM, lambda *_: (_ for _ in ()).throw(TimeoutError(f'graphify rebuild exceeded {_timeout}s')))
signal.alarm(_timeout)
_force = os.environ.get('GRAPHIFY_FORCE', '').lower() in ('1', 'true', 'yes')
# post-checkout: branch switch can touch arbitrary files; full rebuild path
# (no changed_paths) is correct here. The flock inside _rebuild_code still
# prevents pile-ups when commit + checkout fire back-to-back.
_root = Path('.')
_saved = Path('graphify-out/.graphify_root')
if _saved.exists():
_txt = _saved.read_text(encoding='utf-8').strip()
if _txt:
_root = Path(_txt)
_rebuild_code(_root, force=_force)
except TimeoutError as exc:
print(f'[graphify] {exc}')
sys.exit(1)
except Exception as exc:
print(f'[graphify] Rebuild failed: {exc}')
sys.exit(1)
'''
_log = os.environ.get('GRAPHIFY_REBUILD_LOG') or os.path.join(os.path.expanduser('~'), '.cache', 'graphify-rebuild.log')
try:
os.makedirs(os.path.dirname(_log), exist_ok=True)
_out = open(_log, 'a', buffering=1, encoding='utf-8', errors='replace')
except OSError:
_out = subprocess.DEVNULL
_kw = dict(stdout=_out, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, cwd=os.getcwd(), close_fds=True)
_cmd = [sys.executable, '-c', _src]
if os.name == 'nt':
_flags = 0x00000008 | 0x00000200 # DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP
try:
subprocess.Popen(_cmd, creationflags=_flags | 0x01000000, **_kw) # + CREATE_BREAKAWAY_FROM_JOB
except OSError:
subprocess.Popen(_cmd, creationflags=_flags, **_kw)
else:
subprocess.Popen(_cmd, start_new_session=True, **_kw)
"
# graphify-checkout-hook-end

150
.husky/post-commit Executable file
View File

@ -0,0 +1,150 @@
#!/bin/sh
# graphify-hook-start
# Auto-rebuilds the knowledge graph after each commit (code files only, no LLM needed).
# Installed by: graphify hook install
# Deterministic clustering: networkx louvain iterates string-keyed sets whose
# order is randomized per-process by PYTHONHASHSEED, so community assignments
# churn run-to-run. Pinning it makes graphify-out reproducible.
export PYTHONHASHSEED=0
# Skip during rebase/merge/cherry-pick to avoid blocking --continue with unstaged changes
GIT_DIR=$(git rev-parse --git-dir 2>/dev/null)
[ -d "$GIT_DIR/rebase-merge" ] && exit 0
[ -d "$GIT_DIR/rebase-apply" ] && exit 0
[ -f "$GIT_DIR/MERGE_HEAD" ] && exit 0
[ -f "$GIT_DIR/CHERRY_PICK_HEAD" ] && exit 0
[ "${GRAPHIFY_SKIP_HOOK:-0}" = "1" ] && exit 0
CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null || git diff --name-only HEAD 2>/dev/null)
if [ -z "$CHANGED" ]; then
exit 0
fi
# Skip when only graphify-out/ artifacts changed (avoids rebuild loop when graph outputs are tracked in git)
_NON_GRAPH=$(echo "$CHANGED" | grep -v '^graphify-out/' || true)
if [ -z "$_NON_GRAPH" ]; then
exit 0
fi
# Detect the correct Python interpreter (handles uv tool, pipx, venv, system installs).
# _PINNED was recorded at hook-install time; tried first so the hook works even
# when the graphify launcher is not on PATH (common in GUI clients and CI).
GRAPHIFY_PYTHON=""
_PINNED='/Users/ksv741/.local/share/uv/tools/graphifyy/bin/python'
if [ -n "$_PINNED" ] && [ -x "$_PINNED" ] && "$_PINNED" -c "import graphify" 2>/dev/null; then
GRAPHIFY_PYTHON="$_PINNED"
fi
# Second probe: read graphify-out/.graphify_python (written by the skill and
# CLI; survives uv-tool reinstalls and is the same source the README documents).
if [ -z "$GRAPHIFY_PYTHON" ]; then
_GFY_PYTHON_FILE="graphify-out/.graphify_python"
if [ -f "$_GFY_PYTHON_FILE" ]; then
_FROM_FILE=$(cat "$_GFY_PYTHON_FILE" 2>/dev/null | tr -d '[:space:]')
case "$_FROM_FILE" in
*[!a-zA-Z0-9/_.@:\-]*) _FROM_FILE="" ;; # allowlist (covers Windows paths)
esac
if [ -n "$_FROM_FILE" ] && [ -x "$_FROM_FILE" ] && "$_FROM_FILE" -c "import graphify" 2>/dev/null; then
GRAPHIFY_PYTHON="$_FROM_FILE"
fi
fi
fi
# Third probe: resolve via the graphify launcher on PATH (shebang probe).
if [ -z "$GRAPHIFY_PYTHON" ]; then
GRAPHIFY_BIN=$(command -v graphify 2>/dev/null)
if [ -n "$GRAPHIFY_BIN" ]; then
case "$GRAPHIFY_BIN" in
*.exe) _SHEBANG="" ;;
*) _SHEBANG=$(head -1 "$GRAPHIFY_BIN" | sed 's/^#![[:space:]]*//') ;;
esac
case "$_SHEBANG" in
*/env\ *) GRAPHIFY_PYTHON="${_SHEBANG#*/env }" ;;
*) GRAPHIFY_PYTHON="$_SHEBANG" ;;
esac
# Allowlist: only keep characters valid in a filesystem path to prevent
# injection if the shebang contains shell metacharacters.
case "$GRAPHIFY_PYTHON" in
*[!a-zA-Z0-9/_.@-]*) GRAPHIFY_PYTHON="" ;;
esac
if [ -n "$GRAPHIFY_PYTHON" ] && ! "$GRAPHIFY_PYTHON" -c "import graphify" 2>/dev/null; then
GRAPHIFY_PYTHON=""
fi
fi
fi
# Last resort: try python3 / python (works for system/venv installs on PATH).
if [ -z "$GRAPHIFY_PYTHON" ]; then
if command -v python3 >/dev/null 2>&1 && python3 -c "import graphify" 2>/dev/null; then
GRAPHIFY_PYTHON="python3"
elif command -v python >/dev/null 2>&1 && python -c "import graphify" 2>/dev/null; then
GRAPHIFY_PYTHON="python"
else
echo "[graphify hook] could not locate a Python with graphify installed. Add the graphify bin dir to PATH or re-run 'graphify hook install' from the env where graphify lives." >&2
exit 0
fi
fi
export GRAPHIFY_CHANGED="$CHANGED"
# Run the rebuild detached so git commit returns immediately. Full-repo rebuilds
# can take hours; blocking the post-commit hook stalls the shell. The Python
# launcher below detaches the child cross-platform, so it works on Git for
# Windows' shell too (which lacks the coreutils backgrounding tools) (#1161).
_GRAPHIFY_LOG="${HOME}/.cache/graphify-rebuild.log"
mkdir -p "$(dirname "$_GRAPHIFY_LOG")"
export GRAPHIFY_REBUILD_LOG="$_GRAPHIFY_LOG"
echo "[graphify hook] launching background rebuild (log: $_GRAPHIFY_LOG)"
"$GRAPHIFY_PYTHON" -c "import os, subprocess, sys
_src = '''
import os, signal, sys
from pathlib import Path
changed_raw = os.environ.get('GRAPHIFY_CHANGED', '')
changed = [Path(f.strip()) for f in changed_raw.strip().splitlines() if f.strip()]
if not changed:
sys.exit(0)
print(f'[graphify hook] {len(changed)} file(s) changed - rebuilding graph...')
try:
from graphify.watch import _rebuild_code, _apply_resource_limits
_apply_resource_limits()
_timeout = int(os.environ.get('GRAPHIFY_REBUILD_TIMEOUT', '600'))
if _timeout > 0 and hasattr(signal, 'SIGALRM'):
signal.signal(signal.SIGALRM, lambda *_: (_ for _ in ()).throw(TimeoutError(f'graphify rebuild exceeded {_timeout}s')))
signal.alarm(_timeout)
_force = os.environ.get('GRAPHIFY_FORCE', '').lower() in ('1', 'true', 'yes')
_root = Path('.')
_saved = Path('graphify-out/.graphify_root')
if _saved.exists():
_txt = _saved.read_text(encoding='utf-8').strip()
if _txt:
_root = Path(_txt)
_rebuild_code(_root, changed_paths=changed, force=_force)
except TimeoutError as exc:
print(f'[graphify hook] {exc}')
sys.exit(1)
except Exception as exc:
print(f'[graphify hook] Rebuild failed: {exc}')
sys.exit(1)
'''
_log = os.environ.get('GRAPHIFY_REBUILD_LOG') or os.path.join(os.path.expanduser('~'), '.cache', 'graphify-rebuild.log')
try:
os.makedirs(os.path.dirname(_log), exist_ok=True)
_out = open(_log, 'a', buffering=1, encoding='utf-8', errors='replace')
except OSError:
_out = subprocess.DEVNULL
_kw = dict(stdout=_out, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, cwd=os.getcwd(), close_fds=True)
_cmd = [sys.executable, '-c', _src]
if os.name == 'nt':
_flags = 0x00000008 | 0x00000200 # DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP
try:
subprocess.Popen(_cmd, creationflags=_flags | 0x01000000, **_kw) # + CREATE_BREAKAWAY_FROM_JOB
except OSError:
subprocess.Popen(_cmd, creationflags=_flags, **_kw)
else:
subprocess.Popen(_cmd, start_new_session=True, **_kw)
"
# graphify-hook-end

2
.serena/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/cache
/project.local.yml

View File

@ -0,0 +1,33 @@
# Memory Maintenance
## Discovery Model
- Core principle: progressive discovery through references, building a graph of memories.
- Initially, agents are provided with the list of all memories (names only).
- Agents should read `mem:core` as the top-level entry point (graph root).
This memory should contain references to other memories covering major project domains.
The referenced memories shall, in turn, shall contain references to even more specific memories, and so on.
The depth of the graph shall depend on the project complexity.
- Use topics/folders to group related memories in order to make the content structure explicit.
Folders can mirror project structure (e.g. modules like frontend/backend) or topics like debugging, architecture, etc.
- Memory references must use a mem: prefix inside backticks, e.g. `mem:frontend/core`.
The surrounding text should clearly indicate when to read the memory/which content to expect.
The text should provide more precise guidance than the memory name alone,
i.e. avoid a reference like "frontend debugging: `mem:frontend/debugging` and instead make clear which aspects of frontend debugging are covered.
- Memories themselves should not contain information about when to read them; this is the responsibility of the referring memory.
## Style
Dense agent notes, not prose docs. Prefer invariants, terse bullets.
Avoid obvious context, rationale, and examples unless they prevent likely mistakes.
Keep guidance durable and generalizable, not task-local.
## Add/update threshold
Add or update memories only with stable, non-obvious project conventions that avoid complex rediscovery in the future.
Do not add: quick-read facts; generic language/framework knowledge; one-off task notes; volatile line-level details; behavior likely to change soon.
## Maintenance Actions
- Renaming memories: References are updated automatically if handled via Serena's memory rename tool.
- Checking for stale memories (e.g. after deletion): Call `serena memories check` for a report.

133
.serena/project.yml Normal file
View File

@ -0,0 +1,133 @@
# the name by which the project can be referenced within Serena
project_name: "moex-vibe"
# list of languages for which language servers are started; choose from:
# al angular ansible bash clojure
# cpp cpp_ccls crystal csharp csharp_omnisharp
# dart elixir elm erlang fortran
# fsharp go groovy haskell haxe
# hlsl html java json julia
# kotlin lean4 lua luau markdown
# matlab msl nix ocaml pascal
# perl php php_phpactor powershell python
# python_jedi python_ty r rego ruby
# ruby_solargraph rust scala scss solidity
# svelte swift systemverilog terraform toml
# typescript typescript_vts vue yaml zig
# (This list may be outdated. For the current list, see values of Language enum here:
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
# Note:
# - For C, use cpp
# - For JavaScript, use typescript
# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root)
# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm)
# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three)
# - For Free Pascal/Lazarus, use pascal
# Special requirements:
# Some languages require additional setup/installations.
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
# When using multiple languages, the first language server that supports a given file will be used for that file.
# The first language is the default language and the respective language server will be used as a fallback.
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
languages:
- typescript
# the encoding used by text files in the project
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
encoding: "utf-8"
# line ending convention to use when writing source files.
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
line_ending:
# The language backend to use for this project.
# If not set, the global setting from serena_config.yml is used.
# Valid values: LSP, JetBrains
# Note: the backend is fixed at startup. If a project with a different backend
# is activated post-init, an error will be returned.
language_backend:
# whether to use project's .gitignore files to ignore files
ignore_all_files_in_gitignore: true
# advanced configuration option allowing to configure language server-specific options.
# Maps the language key to the options.
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
# No documentation on options means no options are available.
ls_specific_settings: {}
# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos).
# Paths can be absolute or relative to the project root.
# Each folder is registered as an LSP workspace folder, enabling language servers to discover
# symbols and references across package boundaries.
# Currently supported for: TypeScript.
# Example:
# additional_workspace_folders:
# - ../sibling-package
# - ../shared-lib
additional_workspace_folders: []
# list of additional paths to ignore in this project.
# Same syntax as gitignore, so you can use * and **.
# Note: global ignored_paths from serena_config.yml are also applied additively.
ignored_paths: []
# whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18
read_only: false
# list of tool names to exclude.
# This extends the existing exclusions (e.g. from the global configuration)
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
excluded_tools: []
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
# This extends the existing inclusions (e.g. from the global configuration).
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
included_optional_tools: []
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
fixed_tools: []
# list of mode names that are to be activated by default, overriding the setting in the global configuration.
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply
# for this project.
# This setting can, in turn, be overridden by CLI parameters (--mode).
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
default_modes:
# list of mode names to be activated additionally for this project, e.g. ["query-projects"]
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
added_modes:
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
# time budget (seconds) per tool call for the retrieval of additional symbol information
# such as docstrings or parameter information.
# This overrides the corresponding setting in the global configuration; see the documentation there.
# If null or missing, use the setting from the global configuration.
symbol_info_budget:
# list of regex patterns which, when matched, mark a memory entry as readonly.
# Extends the list from the global configuration, merging the two lists.
read_only_memory_patterns: []
# list of regex patterns for memories to completely ignore.
# Matching memories will not appear in list_memories or activate_project output
# and cannot be accessed via read_memory or write_memory.
# To access ignored memory files, use the read_file tool on the raw file path.
# Extends the list from the global configuration, merging the two lists.
# Example: ["_archive/.*", "_episodes/.*"]
ignored_memory_patterns: []

View File

@ -41,7 +41,7 @@
- **SDD (Specification-Driven Development)**: перед значимыми изменениями сначала зафиксировать спецификацию нужного масштаба — PRD/цели, доменную модель, ADR, API-контракт, frontend/backend architecture и этапы реализации. Для небольших maintenance-правок достаточно короткого обоснования и acceptance criteria.
- **Superpowers**: использовать релевантные Skills при старте задачи. Обычно: brainstorming для уточнения дизайна, systematic-debugging для багов, test-driven-development для feature/bugfix, writing-plans для крупных многошаговых работ, subagent-driven-development как предпочтительный способ исполнения плана, executing-plans как fallback для явно связанных inline-задач, frontend-design для UI, requesting-code-review перед завершением крупных изменений.
- **MCP-инструменты**: использовать MCP для анализа, дизайна, работы с API, генерации кода и проверки локального UI, когда это полезно задаче.
- **MCP-инструменты**: в проекте настроены `code-index-mcp` (файловый поиск/индексация), `serena` (LSP-символьный анализ кода) и `graphify` (knowledge graph). Использовать для анализа, дизайна, работы с API, генерации кода и проверки локального UI, когда это полезно задаче.
- **Visual Companion**: в ходе `brainstorming`, если предстоящие вопросы действительно требуют визуального представления (mockups, wireframes, диаграммы, сравнение вариантов), отдельным сообщением предложить пользователю [Visual Companion](https://github.com/obra/superpowers/blob/main/skills/brainstorming/visual-companion.md). Использовать его только после согласия пользователя и только для тех вопросов, которые понятнее показать, чем описать текстом. Visual Companion — инструмент, а не отдельный режим работы.
---
@ -459,3 +459,61 @@ roadmap.md и inbox.md никогда не являются основанием
- Тесты фронтенда есть: Vitest + Testing Library + MSW.
- CI находится в `.gitea/workflows/ci.yml`.
- Pre-commit checks настроены через Husky и lint-staged.
## code-index-mcp
В проекте настроен `code-index-mcp` — MCP-сервер для быстрого поиска файлов и кода.
**Инструменты:**
- `find_files(pattern)` — поиск файлов по glob-паттерну через in-memory индекс
- `search_code_advanced(pattern)` — поиск кода с поддержкой regex, контекста, фильтрации по типу файла
- `get_file_summary(path)` — сводка по файлу (строки, функции, классы, импорты)
- `get_symbol_body(path, symbol_name)` — получить тело символа (функции/класса)
- `find_implementations(name_path, relative_path)` — найти реализации символа
- `find_referencing_symbols(name_path, relative_path)` — найти ссылки на символ
**Когда использовать:**
- Поиск файлов по имени или паттерну (glob)
- Быстрый grep по коду с контекстом
- Получение только тела функции/класса без всего файла
---
## serena
В проекте настроена `serena` — MCP-сервер с LSP-символьным анализом кода. Предоставляет symbol-aware инструменты поверх TypeScript LSP.
**Инструменты:**
- `find_symbol(name_path_pattern)` — поиск символов (классы, функции, методы) по всему проекту
- `get_symbols_overview(relative_path)` — обзор символов в файле (группировка по типу)
- `find_referencing_symbols(name_path, relative_path)` — где используется символ
- `find_implementations(name_path, relative_path)` — реализации интерфейса/класса
- `find_declaration(relative_path, regex)` — найти объявление по вызову
- `replace_symbol_body(name_path, relative_path, body)` — заменить тело метода
- `rename_symbol(name_path, relative_path, new_name)` — рефакторинг-переименование
- `replace_content(relative_path, needle, repl, mode)` — regex-замена в файле
- `safe_delete_symbol(name_path, relative_path)` — удалить неиспользуемый символ
- `get_diagnostics_for_file(relative_path)` — ошибки/предупреждения в файле
- `write_memory/read_memory/list_memories` — сохранение контекста между сессиями
**Когда использовать:**
- Найти все использования функции/метода в коде
- Получить структуру файла (классы, методы)
- Безопасный рефакторинг (переименование, удаление)
- Получить LSP-диагностику (ошибки компиляции)
- Запомнить что-то между сессиями (memories)
---
## graphify
This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.
When the user types `/graphify`, invoke the `skill` tool with `skill: "graphify"` before doing anything else.
Rules:
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
- Dirty graphify-out/ files are expected after hooks or incremental updates; dirty graph files are not a reason to skip graphify. Only skip graphify if the task is about stale or incorrect graph output, or the user explicitly says not to use it.
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).

View File

@ -174,12 +174,18 @@
Таблица данных на основе TanStack Table.
Использует `TanStack Table` как source of truth для модели данных, а MUI только для табличной
оболочки. Поддерживает loading state, empty state, density и выравнивание через `columnDef.meta.align`.
| Свойство | Тип | По умолчанию |
|----------|-----|--------------|
| `columns` | `ColumnDef<T>[]` | — |
| `data` | `T[]` | — |
| `caption` | `string` | — |
| `density` | `'balanced' \| 'compact'` | `'balanced'` |
| `loading` | `boolean` | `false` |
| `empty` | `ReactNode` | — |
| `renderRow` | `(row) => ReactNode` | — |
### Money

View File

@ -10,6 +10,10 @@
Все новые компоненты должны использовать токены дизайн-системы. Подробнее — [Дизайн-система](../design-system/overview).
Для таблиц используйте `DataTable` из дизайн-системы. Он уже встроен в `TanStack Table` и поддерживает
выравнивание, loading/empty state и кастомный рендер строк без прямого использования `MUI Table` во
frontend-коде.
## Legacy: CSS custom properties
Ранее стили определялись через единый `styles.css` с CSS custom properties. Этот подход считается устаревшим — новые страницы должны использовать токены дизайн-системы.

View File

@ -0,0 +1,99 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { ScreenerTable } from './ScreenerTable'
vi.mock('@tanstack/react-router', () => ({
Link: ({ children }: { children: React.ReactNode }) => <a href="/mock">{children}</a>,
}))
const shareResult = {
total: 2,
page: 1,
pageSize: 10,
totalPages: 2,
items: [
{
secid: 'SBER',
shortName: 'Сбер',
type: 'share',
price: 289.5,
changePercent: 0.87,
volume: 1000,
capitalization: 625000000,
},
],
} as const
const bondResult = {
...shareResult,
items: [
{
secid: 'SU26238RMFS5',
shortName: 'ОФЗ 26238',
type: 'bond',
price: 98.5,
changePercent: -0.5,
volume: 500,
yieldToMaturity: 8.2,
duration: 3.5,
couponValue: 36.9,
couponPercent: 7.5,
},
],
} as const
describe('ScreenerTable', () => {
it('renders share columns and pagination', () => {
const onSort = vi.fn()
const onPageChange = vi.fn()
render(
<ScreenerTable
result={shareResult as never}
sortBy="price"
sortOrder="asc"
onSort={onSort}
onPageChange={onPageChange}
/>,
)
expect(screen.getByText('Найдено: 2 бумаг')).toBeInTheDocument()
expect(screen.getByText('Капитализация')).toBeInTheDocument()
expect(screen.getByText('SBER')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '2' })).toBeInTheDocument()
expect(onSort).not.toHaveBeenCalled()
})
it('calls onSort when a sortable header is clicked', () => {
const onSort = vi.fn()
render(
<ScreenerTable
result={shareResult as never}
sortBy="price"
sortOrder="asc"
onSort={onSort}
onPageChange={() => undefined}
/>,
)
screen.getByRole('button', { name: /Цена/ }).click()
expect(onSort).toHaveBeenCalledWith('price')
})
it('renders bond columns', () => {
render(
<ScreenerTable
result={bondResult as never}
sortBy="price"
sortOrder="desc"
onSort={() => undefined}
onPageChange={() => undefined}
/>,
)
expect(screen.getByText('YTM')).toBeInTheDocument()
expect(screen.getByText('ОФЗ 26238')).toBeInTheDocument()
})
})

View File

@ -1,4 +1,7 @@
import { Button, DataTable, Text } from '@moex-vibe/design-system'
import { Box } from '@mui/material'
import { Link } from '@tanstack/react-router'
import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table'
import type { ScreenerResult } from '@/shared/api'
interface Props {
@ -23,149 +26,173 @@ function formatChange(value: number | null | undefined): { text: string; color:
return { text: `${value > 0 ? '+' : ''}${value.toFixed(2)}%`, color }
}
export function ScreenerTable({ result, sortBy, sortOrder, onSort, onPageChange }: Props) {
function SortHeader({ field, children }: { field: string; children: string }) {
const isActive = sortBy === field
return (
<th
onClick={() => onSort(field)}
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
cursor: 'pointer',
userSelect: 'none',
whiteSpace: 'nowrap',
}}
>
{children} {isActive ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</th>
)
}
type ScreenerItem = ScreenerResult['items'][number]
const columnHelper = createColumnHelper<ScreenerItem>()
export function ScreenerTable({ result, sortBy, sortOrder, onSort, onPageChange }: Props) {
const isShare = result.items[0]?.type === 'share'
const columns = [
columnHelper.accessor('secid', {
header: 'Тикер',
cell: (info) => {
const link = isShare
? `/stocks/${info.row.original.secid}`
: `/bonds/${info.row.original.secid}`
return (
<Link to={link} style={{ color: 'inherit', textDecoration: 'none' }}>
{info.getValue()}
</Link>
)
},
}),
columnHelper.accessor('shortName', { header: 'Название', cell: (info) => info.getValue() }),
columnHelper.accessor('price', {
header: () => (
<Box
component="button"
type="button"
onClick={() => onSort('price')}
sx={{ all: 'unset', cursor: 'pointer' }}
>
Цена {sortBy === 'price' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</Box>
),
meta: { align: 'right' },
}),
columnHelper.accessor('changePercent', {
header: () => (
<Box
component="button"
type="button"
onClick={() => onSort('changePercent')}
sx={{ all: 'unset', cursor: 'pointer' }}
>
Изм. {sortBy === 'changePercent' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</Box>
),
meta: { align: 'right' },
cell: (info) => {
const change = formatChange(info.getValue())
return <span style={{ color: change.color }}>{change.text}</span>
},
}),
columnHelper.accessor('volume', {
header: () => (
<Box
component="button"
type="button"
onClick={() => onSort('volume')}
sx={{ all: 'unset', cursor: 'pointer' }}
>
Объём {sortBy === 'volume' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</Box>
),
meta: { align: 'right' },
cell: (info) => info.getValue().toLocaleString('ru-RU'),
}),
...(isShare
? [
columnHelper.accessor('capitalization', {
header: 'Капитализация',
meta: { align: 'right' },
cell: (info) => {
const v = info.getValue()
return v != null ? v.toLocaleString('ru-RU') : '—'
},
}),
]
: [
columnHelper.accessor('yieldToMaturity', {
header: () => (
<Box
component="button"
type="button"
onClick={() => onSort('yieldToMaturity')}
sx={{ all: 'unset', cursor: 'pointer' }}
>
YTM {sortBy === 'yieldToMaturity' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</Box>
),
meta: { align: 'right' },
cell: (info) => formatNum(info.getValue()),
}),
columnHelper.accessor('duration', {
header: () => (
<Box
component="button"
type="button"
onClick={() => onSort('duration')}
sx={{ all: 'unset', cursor: 'pointer' }}
>
Дюрация {sortBy === 'duration' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</Box>
),
meta: { align: 'right' },
cell: (info) => {
const v = info.getValue()
return v != null ? `${v.toFixed(2)}г` : '—'
},
}),
columnHelper.accessor('couponValue', {
header: () => (
<Box
component="button"
type="button"
onClick={() => onSort('couponValue')}
sx={{ all: 'unset', cursor: 'pointer' }}
>
Купон {sortBy === 'couponValue' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</Box>
),
meta: { align: 'right' },
cell: (info) => formatNum(info.getValue()),
}),
columnHelper.accessor('couponPercent', {
header: () => (
<Box
component="button"
type="button"
onClick={() => onSort('couponPercent')}
sx={{ all: 'unset', cursor: 'pointer' }}
>
Куп. % {sortBy === 'couponPercent' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</Box>
),
meta: { align: 'right' },
cell: (info) => formatNum(info.getValue()),
}),
]),
]
const table = useReactTable({
data: result.items,
columns,
getCoreRowModel: getCoreRowModel(),
})
return (
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', marginBottom: 8 }}>
<Box sx={{ flex: 1 }}>
<Text variant="caption" tone="secondary" style={{ fontSize: 13, marginBottom: 8 }}>
Найдено: {result.total} бумаг
</div>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ borderBottom: '2px solid #e0e0e0' }}>
<th
style={{
textAlign: 'left',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Тикер
</th>
<th
style={{
textAlign: 'left',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Название
</th>
<SortHeader field="price">Цена</SortHeader>
<SortHeader field="changePercent">Изм.</SortHeader>
<SortHeader field="volume">Объём</SortHeader>
{isShare ? (
<SortHeader field="capitalization">Капитализация</SortHeader>
) : (
<>
<SortHeader field="yieldToMaturity">YTM</SortHeader>
<SortHeader field="duration">Дюрация</SortHeader>
<SortHeader field="couponValue">Купон</SortHeader>
<SortHeader field="couponPercent">Куп. %</SortHeader>
</>
)}
</tr>
</thead>
<tbody>
{result.items.map((item) => {
const change = formatChange(item.changePercent)
const link = isShare ? `/stocks/${item.secid}` : `/bonds/${item.secid}`
return (
<tr key={item.secid} style={{ borderBottom: '1px solid #f0f0f0' }}>
<td style={{ padding: '8px 12px', fontWeight: 600, fontFamily: 'monospace' }}>
<Link to={link} style={{ color: 'inherit', textDecoration: 'none' }}>
{item.secid}
</Link>
</td>
<td style={{ padding: '8px 12px', color: 'var(--color-text-secondary)' }}>
{item.shortName}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{formatNum(item.price)}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right', color: change.color }}>
{change.text}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{item.volume.toLocaleString('ru-RU')}
</td>
{isShare ? (
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{item.capitalization != null
? item.capitalization.toLocaleString('ru-RU')
: '—'}
</td>
) : (
<>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{formatNum(item.yieldToMaturity)}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{item.duration != null ? `${item.duration.toFixed(2)}г` : '—'}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{formatNum(item.couponValue)}
</td>
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
{formatNum(item.couponPercent)}
</td>
</>
)}
</tr>
)
})}
</tbody>
</table>
</div>
</Text>
<DataTable table={table} caption="Screener" />
{result.totalPages > 1 && (
<div style={{ display: 'flex', justifyContent: 'center', gap: 8, marginTop: 16 }}>
<Box sx={{ display: 'flex', justifyContent: 'center', gap: 1, mt: 2 }}>
{Array.from({ length: Math.min(result.totalPages, 10) }, (_, i) => i + 1).map((p) => (
<button
<Button
key={p}
onClick={() => onPageChange(p)}
style={{
padding: '4px 10px',
background: p === result.page ? 'var(--color-primary)' : 'transparent',
color: p === result.page ? '#fff' : 'var(--color-text)',
border: '1px solid #e0e0e0',
borderRadius: 'var(--border-radius)',
fontSize: 13,
cursor: 'pointer',
}}
variant={p === result.page ? 'primary' : 'secondary'}
size="small"
>
{p}
</button>
</Button>
))}
</div>
</Box>
)}
</div>
</Box>
)
}

View File

@ -1,42 +0,0 @@
import { flexRender, type Table as TanStackTable } from '@tanstack/react-table'
interface TableProps<TData> {
table: TanStackTable<TData>
}
export function Table<TData>({ table }: TableProps<TData>) {
return (
<div className="table-container">
<table>
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
colSpan={header.colSpan}
style={{ cursor: header.column.getCanSort() ? 'pointer' : undefined }}
onClick={header.column.getToggleSortingHandler()}
>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
{{ asc: ' ▲', desc: ' ▼' }[header.column.getIsSorted() as string] ?? null}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id}>
{row.getVisibleCells().map((cell) => (
<td key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}

View File

@ -1 +0,0 @@
export { Table } from './Table'

View File

@ -1,25 +0,0 @@
import { Skeleton } from '@moex-vibe/design-system'
const tdStyle = {
borderBottom: '1px solid #eeeeee',
padding: '10px 8px',
verticalAlign: 'top',
} satisfies React.CSSProperties
type Column = { width: string }
export function TableSkeleton({ rows = 5, columns }: { rows?: number; columns: Column[] }) {
return (
<tbody>
{Array.from({ length: rows }).map((_, i) => (
<tr key={i}>
{columns.map((col, j) => (
<td key={j} style={tdStyle}>
<Skeleton height={12} width={col.width} shape="text" />
</td>
))}
</tr>
))}
</tbody>
)
}

View File

@ -1 +0,0 @@
export { TableSkeleton } from './TableSkeleton'

View File

@ -0,0 +1,73 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import type { PositionWithPrice } from '@/shared/api'
import { BondPositionTable } from './BondPositionTable'
vi.mock('@tanstack/react-router', () => ({
Link: ({ children }: { children: React.ReactNode }) => <a href="/mock">{children}</a>,
}))
const positions: PositionWithPrice[] = [
{
id: 1,
secid: 'SU26238RMFS5',
shortName: 'ОФЗ 26238',
type: 'bond',
quantity: 5,
buyPrice: 980,
buyDate: null,
notes: null,
tags: null,
currentPrice: 985,
totalCost: 4900,
currentValue: 4925,
weightPercent: 7.5,
pnl: 25,
pnlPercent: 0.5,
dividendIncome: null,
totalReturn: null,
totalReturnPercent: null,
change: 5,
changePercent: 0.5,
yieldToMaturity: 8.2,
duration: 3.5,
couponValue: 36.9,
couponPercent: 7.5,
nextCouponDate: '2024-07-15',
matDate: '2027-05-15',
accruedInt: 8.45,
bid: 984,
offer: 986,
couponPeriod: 182,
bondType: 'ОФЗ',
offerDate: null,
},
]
describe('BondPositionTable', () => {
it('renders section title and bond row', () => {
render(
<BondPositionTable
positions={positions}
onUpdatePosition={() => undefined}
onDeletePosition={() => undefined}
/>,
)
expect(screen.getByText('Облигации')).toBeInTheDocument()
expect(screen.getByText('SU26238RMFS5')).toBeInTheDocument()
expect(screen.getByText('ОФЗ 26238')).toBeInTheDocument()
})
it('renders nothing when there are no positions', () => {
const { container } = render(
<BondPositionTable
positions={[]}
onUpdatePosition={() => undefined}
onDeletePosition={() => undefined}
/>,
)
expect(container).toBeEmptyDOMElement()
})
})

View File

@ -1,3 +1,6 @@
import { DataTable } from '@moex-vibe/design-system'
import { Box } from '@mui/material'
import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table'
import type { PositionWithPrice } from '@/shared/api'
import { BondPositionRow } from './BondPositionRow'
@ -10,264 +13,58 @@ interface Props {
onDeletePosition: (positionId: number) => void
}
const columnHelper = createColumnHelper<PositionWithPrice>()
const columns = [
columnHelper.accessor('secid', { header: 'Тикер' }),
columnHelper.accessor('shortName', {
header: 'Название',
cell: (info) => info.getValue() ?? '—',
}),
columnHelper.accessor('bondType', { header: 'Тип', cell: (info) => info.getValue() ?? '—' }),
columnHelper.accessor('quantity', { header: 'Количество' }),
columnHelper.accessor('buyPrice', { header: 'Цена пок.', meta: { align: 'right' } }),
columnHelper.accessor('currentPrice', { header: 'Цена', meta: { align: 'right' } }),
columnHelper.accessor('bid', { header: 'Бид', meta: { align: 'right' } }),
columnHelper.accessor('offer', { header: 'Оффер', meta: { align: 'right' } }),
columnHelper.accessor('yieldToMaturity', { header: 'Доходность', meta: { align: 'right' } }),
columnHelper.accessor('duration', { header: 'Дюрация', meta: { align: 'right' } }),
columnHelper.accessor('couponValue', { header: 'Купон', meta: { align: 'right' } }),
columnHelper.accessor('couponPercent', { header: 'Куп. %', meta: { align: 'right' } }),
columnHelper.accessor('couponPeriod', { header: 'Период', meta: { align: 'right' } }),
columnHelper.accessor('accruedInt', { header: 'НКД', meta: { align: 'right' } }),
columnHelper.accessor('totalCost', { header: 'Затраты', meta: { align: 'right' } }),
columnHelper.accessor('pnl', { header: 'P&L', meta: { align: 'right' } }),
columnHelper.accessor('pnlPercent', { header: 'P&L %', meta: { align: 'right' } }),
columnHelper.accessor('nextCouponDate', { header: 'След. купон', meta: { align: 'right' } }),
columnHelper.accessor('matDate', { header: 'Погашение', meta: { align: 'right' } }),
columnHelper.accessor('offerDate', { header: 'Оферта', meta: { align: 'right' } }),
columnHelper.accessor('weightPercent', { header: 'Доля', meta: { align: 'right' } }),
]
export function BondPositionTable({ positions, onUpdatePosition, onDeletePosition }: Props) {
const table = useReactTable({
data: positions,
columns,
getCoreRowModel: getCoreRowModel(),
})
if (positions.length === 0) return null
return (
<div style={{ marginTop: 24 }}>
<h3 style={{ margin: '0 0 12px', fontSize: 15, fontWeight: 600, color: 'var(--color-text)' }}>
Облигации
</h3>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
<thead>
<tr style={{ borderBottom: '2px solid #e0e0e0' }}>
<th
style={{
textAlign: 'left',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Тикер
</th>
<th
style={{
textAlign: 'left',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Название
</th>
<th
style={{
textAlign: 'left',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Тип
</th>
<th
style={{
textAlign: 'left',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Количество
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Цена пок.
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Цена
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Бид
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Оффер
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Доходность
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Дюрация
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Купон
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Куп. %
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Период
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
НКД
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Затраты
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
P&L
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
P&L %
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
След. купон
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Погашение
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Оферта
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Доля
</th>
<th style={{ padding: '8px 12px', width: 40 }}></th>
</tr>
</thead>
<tbody>
{positions.map((pos) => (
<BondPositionRow
key={pos.id}
position={pos}
onUpdate={(data) => onUpdatePosition(pos.id, data)}
onDelete={() => onDeletePosition(pos.id)}
/>
))}
</tbody>
</table>
</div>
</div>
<Box sx={{ marginTop: 3 }}>
<DataTable
table={table}
caption="Облигации"
renderRow={(row) => (
<BondPositionRow
key={row.id}
position={row.original}
onUpdate={(data) => onUpdatePosition(row.original.id, data)}
onDelete={() => onDeletePosition(row.original.id)}
/>
)}
/>
</Box>
)
}

View File

@ -1,7 +1,7 @@
import { Button, Heading, Skeleton, Text } from '@moex-vibe/design-system'
import { Box } from '@mui/material'
import { Link } from '@tanstack/react-router'
import type { ReactNode } from 'react'
import type { CSSProperties, ReactNode } from 'react'
import {
type BrokerOperationImpact,
getBrokerOperationImpact,
@ -10,7 +10,35 @@ import {
import { getBrokerInstrumentPath } from '@/entities/broker-position'
import type { BrokerOperation, BrokerOperationsPage } from '@/shared/api'
import { formatBrokerSignedMoney } from '@/shared/lib/formatters'
import { TableSkeleton } from '@/shared/ui/TableSkeleton'
const TABLE_SKELETON_COLUMNS = [
{ width: '35%' },
{ width: '30%' },
{ width: '40%' },
{ width: '25%' },
] as const
const tdSkeletonStyle: CSSProperties = {
borderBottom: '1px solid #eeeeee',
padding: '10px 8px',
verticalAlign: 'top',
}
function TableSkeleton({ rows = 5 }: { rows?: number }) {
return (
<tbody>
{Array.from({ length: rows }).map((_, i) => (
<tr key={i}>
{TABLE_SKELETON_COLUMNS.map((col, j) => (
<td key={j} style={tdSkeletonStyle}>
<Skeleton height={12} width={col.width} shape="text" />
</td>
))}
</tr>
))}
</tbody>
)
}
const tableSx = {
width: '100%',
@ -164,10 +192,7 @@ export function BrokerOperationsTable({
</Box>
</Box>
</Box>
<TableSkeleton
rows={5}
columns={[{ width: '35%' }, { width: '30%' }, { width: '40%' }, { width: '25%' }]}
/>
<TableSkeleton rows={5} />
</Box>
</Box>
) : operations.length === 0 && !isFetching ? (

View File

@ -12,4 +12,10 @@ describe('DividendsTable', () => {
expect(screen.getByText('2024-07-10')).toBeInTheDocument()
expect(screen.getByText('35.00 RUB')).toBeInTheDocument()
})
it('shows empty state when there are no dividends', () => {
render(<DividendsTable dividends={[]} />)
expect(screen.getByText('Нет дивидендов')).toBeInTheDocument()
})
})

View File

@ -1,38 +1,47 @@
import { DataTable, Text } from '@moex-vibe/design-system'
import { Box } from '@mui/material'
import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table'
import type { DividendItem } from '@/shared/api'
interface DividendsTableProps {
dividends: DividendItem[]
}
const columnHelper = createColumnHelper<DividendItem>()
const columns = [
columnHelper.accessor('registryCloseDate', {
header: 'Дата закрытия реестра',
cell: (info) => info.getValue(),
}),
columnHelper.accessor('value', {
header: 'Сумма',
meta: { align: 'right' },
cell: (info) => `${info.getValue().toFixed(2)} ${info.row.original.currency}`,
}),
]
export function DividendsTable({ dividends }: DividendsTableProps) {
const table = useReactTable({
data: dividends,
columns,
getCoreRowModel: getCoreRowModel(),
})
return (
<div
style={{
<Box
sx={{
background: 'var(--color-surface)',
borderRadius: 'var(--border-radius)',
boxShadow: 'var(--shadow)',
padding: 24,
padding: 3,
}}
>
<h3 style={{ marginBottom: 16 }}>Дивиденды</h3>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '2px solid #eee' }}>
<th style={{ textAlign: 'left', padding: 8 }}>Дата закрытия реестра</th>
<th style={{ textAlign: 'right', padding: 8 }}>Сумма</th>
</tr>
</thead>
<tbody>
{dividends.map((d, i) => (
<tr key={i} style={{ borderBottom: '1px solid #eee' }}>
<td style={{ padding: 8 }}>{d.registryCloseDate}</td>
<td style={{ textAlign: 'right', padding: 8 }}>
{d.value.toFixed(2)} {d.currency}
</td>
</tr>
))}
</tbody>
</table>
</div>
<DataTable
table={table}
caption="Дивиденды"
empty={<Text tone="secondary">Нет дивидендов</Text>}
/>
</Box>
)
}

View File

@ -0,0 +1,73 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import type { PositionWithPrice } from '@/shared/api'
import { SharePositionTable } from './SharePositionTable'
vi.mock('@tanstack/react-router', () => ({
Link: ({ children }: { children: React.ReactNode }) => <a href="/mock">{children}</a>,
}))
const positions: PositionWithPrice[] = [
{
id: 1,
secid: 'SBER',
shortName: 'Сбер',
type: 'share',
quantity: 10,
buyPrice: 250,
buyDate: null,
notes: null,
tags: null,
currentPrice: 260,
totalCost: 2500,
currentValue: 2600,
weightPercent: 12.5,
pnl: 100,
pnlPercent: 4,
dividendIncome: null,
totalReturn: null,
totalReturnPercent: null,
change: 10,
changePercent: 4,
yieldToMaturity: null,
duration: null,
couponValue: null,
couponPercent: null,
nextCouponDate: null,
matDate: null,
accruedInt: null,
bid: null,
offer: null,
couponPeriod: null,
bondType: null,
offerDate: null,
},
]
describe('SharePositionTable', () => {
it('renders section title and position row', () => {
render(
<SharePositionTable
positions={positions}
onUpdatePosition={() => undefined}
onDeletePosition={() => undefined}
/>,
)
expect(screen.getByText('Акции')).toBeInTheDocument()
expect(screen.getByText('SBER')).toBeInTheDocument()
expect(screen.getByText('Сбер')).toBeInTheDocument()
})
it('renders nothing when there are no positions', () => {
const { container } = render(
<SharePositionTable
positions={[]}
onUpdatePosition={() => undefined}
onDeletePosition={() => undefined}
/>,
)
expect(container).toBeEmptyDOMElement()
})
})

View File

@ -1,3 +1,6 @@
import { DataTable } from '@moex-vibe/design-system'
import { Box } from '@mui/material'
import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table'
import type { PositionWithPrice } from '@/shared/api'
import { SharePositionRow } from './SharePositionRow'
@ -10,154 +13,49 @@ interface Props {
onDeletePosition: (positionId: number) => void
}
const columnHelper = createColumnHelper<PositionWithPrice>()
const columns = [
columnHelper.accessor('secid', { header: 'Тикер' }),
columnHelper.accessor('shortName', {
header: 'Название',
cell: (info) => info.getValue() ?? '—',
}),
columnHelper.accessor('quantity', { header: 'Количество' }),
columnHelper.accessor('buyPrice', { header: 'Цена пок.', meta: { align: 'right' } }),
columnHelper.accessor('currentPrice', { header: 'Цена', meta: { align: 'right' } }),
columnHelper.accessor('change', { header: 'Изм.', meta: { align: 'right' } }),
columnHelper.accessor('currentValue', { header: 'Стоимость', meta: { align: 'right' } }),
columnHelper.accessor('totalCost', { header: 'Затраты', meta: { align: 'right' } }),
columnHelper.accessor('pnl', { header: 'P&L', meta: { align: 'right' } }),
columnHelper.accessor('pnlPercent', { header: 'P&L %', meta: { align: 'right' } }),
columnHelper.accessor('weightPercent', { header: 'Доля', meta: { align: 'right' } }),
columnHelper.display({ id: 'actions', header: '', meta: { align: 'left' } }),
]
export function SharePositionTable({ positions, onUpdatePosition, onDeletePosition }: Props) {
const table = useReactTable({
data: positions,
columns,
getCoreRowModel: getCoreRowModel(),
})
if (positions.length === 0) return null
return (
<div style={{ marginTop: 16 }}>
<h3 style={{ margin: '0 0 12px', fontSize: 15, fontWeight: 600, color: 'var(--color-text)' }}>
Акции
</h3>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
<thead>
<tr style={{ borderBottom: '2px solid #e0e0e0' }}>
<th
style={{
textAlign: 'left',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Тикер
</th>
<th
style={{
textAlign: 'left',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Название
</th>
<th
style={{
textAlign: 'left',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Количество
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Цена пок.
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Цена
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Изм.
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Стоимость
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Затраты
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
P&L
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
P&L %
</th>
<th
style={{
textAlign: 'right',
padding: '8px 12px',
fontWeight: 600,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
Доля
</th>
<th style={{ padding: '8px 12px', width: 40 }}></th>
</tr>
</thead>
<tbody>
{positions.map((pos) => (
<SharePositionRow
key={pos.id}
position={pos}
onUpdate={(data) => onUpdatePosition(pos.id, data)}
onDelete={() => onDeletePosition(pos.id)}
/>
))}
</tbody>
</table>
</div>
</div>
<Box sx={{ marginTop: 2 }}>
<DataTable
table={table}
caption="Акции"
renderRow={(row) => (
<SharePositionRow
key={row.id}
position={row.original}
onUpdate={(data) => onUpdatePosition(row.original.id, data)}
onDelete={() => onDeletePosition(row.original.id)}
/>
)}
/>
</Box>
)
}

View File

@ -10,46 +10,47 @@
## Task 1: Уточнить API `DataTable`
- [ ] Проверить текущее API `DataTable`
- [ ] Зафиксировать минимальный список поддерживаемых сценариев из существующих таблиц
- [ ] Подготовить изменения API только под подтверждённые кейсы
- [ ] Убедиться, что `TanStack Table` остаётся source of truth
- [x] Проверить текущее API `DataTable`
- [x] Зафиксировать минимальный список поддерживаемых сценариев из существующих таблиц
- [x] Подготовить изменения API только под подтверждённые кейсы
- [x] Убедиться, что `TanStack Table` остаётся source of truth
## Task 2: Мигрировать `DividendsTable`
- [ ] Перевести HTML table на `DataTable`
- [ ] Сохранить текущие колонки и форматирование
- [ ] Проверить empty/loading presentation
- [x] Перевести HTML table на `DataTable`
- [x] Сохранить текущие колонки и форматирование
- [x] Проверить empty/loading presentation
## Task 3: Мигрировать `ScreenerTable`
- [ ] Перевести табличную оболочку на `DataTable`
- [ ] Сохранить сортировку и пагинацию
- [ ] Сохранить row actions и кастомные ячейки
- [x] Перевести табличную оболочку на `DataTable`
- [x] Сохранить сортировку и пагинацию
- [x] Сохранить row actions и кастомные ячейки
## Task 4: Мигрировать `SharePositionTable`
- [ ] Перевести таблицу на `DataTable`
- [ ] Сохранить доменные row/cell renderers в приложении
- [ ] Сохранить update/delete сценарии
- [x] Перевести таблицу на `DataTable`
- [x] Сохранить доменные row/cell renderers в приложении
- [x] Сохранить update/delete сценарии
## Task 5: Мигрировать `BondPositionTable`
- [ ] Перевести таблицу на `DataTable`
- [ ] Сохранить bond-specific rendering и действия
- [ ] Не менять предметную финансовую логику
- [x] Перевести таблицу на `DataTable`
- [x] Сохранить bond-specific rendering и действия
- [x] Не менять предметную финансовую логику
## Task 6: Очистка legacy и документация
- [ ] Проверить использование `shared/ui/Table`
- [ ] Проверить использование `TableSkeleton`
- [ ] Обновить docs по `DataTable` и `TanStack Table`
- [ ] Удалить legacy helper'ы, если они больше не нужны
- [x] Проверить использование `shared/ui/Table` — 0 потребителей, удалён
- [x] Проверить использование `TableSkeleton` — 1 потребитель (BrokerOperationsTable, out of scope)
- [x] Обновить docs по `DataTable` и `TanStack Table`
- [x] Удалить `shared/ui/Table` (без потребителей)
- [x] `TableSkeleton` заинлайнен в BrokerOperationsTable, оригинал удалён
## Task 7: Верификация
- [ ] Запустить frontend tests
- [ ] Запустить frontend lint
- [ ] Запустить design-system lint/tests при необходимости
- [ ] Запустить frontend и design-system build
- [ ] Проверить отсутствие новых запрещённых MUI table imports
- [x] Запустить frontend tests — 124 passed
- [x] Запустить frontend lint — 1 pre-existing error (react-hooks/exhaustive-deps not found)
- [x] Запустить design-system lint/tests — 160 tests passed, 7 pre-existing lint errors
- [x] Запустить frontend и design-system buildоба проходят
- [x] Проверить отсутствие новых запрещённых MUI table imports — 0 прямых импортов Table/TableRow/TableCell

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,286 @@
{
"0": "Community 0",
"1": "Community 1",
"2": "Community 2",
"3": "Community 3",
"4": "Community 4",
"5": "Community 5",
"6": "Community 6",
"7": "Community 7",
"8": "Community 8",
"9": "Community 9",
"10": "Community 10",
"11": "Community 11",
"12": "Community 12",
"13": "Community 13",
"14": "Community 14",
"15": "Community 15",
"16": "Community 16",
"17": "Community 17",
"18": "Community 18",
"19": "Community 19",
"20": "Community 20",
"21": "Community 21",
"22": "Community 22",
"23": "Community 23",
"24": "Community 24",
"25": "Community 25",
"26": "Community 26",
"27": "Community 27",
"28": "Community 28",
"29": "Community 29",
"30": "Community 30",
"31": "Community 31",
"32": "Community 32",
"33": "Community 33",
"34": "Community 34",
"35": "Community 35",
"36": "Community 36",
"37": "Community 37",
"38": "Community 38",
"39": "Community 39",
"40": "Community 40",
"41": "Community 41",
"42": "Community 42",
"43": "Community 43",
"44": "Community 44",
"45": "Community 45",
"46": "Community 46",
"47": "Community 47",
"48": "Community 48",
"49": "Community 49",
"50": "Community 50",
"51": "Community 51",
"52": "Community 52",
"53": "Community 53",
"54": "Community 54",
"55": "Community 55",
"56": "Community 56",
"57": "Community 57",
"58": "Community 58",
"59": "Community 59",
"60": "Community 60",
"61": "Community 61",
"62": "Community 62",
"63": "Community 63",
"64": "Community 64",
"65": "Community 65",
"66": "Community 66",
"67": "Community 67",
"68": "Community 68",
"69": "Community 69",
"70": "Community 70",
"71": "Community 71",
"72": "Community 72",
"73": "Community 73",
"74": "Community 74",
"75": "Community 75",
"76": "Community 76",
"77": "Community 77",
"78": "Community 78",
"79": "Community 79",
"80": "Community 80",
"81": "Community 81",
"82": "Community 82",
"83": "Community 83",
"84": "Community 84",
"85": "Community 85",
"86": "Community 86",
"87": "Community 87",
"88": "Community 88",
"89": "Community 89",
"90": "Community 90",
"91": "Community 91",
"92": "Community 92",
"93": "Community 93",
"94": "Community 94",
"95": "Community 95",
"96": "Community 96",
"97": "Community 97",
"98": "Community 98",
"99": "Community 99",
"100": "Community 100",
"101": "Community 101",
"102": "Community 102",
"103": "Community 103",
"104": "Community 104",
"105": "Community 105",
"106": "Community 106",
"107": "Community 107",
"108": "Community 108",
"109": "Community 109",
"110": "Community 110",
"111": "Community 111",
"112": "Community 112",
"113": "Community 113",
"114": "Community 114",
"115": "Community 115",
"116": "Community 116",
"117": "Community 117",
"118": "Community 118",
"119": "Community 119",
"120": "Community 120",
"121": "Community 121",
"122": "Community 122",
"123": "Community 123",
"124": "Community 124",
"125": "Community 125",
"126": "Community 126",
"127": "Community 127",
"128": "Community 128",
"129": "Community 129",
"130": "Community 130",
"131": "Community 131",
"132": "Community 132",
"133": "Community 133",
"134": "Community 134",
"135": "Community 135",
"136": "Community 136",
"137": "Community 137",
"138": "Community 138",
"139": "Community 139",
"140": "Community 140",
"141": "Community 141",
"142": "Community 142",
"143": "Community 143",
"144": "Community 144",
"145": "Community 145",
"146": "Community 146",
"147": "Community 147",
"148": "Community 148",
"149": "Community 149",
"150": "Community 150",
"151": "Community 151",
"152": "Community 152",
"153": "Community 153",
"154": "Community 154",
"155": "Community 155",
"156": "Community 156",
"157": "Community 157",
"158": "Community 158",
"159": "Community 159",
"160": "Community 160",
"161": "Community 161",
"162": "Community 162",
"163": "Community 163",
"164": "Community 164",
"165": "Community 165",
"166": "Community 166",
"167": "Community 167",
"168": "Community 168",
"169": "Community 169",
"170": "Community 170",
"171": "Community 171",
"172": "Community 172",
"173": "Community 173",
"174": "Community 174",
"175": "Community 175",
"176": "Community 176",
"177": "Community 177",
"178": "Community 178",
"179": "Community 179",
"180": "Community 180",
"181": "Community 181",
"182": "Community 182",
"183": "Community 183",
"184": "Community 184",
"185": "Community 185",
"186": "Community 186",
"187": "Community 187",
"188": "Community 188",
"189": "Community 189",
"190": "Community 190",
"191": "Community 191",
"192": "Community 192",
"193": "Community 193",
"194": "Community 194",
"195": "Community 195",
"196": "Community 196",
"197": "Community 197",
"198": "Community 198",
"199": "Community 199",
"200": "Community 200",
"201": "Community 201",
"202": "Community 202",
"203": "Community 203",
"204": "Community 204",
"205": "Community 205",
"206": "Community 206",
"207": "Community 207",
"208": "Community 208",
"209": "Community 209",
"210": "Community 210",
"211": "Community 211",
"212": "Community 212",
"213": "Community 213",
"214": "Community 214",
"215": "Community 215",
"216": "Community 216",
"217": "Community 217",
"218": "Community 218",
"219": "Community 219",
"220": "Community 220",
"221": "Community 221",
"222": "Community 222",
"223": "Community 223",
"224": "Community 224",
"225": "Community 225",
"226": "Community 226",
"227": "Community 227",
"228": "Community 228",
"229": "Community 229",
"230": "Community 230",
"231": "Community 231",
"232": "Community 232",
"233": "Community 233",
"234": "Community 234",
"235": "Community 235",
"236": "Community 236",
"237": "Community 237",
"238": "Community 238",
"239": "Community 239",
"240": "Community 240",
"241": "Community 241",
"242": "Community 242",
"243": "Community 243",
"244": "Community 244",
"245": "Community 245",
"246": "Community 246",
"247": "Community 247",
"248": "Community 248",
"249": "Community 249",
"250": "Community 250",
"251": "Community 251",
"252": "Community 252",
"253": "Community 253",
"254": "Community 254",
"255": "Community 255",
"256": "Community 256",
"257": "Community 257",
"258": "Community 258",
"259": "Community 259",
"260": "Community 260",
"261": "Community 261",
"262": "Community 262",
"263": "Community 263",
"264": "Community 264",
"265": "Community 265",
"266": "Community 266",
"267": "Community 267",
"268": "Community 268",
"269": "Community 269",
"270": "Community 270",
"271": "Community 271",
"272": "Community 272",
"273": "Community 273",
"274": "Community 274",
"275": "Community 275",
"276": "Community 276",
"277": "Community 277",
"278": "Community 278",
"279": "Community 279",
"280": "Community 280",
"281": "Community 281",
"282": "Community 282",
"283": "Community 283"
}

View File

@ -0,0 +1 @@
.

1381
graphify-out/GRAPH_REPORT.md Normal file

File diff suppressed because it is too large Load Diff

307
graphify-out/graph.html Normal file

File diff suppressed because one or more lines are too long

100147
graphify-out/graph.json Normal file

File diff suppressed because it is too large Load Diff

3437
graphify-out/manifest.json Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { TableCell, TableRow } from '@mui/material';
import { DataTable } from './DataTable';
import { MoexVibeThemeProvider } from '../../theme';
import { useReactTable, getCoreRowModel, createColumnHelper } from '@tanstack/react-table';
@ -65,6 +66,50 @@ function EmptyTable() {
);
}
function AlignedTable() {
const alignedColumns = [
columnHelper.accessor('name', { header: 'Name' }),
columnHelper.accessor('price', {
header: 'Price',
meta: { align: 'right' } as any,
}),
];
const table = useReactTable({
data,
columns: alignedColumns,
getCoreRowModel: getCoreRowModel(),
});
return (
<MoexVibeThemeProvider>
<DataTable table={table} caption="Aligned" />
</MoexVibeThemeProvider>
);
}
function CustomRowTable() {
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
return (
<MoexVibeThemeProvider>
<DataTable
table={table}
caption="Custom"
renderRow={(row) => (
<TableRow key={row.id} data-testid={`custom-row-${row.id}`}>
<TableCell colSpan={2}>{row.original.name}</TableCell>
</TableRow>
)}
/>
</MoexVibeThemeProvider>
);
}
describe('DataTable', () => {
it('renders caption', () => {
render(<TestTable caption="Test Caption" />);
@ -94,4 +139,25 @@ describe('DataTable', () => {
render(<EmptyTable />);
expect(screen.getByText('No data')).toBeInTheDocument();
});
it('shows loading rows instead of data when loading', () => {
render(<TestTableWithProps caption="Stocks" loading />);
expect(screen.queryByText('AAPL')).not.toBeInTheDocument();
expect(screen.getAllByRole('row')).toHaveLength(6);
});
it('applies column alignment from meta', () => {
render(<AlignedTable />);
expect(screen.getByText('Price').closest('th')).toHaveClass('MuiTableCell-alignRight');
});
it('renders custom rows when provided', () => {
render(<CustomRowTable />);
expect(screen.getByTestId('custom-row-0')).toBeInTheDocument();
expect(screen.getByTestId('custom-row-1')).toBeInTheDocument();
expect(screen.getByText('AAPL')).toBeInTheDocument();
});
});

View File

@ -6,19 +6,30 @@ import {
TableBody,
TableRow,
TableCell,
Skeleton,
type TableProps as MuiTableProps,
} from '@mui/material';
import type { Table as TanStackTable } from '@tanstack/react-table';
import type { Column, Row, RowData, Table as TanStackTable } from '@tanstack/react-table';
import { flexRender } from '@tanstack/react-table';
export type Density = 'balanced' | 'compact';
type CellAlign = 'left' | 'center' | 'right';
declare module '@tanstack/react-table' {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface ColumnMeta<TData extends RowData, TValue> {
align?: CellAlign;
}
}
export interface DataTableProps<T> {
table: TanStackTable<T>;
density?: Density;
loading?: boolean;
empty?: ReactNode;
caption: string;
renderRow?: (row: Row<T>) => ReactNode;
}
const DENSITY_PADDING: Record<Density, MuiTableProps['size']> = {
@ -32,6 +43,7 @@ export function DataTable<T>({
loading,
empty,
caption,
renderRow,
}: DataTableProps<T>) {
const rows = table.getRowModel().rows;
@ -39,6 +51,14 @@ export function DataTable<T>({
return <>{empty}</>;
}
const isLoading = Boolean(loading);
const loadingRows = Array.from({ length: 5 });
const leafColumns = table.getVisibleLeafColumns();
function getAlign(column: Column<T, unknown>) {
return column.columnDef.meta?.align;
}
return (
<TableContainer>
<Table size={DENSITY_PADDING[density]}>
@ -47,7 +67,11 @@ export function DataTable<T>({
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableCell key={header.id} sortDirection={header.column.getIsSorted() || false}>
<TableCell
key={header.id}
align={header.column.columnDef.meta?.align ?? 'left'}
sortDirection={header.column.getIsSorted() || false}
>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
@ -57,15 +81,27 @@ export function DataTable<T>({
))}
</TableHead>
<TableBody>
{rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))}
{isLoading
? loadingRows.map((_, index) => (
<TableRow key={index}>
{leafColumns.map((column) => (
<TableCell key={column.id} align={getAlign(column) ?? 'left'}>
<Skeleton height={12} width="80%" />
</TableCell>
))}
</TableRow>
))
: renderRow
? rows.map((row) => renderRow(row))
: rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} align={cell.column.columnDef.meta?.align ?? 'left'}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>