Update backend and frontend components

This commit is contained in:
poignatov
2025-12-30 18:27:12 +03:00
parent 6473622977
commit a76252a198
4 changed files with 190 additions and 97 deletions

View File

@@ -192,7 +192,7 @@
<span class="status" id="dailyReportStatus" style="display: none;"></span> <span class="status" id="dailyReportStatus" style="display: none;"></span>
</h2> </h2>
<p style="margin-bottom: 15px; color: #666;"> <p style="margin-bottom: 15px; color: #666;">
Нажмите кнопку для отправки ежедневного отчёта по Score и Целям в Telegram (обычно отправляется автоматически в 11:59). Нажмите кнопку для отправки ежедневного отчёта по Score и Целям в Telegram (обычно отправляется автоматически в 23:59).
</p> </p>
<button onclick="triggerDailyReport()">Отправить отчёт</button> <button onclick="triggerDailyReport()">Отправить отчёт</button>
<div id="dailyReportResult"></div> <div id="dailyReportResult"></div>

View File

@@ -1431,20 +1431,20 @@ func (a *App) getWeeklyStatsHandler(w http.ResponseWriter, r *http.Request) {
COALESCE(wr.total_score, 0.0000) AS total_score, COALESCE(wr.total_score, 0.0000) AS total_score,
wg.min_goal_score, wg.min_goal_score,
wg.max_goal_score, wg.max_goal_score,
wg.priority COALESCE(wg.priority, p.priority) AS priority
FROM FROM
weekly_goals wg projects p
JOIN LEFT JOIN
projects p ON wg.project_id = p.id weekly_goals wg ON wg.project_id = p.id
AND wg.goal_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
AND wg.goal_week = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER
LEFT JOIN LEFT JOIN
weekly_report_mv wr weekly_report_mv wr
ON wg.project_id = wr.project_id ON p.id = wr.project_id
AND wg.goal_year = wr.report_year AND EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER = wr.report_year
AND wg.goal_week = wr.report_week AND EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER = wr.report_week
WHERE WHERE
-- Фильтруем ТОЛЬКО по целям текущего года и недели p.deleted = FALSE
wg.goal_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
AND wg.goal_week = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER
ORDER BY ORDER BY
total_score DESC total_score DESC
` `
@@ -1463,13 +1463,14 @@ func (a *App) getWeeklyStatsHandler(w http.ResponseWriter, r *http.Request) {
for rows.Next() { for rows.Next() {
var project WeeklyProjectStats var project WeeklyProjectStats
var minGoalScore sql.NullFloat64
var maxGoalScore sql.NullFloat64 var maxGoalScore sql.NullFloat64
var priority sql.NullInt64 var priority sql.NullInt64
err := rows.Scan( err := rows.Scan(
&project.ProjectName, &project.ProjectName,
&project.TotalScore, &project.TotalScore,
&project.MinGoalScore, &minGoalScore,
&maxGoalScore, &maxGoalScore,
&priority, &priority,
) )
@@ -1479,6 +1480,12 @@ func (a *App) getWeeklyStatsHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
if minGoalScore.Valid {
project.MinGoalScore = minGoalScore.Float64
} else {
project.MinGoalScore = 0
}
if maxGoalScore.Valid { if maxGoalScore.Valid {
maxGoalVal := maxGoalScore.Float64 maxGoalVal := maxGoalScore.Float64
project.MaxGoalScore = &maxGoalVal project.MaxGoalScore = &maxGoalVal
@@ -1492,7 +1499,7 @@ func (a *App) getWeeklyStatsHandler(w http.ResponseWriter, r *http.Request) {
// Расчет calculated_score по формуле из n8n // Расчет calculated_score по формуле из n8n
totalScore := project.TotalScore totalScore := project.TotalScore
minGoalScore := project.MinGoalScore minGoalScoreVal := project.MinGoalScore
var maxGoalScoreVal float64 var maxGoalScoreVal float64
if project.MaxGoalScore != nil { if project.MaxGoalScore != nil {
maxGoalScoreVal = *project.MaxGoalScore maxGoalScoreVal = *project.MaxGoalScore
@@ -1508,15 +1515,15 @@ func (a *App) getWeeklyStatsHandler(w http.ResponseWriter, r *http.Request) {
// Расчет базового прогресса // Расчет базового прогресса
var baseProgress float64 var baseProgress float64
if minGoalScore > 0 { if minGoalScoreVal > 0 {
baseProgress = (min(totalScore, minGoalScore) / minGoalScore) * 100.0 baseProgress = (min(totalScore, minGoalScoreVal) / minGoalScoreVal) * 100.0
} }
// Расчет экстра прогресса // Расчет экстра прогресса
var extraProgress float64 var extraProgress float64
denominator := maxGoalScoreVal - minGoalScore denominator := maxGoalScoreVal - minGoalScoreVal
if denominator > 0 && totalScore > minGoalScore { if denominator > 0 && totalScore > minGoalScoreVal {
excess := min(totalScore, maxGoalScoreVal) - minGoalScore excess := min(totalScore, maxGoalScoreVal) - minGoalScoreVal
extraProgress = (excess / denominator) * extraBonusLimit extraProgress = (excess / denominator) * extraBonusLimit
} }
@@ -1524,23 +1531,45 @@ func (a *App) getWeeklyStatsHandler(w http.ResponseWriter, r *http.Request) {
project.CalculatedScore = roundToTwoDecimals(resultScore) project.CalculatedScore = roundToTwoDecimals(resultScore)
// Группировка для итогового расчета // Группировка для итогового расчета
// Проекты с minGoal = 0 или null не учитываются в общем проценте выполнения
if minGoalScoreVal > 0 {
if _, exists := groups[priorityVal]; !exists { if _, exists := groups[priorityVal]; !exists {
groups[priorityVal] = make([]float64, 0) groups[priorityVal] = make([]float64, 0)
} }
groups[priorityVal] = append(groups[priorityVal], project.CalculatedScore) groups[priorityVal] = append(groups[priorityVal], project.CalculatedScore)
}
projects = append(projects, project) projects = append(projects, project)
} }
// Находим среднее внутри каждой группы // Находим среднее внутри каждой группы
groupAverages := make([]float64, 0) groupAverages := make([]float64, 0)
for _, scores := range groups { for priorityVal, scores := range groups {
if len(scores) > 0 { if len(scores) > 0 {
var avg float64
// Для приоритета 1 и 2 - обычное среднее (как было)
if priorityVal == 1 || priorityVal == 2 {
sum := 0.0 sum := 0.0
for _, score := range scores { for _, score := range scores {
sum += score sum += score
} }
avg := sum / float64(len(scores)) avg = sum / float64(len(scores))
} else {
// Для проектов без приоритета (priorityVal == 0) - новая формула
projectCount := float64(len(scores))
multiplier := 100.0 / math.Floor(projectCount * 0.8)
sum := 0.0
for _, score := range scores {
// score уже в процентах (например, 80.0), переводим в долю (0.8)
scoreAsDecimal := score / 100.0
sum += scoreAsDecimal * multiplier
}
avg = math.Min(120.0, sum)
}
groupAverages = append(groupAverages, avg) groupAverages = append(groupAverages, avg)
} }
} }
@@ -1926,23 +1955,32 @@ func (a *App) initPlayLifeDB() error {
func (a *App) startWeeklyGoalsScheduler() { func (a *App) startWeeklyGoalsScheduler() {
// Получаем часовой пояс из переменной окружения (по умолчанию UTC) // Получаем часовой пояс из переменной окружения (по умолчанию UTC)
timezoneStr := getEnv("TIMEZONE", "UTC") timezoneStr := getEnv("TIMEZONE", "UTC")
log.Printf("Loading timezone for weekly goals scheduler: '%s'", timezoneStr)
// Загружаем часовой пояс // Загружаем часовой пояс
loc, err := time.LoadLocation(timezoneStr) loc, err := time.LoadLocation(timezoneStr)
if err != nil { if err != nil {
log.Printf("Warning: Invalid timezone '%s': %v. Using UTC instead.", timezoneStr, err) log.Printf("Warning: Invalid timezone '%s': %v. Using UTC instead.", timezoneStr, err)
log.Printf("Note: Timezone must be in IANA format (e.g., 'Europe/Moscow', 'America/New_York'), not 'UTC+3'")
loc = time.UTC loc = time.UTC
timezoneStr = "UTC"
} else { } else {
log.Printf("Scheduler timezone set to: %s", timezoneStr) log.Printf("Weekly goals scheduler timezone set to: %s", timezoneStr)
} }
// Логируем текущее время в указанном часовом поясе для проверки
now := time.Now().In(loc)
log.Printf("Current time in scheduler timezone (%s): %s", timezoneStr, now.Format("2006-01-02 15:04:05 MST"))
log.Printf("Next weekly goals setup will be on Monday at: 06:00 %s (cron: '0 6 * * 1')", timezoneStr)
// Создаем планировщик с указанным часовым поясом // Создаем планировщик с указанным часовым поясом
c := cron.New(cron.WithLocation(loc)) c := cron.New(cron.WithLocation(loc))
// Добавляем задачу: каждый понедельник в 6:00 утра // Добавляем задачу: каждый понедельник в 6:00 утра
// Cron выражение: "0 6 * * 1" означает: минута=0, час=6, любой день месяца, любой месяц, понедельник (1) // Cron выражение: "0 6 * * 1" означает: минута=0, час=6, любой день месяца, любой месяц, понедельник (1)
_, err = c.AddFunc("0 6 * * 1", func() { _, err = c.AddFunc("0 6 * * 1", func() {
log.Printf("Scheduled task: Setting up weekly goals (timezone: %s)", timezoneStr) now := time.Now().In(loc)
log.Printf("Scheduled task: Setting up weekly goals (timezone: %s, local time: %s)", timezoneStr, now.Format("2006-01-02 15:04:05 MST"))
if err := a.setupWeeklyGoals(); err != nil { if err := a.setupWeeklyGoals(); err != nil {
log.Printf("Error in scheduled weekly goals setup: %v", err) log.Printf("Error in scheduled weekly goals setup: %v", err)
} }
@@ -1955,7 +1993,7 @@ func (a *App) startWeeklyGoalsScheduler() {
// Запускаем планировщик // Запускаем планировщик
c.Start() c.Start()
log.Println("Weekly goals scheduler started: every Monday at 6:00 AM") log.Printf("Weekly goals scheduler started: every Monday at 6:00 AM %s", timezoneStr)
// Планировщик будет работать в фоновом режиме // Планировщик будет работать в фоновом режиме
} }
@@ -1976,21 +2014,20 @@ func (a *App) getWeeklyStatsData() (*WeeklyStatsResponse, error) {
COALESCE(wr.total_score, 0.0000) AS total_score, COALESCE(wr.total_score, 0.0000) AS total_score,
wg.min_goal_score, wg.min_goal_score,
wg.max_goal_score, wg.max_goal_score,
wg.priority COALESCE(wg.priority, p.priority) AS priority
FROM FROM
weekly_goals wg projects p
JOIN LEFT JOIN
projects p ON wg.project_id = p.id weekly_goals wg ON wg.project_id = p.id
AND wg.goal_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
AND wg.goal_week = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER
LEFT JOIN LEFT JOIN
weekly_report_mv wr weekly_report_mv wr
ON wg.project_id = wr.project_id ON p.id = wr.project_id
AND wg.goal_year = wr.report_year AND EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER = wr.report_year
AND wg.goal_week = wr.report_week AND EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER = wr.report_week
WHERE WHERE
-- Фильтруем ТОЛЬКО по целям текущего года и недели p.deleted = FALSE
wg.goal_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
AND wg.goal_week = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER
AND p.deleted = FALSE
ORDER BY ORDER BY
total_score DESC total_score DESC
` `
@@ -2008,13 +2045,14 @@ func (a *App) getWeeklyStatsData() (*WeeklyStatsResponse, error) {
for rows.Next() { for rows.Next() {
var project WeeklyProjectStats var project WeeklyProjectStats
var minGoalScore sql.NullFloat64
var maxGoalScore sql.NullFloat64 var maxGoalScore sql.NullFloat64
var priority sql.NullInt64 var priority sql.NullInt64
err := rows.Scan( err := rows.Scan(
&project.ProjectName, &project.ProjectName,
&project.TotalScore, &project.TotalScore,
&project.MinGoalScore, &minGoalScore,
&maxGoalScore, &maxGoalScore,
&priority, &priority,
) )
@@ -2023,6 +2061,12 @@ func (a *App) getWeeklyStatsData() (*WeeklyStatsResponse, error) {
return nil, fmt.Errorf("error scanning weekly stats row: %w", err) return nil, fmt.Errorf("error scanning weekly stats row: %w", err)
} }
if minGoalScore.Valid {
project.MinGoalScore = minGoalScore.Float64
} else {
project.MinGoalScore = 0
}
if maxGoalScore.Valid { if maxGoalScore.Valid {
maxGoalVal := maxGoalScore.Float64 maxGoalVal := maxGoalScore.Float64
project.MaxGoalScore = &maxGoalVal project.MaxGoalScore = &maxGoalVal
@@ -2036,7 +2080,7 @@ func (a *App) getWeeklyStatsData() (*WeeklyStatsResponse, error) {
// Расчет calculated_score по формуле из n8n // Расчет calculated_score по формуле из n8n
totalScore := project.TotalScore totalScore := project.TotalScore
minGoalScore := project.MinGoalScore minGoalScoreVal := project.MinGoalScore
var maxGoalScoreVal float64 var maxGoalScoreVal float64
if project.MaxGoalScore != nil { if project.MaxGoalScore != nil {
maxGoalScoreVal = *project.MaxGoalScore maxGoalScoreVal = *project.MaxGoalScore
@@ -2052,15 +2096,15 @@ func (a *App) getWeeklyStatsData() (*WeeklyStatsResponse, error) {
// Расчет базового прогресса // Расчет базового прогресса
var baseProgress float64 var baseProgress float64
if minGoalScore > 0 { if minGoalScoreVal > 0 {
baseProgress = (min(totalScore, minGoalScore) / minGoalScore) * 100.0 baseProgress = (min(totalScore, minGoalScoreVal) / minGoalScoreVal) * 100.0
} }
// Расчет экстра прогресса // Расчет экстра прогресса
var extraProgress float64 var extraProgress float64
denominator := maxGoalScoreVal - minGoalScore denominator := maxGoalScoreVal - minGoalScoreVal
if denominator > 0 && totalScore > minGoalScore { if denominator > 0 && totalScore > minGoalScoreVal {
excess := min(totalScore, maxGoalScoreVal) - minGoalScore excess := min(totalScore, maxGoalScoreVal) - minGoalScoreVal
extraProgress = (excess / denominator) * extraBonusLimit extraProgress = (excess / denominator) * extraBonusLimit
} }
@@ -2068,23 +2112,45 @@ func (a *App) getWeeklyStatsData() (*WeeklyStatsResponse, error) {
project.CalculatedScore = roundToTwoDecimals(resultScore) project.CalculatedScore = roundToTwoDecimals(resultScore)
// Группировка для итогового расчета // Группировка для итогового расчета
// Проекты с minGoal = 0 или null не учитываются в общем проценте выполнения
if minGoalScoreVal > 0 {
if _, exists := groups[priorityVal]; !exists { if _, exists := groups[priorityVal]; !exists {
groups[priorityVal] = make([]float64, 0) groups[priorityVal] = make([]float64, 0)
} }
groups[priorityVal] = append(groups[priorityVal], project.CalculatedScore) groups[priorityVal] = append(groups[priorityVal], project.CalculatedScore)
}
projects = append(projects, project) projects = append(projects, project)
} }
// Находим среднее внутри каждой группы // Находим среднее внутри каждой группы
groupAverages := make([]float64, 0) groupAverages := make([]float64, 0)
for _, scores := range groups { for priorityVal, scores := range groups {
if len(scores) > 0 { if len(scores) > 0 {
var avg float64
// Для приоритета 1 и 2 - обычное среднее (как было)
if priorityVal == 1 || priorityVal == 2 {
sum := 0.0 sum := 0.0
for _, score := range scores { for _, score := range scores {
sum += score sum += score
} }
avg := sum / float64(len(scores)) avg = sum / float64(len(scores))
} else {
// Для проектов без приоритета (priorityVal == 0) - новая формула
projectCount := float64(len(scores))
multiplier := 100.0 / math.Floor(projectCount * 0.8)
sum := 0.0
for _, score := range scores {
// score уже в процентах (например, 80.0), переводим в долю (0.8)
scoreAsDecimal := score / 100.0
sum += scoreAsDecimal * multiplier
}
avg = math.Min(120.0, sum)
}
groupAverages = append(groupAverages, avg) groupAverages = append(groupAverages, avg)
} }
} }
@@ -2191,27 +2257,36 @@ func (a *App) sendDailyReport() error {
} }
// startDailyReportScheduler запускает планировщик для ежедневного отчета // startDailyReportScheduler запускает планировщик для ежедневного отчета
// каждый день в 11:59 в указанном часовом поясе // каждый день в 23:59 в указанном часовом поясе
func (a *App) startDailyReportScheduler() { func (a *App) startDailyReportScheduler() {
// Получаем часовой пояс из переменной окружения (по умолчанию UTC) // Получаем часовой пояс из переменной окружения (по умолчанию UTC)
timezoneStr := getEnv("TIMEZONE", "UTC") timezoneStr := getEnv("TIMEZONE", "UTC")
log.Printf("Loading timezone for daily report scheduler: '%s'", timezoneStr)
// Загружаем часовой пояс // Загружаем часовой пояс
loc, err := time.LoadLocation(timezoneStr) loc, err := time.LoadLocation(timezoneStr)
if err != nil { if err != nil {
log.Printf("Warning: Invalid timezone '%s': %v. Using UTC instead.", timezoneStr, err) log.Printf("Warning: Invalid timezone '%s': %v. Using UTC instead.", timezoneStr, err)
log.Printf("Note: Timezone must be in IANA format (e.g., 'Europe/Moscow', 'America/New_York'), not 'UTC+3'")
loc = time.UTC loc = time.UTC
timezoneStr = "UTC"
} else { } else {
log.Printf("Daily report scheduler timezone set to: %s", timezoneStr) log.Printf("Daily report scheduler timezone set to: %s", timezoneStr)
} }
// Логируем текущее время в указанном часовом поясе для проверки
now := time.Now().In(loc)
log.Printf("Current time in scheduler timezone (%s): %s", timezoneStr, now.Format("2006-01-02 15:04:05 MST"))
log.Printf("Next daily report will be sent at: 23:59 %s (cron: '59 23 * * *')", timezoneStr)
// Создаем планировщик с указанным часовым поясом // Создаем планировщик с указанным часовым поясом
c := cron.New(cron.WithLocation(loc)) c := cron.New(cron.WithLocation(loc))
// Добавляем задачу: каждый день в 11:59 // Добавляем задачу: каждый день в 23:59
// Cron выражение: "59 11 * * *" означает: минута=59, час=11, любой день месяца, любой месяц, любой день недели // Cron выражение: "59 23 * * *" означает: минута=59, час=23, любой день месяца, любой месяц, любой день недели
_, err = c.AddFunc("59 11 * * *", func() { _, err = c.AddFunc("59 23 * * *", func() {
log.Printf("Scheduled task: Sending daily report (timezone: %s)", timezoneStr) now := time.Now().In(loc)
log.Printf("Scheduled task: Sending daily report (timezone: %s, local time: %s)", timezoneStr, now.Format("2006-01-02 15:04:05 MST"))
if err := a.sendDailyReport(); err != nil { if err := a.sendDailyReport(); err != nil {
log.Printf("Error in scheduled daily report: %v", err) log.Printf("Error in scheduled daily report: %v", err)
} }
@@ -2224,7 +2299,7 @@ func (a *App) startDailyReportScheduler() {
// Запускаем планировщик // Запускаем планировщик
c.Start() c.Start()
log.Println("Daily report scheduler started: every day at 11:59 AM") log.Printf("Daily report scheduler started: every day at 23:59 %s", timezoneStr)
// Планировщик будет работать в фоновом режиме // Планировщик будет работать в фоновом режиме
} }
@@ -2331,7 +2406,7 @@ func main() {
// Запускаем планировщик для автоматической фиксации целей на неделю // Запускаем планировщик для автоматической фиксации целей на неделю
app.startWeeklyGoalsScheduler() app.startWeeklyGoalsScheduler()
// Запускаем планировщик для ежедневного отчета в 11:59 // Запускаем планировщик для ежедневного отчета в 23:59
app.startDailyReportScheduler() app.startDailyReportScheduler()
r := mux.NewRouter() r := mux.NewRouter()
@@ -2876,7 +2951,7 @@ func (a *App) setupWeeklyGoals() error {
EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER AS c_week EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER AS c_week
), ),
goal_metrics AS ( goal_metrics AS (
-- Считаем медиану на основе последних 12 записей из вьюхи -- Считаем медиану на основе данных за 3 месяца (12 недель), исключая текущую неделю
SELECT SELECT
project_id, project_id,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY total_score) AS median_score PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY total_score) AS median_score
@@ -2884,11 +2959,19 @@ func (a *App) setupWeeklyGoals() error {
SELECT SELECT
project_id, project_id,
total_score, total_score,
report_year,
report_week,
-- Нумеруем недели от новых к старым -- Нумеруем недели от новых к старым
ROW_NUMBER() OVER (PARTITION BY project_id ORDER BY report_year DESC, report_week DESC) as rn ROW_NUMBER() OVER (PARTITION BY project_id ORDER BY report_year DESC, report_week DESC) as rn
FROM weekly_report_mv FROM weekly_report_mv
WHERE
-- Исключаем текущую неделю и все будущие недели
-- Используем сравнение (year, week) < (current_year, current_week) для корректного исключения
(report_year < EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER)
OR (report_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
AND report_week < EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER)
) sub ) sub
WHERE rn <= 12 -- Берем историю за последние 12 недель активности WHERE rn <= 12 -- Берем историю за последние 12 недель (3 месяца), исключая текущую неделю
GROUP BY project_id GROUP BY project_id
) )
INSERT INTO weekly_goals ( INSERT INTO weekly_goals (
@@ -2903,13 +2986,15 @@ func (a *App) setupWeeklyGoals() error {
p.id, p.id,
ci.c_year, ci.c_year,
ci.c_week, ci.c_week,
-- Если нет данных (gm.median_score IS NULL), используем 0 (значение по умолчанию)
COALESCE(gm.median_score, 0) AS min_goal_score, COALESCE(gm.median_score, 0) AS min_goal_score,
-- Логика max_score в зависимости от приоритета -- Логика max_score в зависимости от приоритета (только если есть данные)
CASE CASE
WHEN p.priority = 1 THEN COALESCE(gm.median_score, 0) * 1.5 WHEN gm.median_score IS NULL THEN NULL
WHEN p.priority = 2 THEN COALESCE(gm.median_score, 0) * 1.3 WHEN p.priority = 1 THEN gm.median_score * 1.5
ELSE COALESCE(gm.median_score, 0) * 1.2 WHEN p.priority = 2 THEN gm.median_score * 1.3
END + (CASE WHEN COALESCE(gm.median_score, 0) = 0 THEN 10 ELSE 0 END) AS max_goal_score, ELSE gm.median_score * 1.2
END AS max_goal_score,
p.priority p.priority
FROM projects p FROM projects p
CROSS JOIN current_info ci CROSS JOIN current_info ci

View File

@@ -228,7 +228,7 @@ function TestConfigSelection({ onNavigate, refreshTrigger = 0 }) {
</div> </div>
))} ))}
<button <button
onClick={() => onNavigate?.('words', { isNewDictionary: true })} onClick={() => onNavigate?.('words', { dictionaryId: null, isNewDictionary: true })}
className="add-dictionary-button" className="add-dictionary-button"
> >
<div className="add-config-icon">+</div> <div className="add-config-icon">+</div>

View File

@@ -11,36 +11,45 @@ function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger =
const [dictionaryName, setDictionaryName] = useState('') const [dictionaryName, setDictionaryName] = useState('')
const [originalDictionaryName, setOriginalDictionaryName] = useState('') const [originalDictionaryName, setOriginalDictionaryName] = useState('')
const [isSavingName, setIsSavingName] = useState(false) const [isSavingName, setIsSavingName] = useState(false)
const [currentDictionaryId, setCurrentDictionaryId] = useState(dictionaryId) // Normalize undefined to null for clarity: new dictionary if dictionaryId is null or undefined
const [isNewDict, setIsNewDict] = useState(isNewDictionary) const [currentDictionaryId, setCurrentDictionaryId] = useState(dictionaryId ?? null)
// isNewDict is computed from currentDictionaryId: new dictionary if currentDictionaryId == null
const isNewDict = currentDictionaryId == null
// Helper function to check if dictionary exists and is not new
const hasValidDictionary = (dictId) => {
return dictId !== undefined && dictId !== null
}
useEffect(() => { useEffect(() => {
setCurrentDictionaryId(dictionaryId) // Normalize undefined to null: if dictionaryId is undefined, treat it as null (new dictionary)
setIsNewDict(isNewDictionary) const normalizedDictionaryId = dictionaryId ?? null
setCurrentDictionaryId(normalizedDictionaryId)
if (isNewDictionary) { if (normalizedDictionaryId == null) {
setLoading(false) setLoading(false)
setDictionary(null) setDictionary(null)
setDictionaryName('') setDictionaryName('')
setOriginalDictionaryName('') setOriginalDictionaryName('')
setWords([]) setWords([])
} else if (dictionaryId !== undefined && dictionaryId !== null) { } else if (hasValidDictionary(normalizedDictionaryId)) {
fetchDictionary() fetchDictionary(normalizedDictionaryId)
fetchWords() fetchWords(normalizedDictionaryId)
} else { } else {
setLoading(false) setLoading(false)
setWords([]) setWords([])
} }
}, [dictionaryId, isNewDictionary, refreshTrigger]) }, [dictionaryId, refreshTrigger])
const fetchDictionary = async () => { const fetchDictionary = async (dictId) => {
try { try {
const response = await fetch(`${API_URL}/dictionaries`) const response = await fetch(`${API_URL}/dictionaries`)
if (!response.ok) { if (!response.ok) {
throw new Error('Ошибка при загрузке словарей') throw new Error('Ошибка при загрузке словарей')
} }
const dictionaries = await response.json() const dictionaries = await response.json()
const dict = dictionaries.find(d => d.id === dictionaryId) const dict = dictionaries.find(d => d.id === dictId)
if (dict) { if (dict) {
setDictionary(dict) setDictionary(dict)
setDictionaryName(dict.name) setDictionaryName(dict.name)
@@ -51,14 +60,14 @@ function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger =
} }
} }
const fetchWords = async () => { const fetchWords = async (dictId) => {
if (isNewDictionary || dictionaryId === undefined || dictionaryId === null) { if (!hasValidDictionary(dictId)) {
setWords([]) setWords([])
setLoading(false) setLoading(false)
return return
} }
await fetchWordsForDictionary(dictionaryId) await fetchWordsForDictionary(dictId)
} }
const fetchWordsForDictionary = async (dictId) => { const fetchWordsForDictionary = async (dictId) => {
@@ -91,7 +100,7 @@ function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger =
setIsSavingName(true) setIsSavingName(true)
try { try {
if (isNewDictionary) { if (!hasValidDictionary(currentDictionaryId)) {
// Create new dictionary // Create new dictionary
const response = await fetch(`${API_URL}/dictionaries`, { const response = await fetch(`${API_URL}/dictionaries`, {
method: 'POST', method: 'POST',
@@ -108,21 +117,21 @@ function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger =
const newDict = await response.json() const newDict = await response.json()
const newDictionaryId = newDict.id const newDictionaryId = newDict.id
// Update local state immediately // Update local state
setOriginalDictionaryName(newDict.name) setOriginalDictionaryName(newDict.name)
setDictionaryName(newDict.name) setDictionaryName(newDict.name)
setDictionary(newDict) setDictionary(newDict)
setCurrentDictionaryId(newDictionaryId) setCurrentDictionaryId(newDictionaryId)
setIsNewDict(false)
// Fetch words for the new dictionary // Reinitialize screen: fetch dictionary info and words for the new dictionary
await fetchDictionary(newDictionaryId)
await fetchWordsForDictionary(newDictionaryId) await fetchWordsForDictionary(newDictionaryId)
// Update navigation to use the new dictionary ID and remove isNewDictionary flag // Update navigation to use the new dictionary ID
onNavigate?.('words', { dictionaryId: newDictionaryId, isNewDictionary: false }) onNavigate?.('words', { dictionaryId: newDictionaryId })
} else if (dictionaryId !== undefined && dictionaryId !== null) { } else if (hasValidDictionary(currentDictionaryId)) {
// Update existing dictionary // Update existing dictionary (rename)
const response = await fetch(`${API_URL}/dictionaries/${dictionaryId}`, { const response = await fetch(`${API_URL}/dictionaries/${currentDictionaryId}`, {
method: 'PUT', method: 'PUT',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -146,6 +155,7 @@ function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger =
} }
} }
// Show save button only if name is not empty and has changed
const showSaveButton = dictionaryName.trim() !== '' && dictionaryName.trim() !== originalDictionaryName const showSaveButton = dictionaryName.trim() !== '' && dictionaryName.trim() !== originalDictionaryName
if (loading) { if (loading) {
@@ -195,10 +205,8 @@ function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger =
)} )}
</div> </div>
{/* Show add button and words list: {/* Show add button and words list only if dictionaryId exists and is not new */}
- If dictionary exists (has dictionaryId), show regardless of name {hasValidDictionary(currentDictionaryId) && (
- If new dictionary (no dictionaryId), show only if name is set */}
{((currentDictionaryId !== undefined && currentDictionaryId !== null && !isNewDict) || (isNewDict && dictionaryName.trim())) && (
<> <>
{(!words || words.length === 0) ? ( {(!words || words.length === 0) ? (
<> <>