Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .jules/bolt.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,13 @@ if is_visible:
**Действие:** В вызывающем коде (в слоте вызова диалога настроек) всегда вызывать методы обновления и синхронизации динамических элементов UI (например, `self.update_skin_menu()`) как в ветке сохранения настроек, так и в ветке закрытия/отклонения диалога (блок `else`), а также принудительно сбрасывать активный скин на валидный, если текущий был удален.

## 2026-07-10 - [Forced Animation State Prevention from Early Idle Reset]
**Инсайт:** Быстрый сброс в состояние `idle` из фонового таймера-watchdog перебивает анимации мгновенных побочных эффектов, таких как «eating» или «playing», запускаемых вручную через меню трея. Метод `force_state(state, duration)` в `InputManager`, искусственно сдвигающий `last_input_time` вперед во времени, позволяет временно удержать котика в нужном состоянии на заданную длительность без создания разрозненных локальных таймеров.
**Инсайт:** Быстрый сброс в состояние `idle` из фонового таймера-watchdog перебивает анимации мгновенных побочных эффектов, таких как «eating» или «playing», запускаемых вручную через меню трея. Метод `force_state(state, duration)` in `InputManager`, искусственно сдвигающий `last_input_time` вперед во времени, позволяет временно удержать котика в нужном состоянии на заданную длительность без создания разрозненных локальных таймеров.
**Действие:** Для всех запускаемых вручную коротких состояний использовать `input_manager.force_state(state, duration=3.0)` вместо прямого вызова `play_state` в `AnimationManager`.

## 2026-07-11 - [UTC Timestamps to Local Timezone Conversion in SQLite]
**Инсайт:** Хранение меток времени в SQLite в UTC-формате (через `CURRENT_TIMESTAMP`) требует обязательной явной локализации при отображении на стороне интерфейса. Попытки ручного сдвига эпохи с использованием `time.timezone` и `time.daylight` могут приводить к ошибкам и игнорировать динамический перевод стрелок. Использование методов timezone из модуля datetime позволяет делать это нативно и безбажно.
**Действие:** Конвертировать UTC-строку из БД SQLite обратно в локальное время системы через `datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc).astimezone(None)`.

## 2026-07-12 - [Optimized Real-Time Dialogue Refreshes with Widget Persistence]
**Инсайт:** Постоянный ребилд и воссоздание сложной иерархии UI виджетов (таких как списки достижений и истории активности) по таймеру с высокой частотой приводит к утечкам памяти, скачкам CPU и прерывистой прокрутке (layout jitter). Сохранение ссылок на динамические виджеты при инициализации с последующим их селективным обновлением «in-place» гарантирует плавность интерфейса. Ограничение отрисовки вкладки истории только при её активной видимости и сравнение списков кэшированных событий экономит до 95% процессорного времени.
**Действие:** В StatsDialog применить паттерн разделения создания UI от его обновления, сохранять ссылки на экземпляры QLabel/QProgressBar в словаре `self.achievement_widgets` для in-place обновления, а историю перерисовывать лениво и только при несовпадении нового кортежа данных с кэшированным.
225 changes: 151 additions & 74 deletions src/ui/stats_dialog.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import time
from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel, QProgressBar, QPushButton, QHBoxLayout, QScrollArea, QWidget, QTabWidget
from PySide6.QtCore import Qt
from PySide6.QtCore import Qt, QTimer
from src.utils.bonding_utils import get_level_info, ACHIEVEMENTS
from datetime import datetime, timezone

Expand All @@ -26,47 +26,54 @@ def __init__(self, db, parent=None):
self.setup_history_tab(history_tab)
self.tabs.addTab(history_tab, "История")

# Инициализируем значения
self.update_progress_ui()
self.update_history_ui()

# Таймер для обновления UI в реальном времени
self.update_timer = QTimer(self)
self.update_timer.timeout.connect(self.update_ui)
self.update_timer.start(500) # Обновление каждые 500мс

# Кнопка закрытия
close_btn = QPushButton("Закрыть")
close_btn.clicked.connect(self.accept)
main_layout.addWidget(close_btn)

def update_ui(self):
self.update_progress_ui()
if self.tabs.currentIndex() == 1:
self.update_history_ui()

def setup_progress_tab(self, widget):
layout = QVBoxLayout(widget)

points = self.db.get_affection_points()
level, title, points_in_level, points_for_next_level = get_level_info(points)

# Заголовок
title_label = QLabel(f"Уровень {level}: {title}")
title_label.setAlignment(Qt.AlignCenter)
title_label.setStyleSheet("font-size: 16px; font-weight: bold; margin-bottom: 5px;")
layout.addWidget(title_label)
self.title_label = QLabel()
self.title_label.setAlignment(Qt.AlignCenter)
self.title_label.setStyleSheet("font-size: 16px; font-weight: bold; margin-bottom: 5px;")
layout.addWidget(self.title_label)

