Compare commits

..

3 Commits

Author SHA1 Message Date
poignatov
8a036df1b4 4.24.1: Фильтрация проектов в отслеживании
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m50s
2026-02-06 15:22:09 +03:00
poignatov
65f21cd025 4.24.0: Удаление и сброс прогресса слов 2026-02-06 15:16:58 +03:00
poignatov
a76d1d40cb 4.23.1: Исправлен сброс дневных баллов в 0:00 2026-02-06 14:48:39 +03:00
5 changed files with 338 additions and 8 deletions

View File

@@ -1 +1 @@
4.23.0 4.24.1

View File

@@ -1543,6 +1543,129 @@ func (a *App) addWordsHandler(w http.ResponseWriter, r *http.Request) {
}) })
} }
func (a *App) deleteWordHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
setCORSHeaders(w)
w.WriteHeader(http.StatusOK)
return
}
userID, ok := getUserIDFromContext(r)
if !ok {
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
return
}
vars := mux.Vars(r)
wordID, err := strconv.Atoi(vars["id"])
if err != nil {
sendErrorWithCORS(w, "Invalid word ID", http.StatusBadRequest)
return
}
// Verify ownership - check that word belongs to user
var ownerID int
err = a.DB.QueryRow("SELECT user_id FROM words WHERE id = $1", wordID).Scan(&ownerID)
if err == sql.ErrNoRows {
sendErrorWithCORS(w, "Word not found", http.StatusNotFound)
return
}
if err != nil {
log.Printf("Error checking word ownership: %v", err)
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
return
}
if ownerID != userID {
sendErrorWithCORS(w, "Word not found", http.StatusNotFound)
return
}
// Delete the word (progress will be deleted automatically due to CASCADE)
result, err := a.DB.Exec("DELETE FROM words WHERE id = $1", wordID)
if err != nil {
log.Printf("Error deleting word: %v", err)
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
return
}
rowsAffected, err := result.RowsAffected()
if err != nil {
log.Printf("Error getting rows affected: %v", err)
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
return
}
if rowsAffected == 0 {
sendErrorWithCORS(w, "Word not found", http.StatusNotFound)
return
}
setCORSHeaders(w)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"message": "Word deleted successfully",
})
}
func (a *App) resetWordProgressHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
setCORSHeaders(w)
w.WriteHeader(http.StatusOK)
return
}
userID, ok := getUserIDFromContext(r)
if !ok {
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
return
}
vars := mux.Vars(r)
wordID, err := strconv.Atoi(vars["id"])
if err != nil {
sendErrorWithCORS(w, "Invalid word ID", http.StatusBadRequest)
return
}
// Verify ownership - check that word belongs to user
var ownerID int
err = a.DB.QueryRow("SELECT user_id FROM words WHERE id = $1", wordID).Scan(&ownerID)
if err == sql.ErrNoRows {
sendErrorWithCORS(w, "Word not found", http.StatusNotFound)
return
}
if err != nil {
log.Printf("Error checking word ownership: %v", err)
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
return
}
if ownerID != userID {
sendErrorWithCORS(w, "Word not found", http.StatusNotFound)
return
}
// Reset progress for this word and user
_, err = a.DB.Exec(`
UPDATE progress
SET success = 0,
failure = 0,
last_success_at = NULL,
last_failure_at = NULL
WHERE word_id = $1 AND user_id = $2
`, wordID, userID)
if err != nil {
log.Printf("Error resetting word progress: %v", err)
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
return
}
setCORSHeaders(w)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"message": "Word progress reset successfully",
})
}
func (a *App) getTestWordsHandler(w http.ResponseWriter, r *http.Request) { func (a *App) getTestWordsHandler(w http.ResponseWriter, r *http.Request) {
log.Printf("getTestWordsHandler called: %s %s", r.Method, r.URL.Path) log.Printf("getTestWordsHandler called: %s %s", r.Method, r.URL.Path)
setCORSHeaders(w) setCORSHeaders(w)
@@ -3068,6 +3191,20 @@ func (a *App) getCurrentWeekScores(userID int) (map[int]float64, error) {
// getTodayScores получает сумму score всех нод, созданных сегодня для конкретного пользователя // getTodayScores получает сумму score всех нод, созданных сегодня для конкретного пользователя
// Возвращает map[project_id]today_score для сегодняшнего дня // Возвращает map[project_id]today_score для сегодняшнего дня
func (a *App) getTodayScores(userID int) (map[int]float64, error) { func (a *App) getTodayScores(userID int) (map[int]float64, error) {
// Получаем часовой пояс из переменной окружения (по умолчанию UTC)
timezoneStr := getEnv("TIMEZONE", "UTC")
loc, err := time.LoadLocation(timezoneStr)
if err != nil {
log.Printf("Warning: Invalid timezone '%s': %v. Using UTC instead.", timezoneStr, err)
loc = time.UTC
timezoneStr = "UTC"
}
// Вычисляем текущую дату в нужном часовом поясе
now := time.Now().In(loc)
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
todayEnd := todayStart.Add(24 * time.Hour)
query := ` query := `
SELECT SELECT
n.project_id, n.project_id,
@@ -3078,11 +3215,12 @@ func (a *App) getTodayScores(userID int) (map[int]float64, error) {
p.deleted = FALSE p.deleted = FALSE
AND p.user_id = $1 AND p.user_id = $1
AND n.user_id = $1 AND n.user_id = $1
AND DATE(n.created_date) = CURRENT_DATE AND n.created_date >= $2
AND n.created_date < $3
GROUP BY n.project_id GROUP BY n.project_id
` `
rows, err := a.DB.Query(query, userID) rows, err := a.DB.Query(query, userID, todayStart, todayEnd)
if err != nil { if err != nil {
log.Printf("Error querying today scores: %v", err) log.Printf("Error querying today scores: %v", err)
return nil, fmt.Errorf("error querying today scores: %w", err) return nil, fmt.Errorf("error querying today scores: %w", err)
@@ -3106,6 +3244,20 @@ func (a *App) getTodayScores(userID int) (map[int]float64, error) {
// getTodayScoresAllUsers получает сумму score всех нод, созданных сегодня для всех пользователей // getTodayScoresAllUsers получает сумму score всех нод, созданных сегодня для всех пользователей
// Возвращает map[project_id]today_score для сегодняшнего дня // Возвращает map[project_id]today_score для сегодняшнего дня
func (a *App) getTodayScoresAllUsers() (map[int]float64, error) { func (a *App) getTodayScoresAllUsers() (map[int]float64, error) {
// Получаем часовой пояс из переменной окружения (по умолчанию UTC)
timezoneStr := getEnv("TIMEZONE", "UTC")
loc, err := time.LoadLocation(timezoneStr)
if err != nil {
log.Printf("Warning: Invalid timezone '%s': %v. Using UTC instead.", timezoneStr, err)
loc = time.UTC
timezoneStr = "UTC"
}
// Вычисляем текущую дату в нужном часовом поясе
now := time.Now().In(loc)
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
todayEnd := todayStart.Add(24 * time.Hour)
query := ` query := `
SELECT SELECT
n.project_id, n.project_id,
@@ -3114,11 +3266,12 @@ func (a *App) getTodayScoresAllUsers() (map[int]float64, error) {
JOIN projects p ON n.project_id = p.id JOIN projects p ON n.project_id = p.id
WHERE WHERE
p.deleted = FALSE p.deleted = FALSE
AND DATE(n.created_date) = CURRENT_DATE AND n.created_date >= $1
AND n.created_date < $2
GROUP BY n.project_id GROUP BY n.project_id
` `
rows, err := a.DB.Query(query) rows, err := a.DB.Query(query, todayStart, todayEnd)
if err != nil { if err != nil {
log.Printf("Error querying today scores for all users: %v", err) log.Printf("Error querying today scores for all users: %v", err)
return nil, fmt.Errorf("error querying today scores for all users: %w", err) return nil, fmt.Errorf("error querying today scores for all users: %w", err)
@@ -3977,6 +4130,8 @@ func main() {
// Words & dictionaries // Words & dictionaries
protected.HandleFunc("/api/words", app.getWordsHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/words", app.getWordsHandler).Methods("GET", "OPTIONS")
protected.HandleFunc("/api/words", app.addWordsHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/api/words", app.addWordsHandler).Methods("POST", "OPTIONS")
protected.HandleFunc("/api/words/{id}", app.deleteWordHandler).Methods("DELETE", "OPTIONS")
protected.HandleFunc("/api/words/{id}/reset-progress", app.resetWordProgressHandler).Methods("POST", "OPTIONS")
protected.HandleFunc("/api/test/words", app.getTestWordsHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/test/words", app.getTestWordsHandler).Methods("GET", "OPTIONS")
protected.HandleFunc("/api/test/progress", app.updateTestProgressHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/api/test/progress", app.updateTestProgressHandler).Methods("POST", "OPTIONS")
protected.HandleFunc("/api/dictionaries", app.getDictionariesHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/dictionaries", app.getDictionariesHandler).Methods("GET", "OPTIONS")
@@ -14738,7 +14893,7 @@ func (a *App) getWeeklyStatsDataForUserAndWeek(userID int, year int, week int) (
p.color p.color
FROM FROM
projects p projects p
LEFT JOIN INNER JOIN
weekly_goals wg ON wg.project_id = p.id weekly_goals wg ON wg.project_id = p.id
AND wg.goal_year = $2 AND wg.goal_year = $2
AND wg.goal_week = $3 AND wg.goal_week = $3
@@ -14749,6 +14904,7 @@ func (a *App) getWeeklyStatsDataForUserAndWeek(userID int, year int, week int) (
AND $3 = wr.report_week AND $3 = wr.report_week
WHERE WHERE
p.deleted = FALSE AND p.user_id = $1 p.deleted = FALSE AND p.user_id = $1
AND wg.min_goal_score IS NOT NULL
ORDER BY ORDER BY
total_score DESC total_score DESC
` `

View File

@@ -1,6 +1,6 @@
{ {
"name": "play-life-web", "name": "play-life-web",
"version": "4.23.0", "version": "4.24.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -94,6 +94,7 @@
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 1.5rem; gap: 1.5rem;
cursor: pointer;
} }
.word-card:hover { .word-card:hover {
@@ -248,3 +249,96 @@
transform: scale(1.1); transform: scale(1.1);
} }
.word-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.word-modal {
background: white;
border-radius: 12px;
padding: 0;
max-width: 400px;
width: 90%;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
animation: modalSlideIn 0.2s ease-out;
}
@keyframes modalSlideIn {
from {
transform: translateY(-20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.word-modal-header {
display: flex;
justify-content: center;
align-items: center;
padding: 1.5rem 1.5rem 0.5rem 1.5rem;
position: relative;
}
.word-modal-header h3 {
margin: 0;
color: #2c3e50;
font-size: 1.75rem;
text-align: center;
display: -webkit-box;
-webkit-line-clamp: 1;
line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.word-modal-actions {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.5rem 1.5rem 1.5rem 1.5rem;
}
.word-modal-reset,
.word-modal-delete {
width: 100%;
padding: 0.75rem 1.5rem;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.word-modal-reset {
background-color: #f39c12;
color: white;
}
.word-modal-reset:hover {
background-color: #e67e22;
transform: translateY(-1px);
}
.word-modal-delete {
background-color: #e74c3c;
color: white;
}
.word-modal-delete:hover {
background-color: #c0392b;
transform: translateY(-1px);
}

View File

@@ -14,6 +14,7 @@ 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 [selectedWord, setSelectedWord] = useState(null)
// Normalize undefined to null for clarity: new dictionary if dictionaryId is null or undefined // Normalize undefined to null for clarity: new dictionary if dictionaryId is null or undefined
const [currentDictionaryId, setCurrentDictionaryId] = useState(dictionaryId ?? null) const [currentDictionaryId, setCurrentDictionaryId] = useState(dictionaryId ?? null)
@@ -92,6 +93,62 @@ function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger =
} }
} }
const handleWordClick = (word) => {
setSelectedWord(word)
}
const handleDeleteWord = async () => {
if (!selectedWord) return
if (!window.confirm('Вы уверены, что хотите удалить это слово?')) {
return
}
try {
const response = await authFetch(`${API_URL}/words/${selectedWord.id}`, {
method: 'DELETE',
})
if (!response.ok) {
throw new Error('Ошибка при удалении слова')
}
// Remove word from local state
setWords(words.filter(word => word.id !== selectedWord.id))
setSelectedWord(null)
} catch (err) {
setError(err.message)
}
}
const handleResetProgress = async () => {
if (!selectedWord) return
if (!window.confirm('Вы уверены, что хотите сбросить прогресс этого слова?')) {
return
}
try {
const response = await authFetch(`${API_URL}/words/${selectedWord.id}/reset-progress`, {
method: 'POST',
})
if (!response.ok) {
throw new Error('Ошибка при сбросе прогресса')
}
// Update word in local state - reset progress fields
setWords(words.map(word =>
word.id === selectedWord.id
? { ...word, success: 0, failure: 0, last_success_at: null, last_failure_at: null }
: word
))
setSelectedWord(null)
} catch (err) {
setError(err.message)
}
}
const handleNameChange = (e) => { const handleNameChange = (e) => {
setDictionaryName(e.target.value) setDictionaryName(e.target.value)
} }
@@ -225,7 +282,11 @@ function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger =
<> <>
<div className="words-grid"> <div className="words-grid">
{words.map((word) => ( {words.map((word) => (
<div key={word.id} className="word-card"> <div
key={word.id}
className="word-card"
onClick={() => handleWordClick(word)}
>
<div className="word-content"> <div className="word-content">
<div className="word-header"> <div className="word-header">
<h3 className="word-name">{word.name}</h3> <h3 className="word-name">{word.name}</h3>
@@ -247,6 +308,25 @@ function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger =
)} )}
</> </>
)} )}
{/* Модальное окно для действий со словом */}
{selectedWord && (
<div className="word-modal-overlay" onClick={() => setSelectedWord(null)}>
<div className="word-modal" onClick={(e) => e.stopPropagation()}>
<div className="word-modal-header">
<h3>{selectedWord.name}</h3>
</div>
<div className="word-modal-actions">
<button className="word-modal-reset" onClick={handleResetProgress}>
Сбросить
</button>
<button className="word-modal-delete" onClick={handleDeleteWord}>
Удалить
</button>
</div>
</div>
</div>
)}
</div> </div>
) )
} }