# Общие очки и KPS в одной строке
stats_row = QHBoxLayout()
points_label = QLabel(f"Всего: {points} ❤️")
points_label.setStyleSheet("font-weight: bold; font-size: 12px;")
self.points_label = QLabel()
self.points_label.setStyleSheet("font-weight: bold; font-size: 12px;")

max_kps = self.db.get_stat("max_kps")
kps_label = QLabel(f"Рекорд: {max_kps} кл/сек ⚡")
kps_label.setStyleSheet("color: #555; font-size: 11px;")
self.kps_label = QLabel()
self.kps_label.setStyleSheet("color: #555; font-size: 11px;")

stats_row.addWidget(points_label)
stats_row.addWidget(self.points_label)
stats_row.addStretch()
stats_row.addWidget(kps_label)
stats_row.addWidget(self.kps_label)
layout.addLayout(stats_row)

# Прогресс бар
if points_for_next_level > 0:
layout.addWidget(QLabel(f"До следующего уровня: {points_for_next_level - points_in_level}"))
progress = QProgressBar()
progress.setMaximum(points_for_next_level)
progress.setValue(points_in_level)
progress.setFormat("%v / %m")
layout.addWidget(progress)
else:
layout.addWidget(QLabel("Максимальный уровень достигнут! 🎉"))
self.next_level_label = QLabel()
layout.addWidget(self.next_level_label)

self.level_progress_bar = QProgressBar()
self.level_progress_bar.setFormat("%v / %m")
layout.addWidget(self.level_progress_bar)

layout.addSpacing(10)
layout.addWidget(QLabel("<b>Достижения:</b>"))
Expand All @@ -78,55 +85,24 @@ def setup_progress_tab(self, widget):
scroll_layout.setSpacing(10)
scroll_layout.setContentsMargins(5, 5, 5, 5)

unlocked = self.db.get_unlocked_achievements()
self.achievement_widgets = {}

for ach_id, ach_info in ACHIEVEMENTS.items():
ach_widget = QWidget()
ach_item_layout = QHBoxLayout(ach_widget)
ach_item_layout.setContentsMargins(0, 0, 0, 0)

is_unlocked = ach_id in unlocked
icon = ach_info['icon'] if is_unlocked else "🔒"

label_text = f"<span style='font-size: 20px;'>{icon}</span>"
item_label = QLabel(label_text)
ach_item_layout.addWidget(item_label)
icon_label = QLabel()
ach_item_layout.addWidget(icon_label)

# Расчет прогресса
progress_text = ""
if not is_unlocked and 'goal' in ach_info and 'stat' in ach_info:
if self.parent() and hasattr(self.parent(), 'input_manager') and self.parent().input_manager:
im = self.parent().input_manager
if ach_info['stat'] == 'bonding_points':
current_val = im.last_affection_points + im.pending_points
elif ach_info['stat'] == 'total_clicks':
current_val = im.total_clicks_cache + im.pending_stats.get('total_clicks', 0)
elif ach_info['stat'] == 'max_kps':
current_val = im.max_kps
else:
current_val = self.db.get_stat(ach_info['stat']) + im.pending_stats.get(ach_info['stat'], 0)
else:
current_val = self.db.get_stat(ach_info['stat'])

if ach_info['stat'] == 'level':
current_val, _, _, _ = get_level_info(points)

goal = ach_info['goal']
if goal > 0:
progress_text = f" <span style='color: #888;'>({current_val}/{goal})</span>"

info_label = QLabel(f"<b>{ach_info['title']}</b>{progress_text}<br/><small>{ach_info['desc']}</small>")
if not is_unlocked:
info_label.setStyleSheet("color: #888;")

# Добавляем прогресс-бар для закрытых достижений
ach_text_layout = QVBoxLayout()
info_label = QLabel()
ach_text_layout.addWidget(info_label)

if not is_unlocked and 'goal' in ach_info and goal > 0:
prog_bar = None
if 'goal' in ach_info and ach_info['goal'] > 0:
prog_bar = QProgressBar()
prog_bar.setMaximum(goal)
prog_bar.setValue(min(current_val, goal))
prog_bar.setMaximum(ach_info['goal'])
prog_bar.setFixedHeight(10)
prog_bar.setTextVisible(False)
prog_bar.setStyleSheet("""
Expand All @@ -147,20 +123,122 @@ def setup_progress_tab(self, widget):

scroll_layout.addWidget(ach_widget)

self.achievement_widgets[ach_id] = {
'icon_label': icon_label,
'info_label': info_label,
'prog_bar': prog_bar,
'widget': ach_widget
}

scroll.setWidget(scroll_content)
scroll.setFixedHeight(200)
layout.addWidget(scroll)

def update_progress_ui(self):
# Получаем данные о привязанности и KPS
im = None
if self.parent() and hasattr(self.parent(), 'input_manager') and self.parent().input_manager:
im = self.parent().input_manager

if im:
points = im.last_affection_points + im.pending_points
max_kps = im.max_kps
else:
points = self.db.get_affection_points()
max_kps = self.db.get_stat("max_kps")

level, title, points_in_level, points_for_next_level = get_level_info(points)

# Обновляем основные метки
self.title_label.setText(f"Уровень {level}: {title}")
self.points_label.setText(f"Всего: {points} ❤️")
self.kps_label.setText(f"Рекорд: {max_kps} кл/сек ⚡")

# Обновляем прогресс-бар уровня
if points_for_next_level > 0:
self.next_level_label.setText(f"До следующего уровня: {points_for_next_level - points_in_level}")
self.level_progress_bar.setVisible(True)
self.level_progress_bar.setMaximum(points_for_next_level)
self.level_progress_bar.setValue(points_in_level)
else:
self.next_level_label.setText("Максимальный уровень достигнут! 🎉")
self.level_progress_bar.setVisible(False)

# Получаем разблокированные достижения
if im:
unlocked = im.unlocked_achievements
else:
unlocked = self.db.get_unlocked_achievements()

# Обновляем виджеты достижений
for ach_id, ach_info in ACHIEVEMENTS.items():
ref = self.achievement_widgets[ach_id]
is_unlocked = ach_id in unlocked

# Иконка
icon = ach_info['icon'] if is_unlocked else "🔒"
ref['icon_label'].setText(f"<span style='font-size: 20px;'>{icon}</span>")

# Текущее значение прогресса
current_val = 0
if 'stat' in ach_info:
stat_name = ach_info['stat']
if stat_name == 'level':
current_val = level
elif stat_name == 'bonding_points':
current_val = points
elif stat_name == 'total_clicks':
current_val = im.total_clicks_cache + im.pending_stats.get('total_clicks', 0) if im else self.db.get_stat('total_clicks')
elif stat_name == 'max_kps':
current_val = max_kps
else:
current_val = self.db.get_stat(stat_name) + (im.pending_stats.get(stat_name, 0) if im else 0)

# Текст с прогрессом
progress_text = ""
if not is_unlocked and 'goal' in ach_info and ach_info['goal'] > 0:
progress_text = f" <span style='color: #888;'>({current_val}/{ach_info['goal']})</span>"

info_text = f"<b>{ach_info['title']}</b>{progress_text}<br/><small>{ach_info['desc']}</small>"
ref['info_label'].setText(info_text)

if is_unlocked:
ref['info_label'].setStyleSheet("")
if ref['prog_bar']:
ref['prog_bar'].setVisible(False)
else:
ref['info_label'].setStyleSheet("color: #888;")
if ref['prog_bar']:
ref['prog_bar'].setVisible(True)
ref['prog_bar'].setValue(min(current_val, ach_info['goal']))

def setup_history_tab(self, widget):
layout = QVBoxLayout(widget)

scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll_content = QWidget()
scroll_layout = QVBoxLayout(scroll_content)
scroll_layout.setAlignment(Qt.AlignTop)
self.history_scroll = QScrollArea()
self.history_scroll.setWidgetResizable(True)
self.history_content = QWidget()
self.history_layout = QVBoxLayout(self.history_content)
self.history_layout.setAlignment(Qt.AlignTop)

self.history_scroll.setWidget(self.history_content)
layout.addWidget(self.history_scroll)

self.last_events_cache = []

def update_history_ui(self):
events = self.db.get_recent_activity(30)
# Оптимизация: не перерисовываем историю, если события не изменились
if events == self.last_events_cache:
return
self.last_events_cache = events

# Очищаем старые элементы истории
while self.history_layout.count() > 0:
item = self.history_layout.takeAt(0)
widget = item.widget()
if widget:
widget.deleteLater()

type_icons = {
"app_start": "🚀",
Expand All @@ -171,7 +249,9 @@ def setup_history_tab(self, widget):
}

if not events:
scroll_layout.addWidget(QLabel("История событий пуста..."))
no_events_label = QLabel("История событий пуста...")
self.history_layout.addWidget(no_events_label)
return

for ev in events:
# ev format: (id, timestamp, event_type, description)
Expand All @@ -182,7 +262,7 @@ def setup_history_tab(self, widget):
# Конвертация UTC времени из базы в локальное время системы автоматически
dt_local = dt_utc.astimezone(None)
time_display = dt_local.strftime("%H:%M")
except:
except Exception:
time_display = ts_str

icon = type_icons.get(ev[2], "📝")
Expand All @@ -195,13 +275,10 @@ def setup_history_tab(self, widget):
label.setWordWrap(True)
item_layout.addWidget(label)

scroll_layout.addWidget(item)
self.history_layout.addWidget(item)

# Разделитель
line = QWidget()
line.setFixedHeight(1)
line.setStyleSheet("background-color: #eee;")
scroll_layout.addWidget(line)

scroll.setWidget(scroll_content)
layout.addWidget(scroll)
self.history_layout.addWidget(line)
Binary file modified verification/screenshots/stats_history.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.