6.15.0: Драфты авто-выполнения в статистике
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m22s

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
poignatov
2026-03-13 14:43:19 +03:00
parent 4ce8ba66cc
commit c8a47ff408
4 changed files with 296 additions and 73 deletions

View File

@@ -1 +1 @@
6.14.2 6.15.0

View File

@@ -244,6 +244,8 @@ type TodayEntry struct {
Text string `json:"text"` Text string `json:"text"`
CreatedDate string `json:"created_date"` CreatedDate string `json:"created_date"`
Nodes []TodayEntryNode `json:"nodes"` Nodes []TodayEntryNode `json:"nodes"`
IsDraft bool `json:"is_draft"`
TaskID *int `json:"task_id,omitempty"`
} }
type TodoistWebhook struct { type TodoistWebhook struct {
@@ -3427,6 +3429,107 @@ func (a *App) getDraftPendingScores(userID int) (map[int]float64, error) {
return scores, nil return scores, nil
} }
// getAutoCompleteDraftEntries возвращает драфты с auto_complete=true как TodayEntry для отображения в списке записей
func (a *App) getAutoCompleteDraftEntries(userID int) ([]TodayEntry, error) {
rows, err := a.DB.Query(`
SELECT td.task_id, t.name, COALESCE(t.reward_message, ''), td.progression_value, t.progression_base
FROM task_drafts td
JOIN tasks t ON td.task_id = t.id
WHERE td.user_id = $1 AND td.auto_complete = TRUE AND t.deleted = FALSE
ORDER BY td.updated_at DESC
`, userID)
if err != nil {
return nil, fmt.Errorf("error querying auto complete drafts: %w", err)
}
defer rows.Close()
entries := make([]TodayEntry, 0)
for rows.Next() {
var taskID int
var taskName string
var rewardMessageStr string
var progressionValue sql.NullFloat64
var progressionBase sql.NullFloat64
if err := rows.Scan(&taskID, &taskName, &rewardMessageStr, &progressionValue, &progressionBase); err != nil {
log.Printf("Error scanning auto complete draft row: %v", err)
continue
}
var progressionValuePtr *float64
if progressionValue.Valid {
progressionValuePtr = &progressionValue.Float64
}
var progressionBasePtr *float64
if progressionBase.Valid {
progressionBasePtr = &progressionBase.Float64
}
// Получаем ноды (reward_configs) для задачи
rewardRows, err := a.DB.Query(`
SELECT rc.position, p.name AS project_name, rc.value, rc.use_progression
FROM reward_configs rc
JOIN projects p ON rc.project_id = p.id
WHERE rc.task_id = $1
ORDER BY rc.position
`, taskID)
if err != nil {
log.Printf("Error querying rewards for draft task %d: %v", taskID, err)
continue
}
nodes := make([]TodayEntryNode, 0)
for rewardRows.Next() {
var position int
var projectName string
var rewardValue float64
var useProgression bool
if err := rewardRows.Scan(&position, &projectName, &rewardValue, &useProgression); err != nil {
log.Printf("Error scanning reward row for draft: %v", err)
continue
}
reward := Reward{
Value: rewardValue,
UseProgression: useProgression,
}
score := calculateRewardScore(reward, progressionValuePtr, progressionBasePtr)
nodes = append(nodes, TodayEntryNode{
ProjectName: projectName,
Score: score,
Index: position,
})
}
rewardRows.Close()
// Текст отдаём как есть (с плейсхолдерами $0, $1), форматирование делает фронтенд через formatEntryText
var entryText string
if rewardMessageStr != "" {
entryText = rewardMessageStr
} else {
entryText = taskName
}
taskIDCopy := taskID
entries = append(entries, TodayEntry{
IsDraft: true,
TaskID: &taskIDCopy,
Text: entryText,
Nodes: nodes,
})
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("error iterating auto complete draft rows: %w", err)
}
return entries, nil
}
// 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) {
@@ -7567,6 +7670,37 @@ func (a *App) getTodayEntriesHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
// Если запрошена сегодняшняя дата — добавляем драфты с auto_complete=true в начало списка
tzStr := getEnv("TIMEZONE", "UTC")
tzLoc, tzErr := time.LoadLocation(tzStr)
if tzErr != nil {
tzLoc = time.UTC
}
todayStr := time.Now().In(tzLoc).Format("2006-01-02")
targetDateStr := targetDate.Format("2006-01-02")
if targetDateStr == todayStr {
draftEntries, draftErr := a.getAutoCompleteDraftEntries(userID)
if draftErr != nil {
log.Printf("Error getting auto complete draft entries: %v", draftErr)
} else {
// Применяем фильтр по проекту если указан
if projectFilter != nil {
filtered := make([]TodayEntry, 0, len(draftEntries))
for _, de := range draftEntries {
for _, node := range de.Nodes {
if node.ProjectName == *projectFilter {
filtered = append(filtered, de)
break
}
}
}
draftEntries = filtered
}
entries = append(draftEntries, entries...)
}
}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(entries) json.NewEncoder(w).Encode(entries)
} }

View File

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

View File

@@ -134,6 +134,7 @@ const formatEntryText = (text, nodes) => {
function TodayEntriesList({ data, loading, error, onRetry, onDelete }) { function TodayEntriesList({ data, loading, error, onRetry, onDelete }) {
const { authFetch } = useAuth() const { authFetch } = useAuth()
const [deletingIds, setDeletingIds] = useState(new Set()) const [deletingIds, setDeletingIds] = useState(new Set())
const [clearingAutoCompleteIds, setClearingAutoCompleteIds] = useState(new Set())
const handleDelete = async (entryId) => { const handleDelete = async (entryId) => {
if (deletingIds.has(entryId)) return if (deletingIds.has(entryId)) return
@@ -174,6 +175,45 @@ function TodayEntriesList({ data, loading, error, onRetry, onDelete }) {
} }
} }
const handleClearAutoComplete = async (taskId) => {
if (clearingAutoCompleteIds.has(taskId)) return
if (!window.confirm('Снять автовыполнение в конце дня? Задача не будет выполнена автоматически.')) {
return
}
setClearingAutoCompleteIds(prev => new Set(prev).add(taskId))
try {
const response = await authFetch(`/api/tasks/${taskId}/draft`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ auto_complete: false }),
})
if (!response.ok) {
const errorText = await response.text()
console.error('Clear auto_complete error:', response.status, errorText)
throw new Error(`Ошибка: ${response.status}`)
}
if (onDelete) {
onDelete()
}
} catch (err) {
console.error('Clear auto_complete failed:', err)
alert(err.message || 'Не удалось снять автовыполнение')
} finally {
setClearingAutoCompleteIds(prev => {
const next = new Set(prev)
next.delete(taskId)
return next
})
}
}
if (loading) { if (loading) {
return ( return (
<div className="flex justify-center items-center py-8"> <div className="flex justify-center items-center py-8">
@@ -197,71 +237,120 @@ function TodayEntriesList({ data, loading, error, onRetry, onDelete }) {
return ( return (
<div className="mt-2 mb-6"> <div className="mt-2 mb-6">
<div className="space-y-3"> <div className="space-y-3">
{data.map((entry) => ( {data.map((entry) => {
<div const isDraft = entry.is_draft === true
key={entry.id} return (
className="bg-white rounded-lg p-4 shadow-sm border border-gray-200 relative group" <div
> key={isDraft ? `draft-${entry.task_id}` : entry.id}
<button className={`bg-white rounded-lg p-4 shadow-sm border relative group ${
onClick={() => handleDelete(entry.id)} isDraft ? 'border-blue-400' : 'border-gray-200'
disabled={deletingIds.has(entry.id)} }`}
className="absolute top-4 right-4 disabled:opacity-50 disabled:cursor-not-allowed"
style={{
color: '#6b7280',
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '0.25rem',
borderRadius: '0.25rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '24px',
height: '24px',
transition: 'all 0.2s',
opacity: deletingIds.has(entry.id) ? 0.5 : 1,
zIndex: 10
}}
onMouseEnter={(e) => {
if (!deletingIds.has(entry.id)) {
e.currentTarget.style.backgroundColor = '#f3f4f6'
e.currentTarget.style.color = '#1f2937'
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent'
e.currentTarget.style.color = '#6b7280'
}}
title="Удалить запись"
> >
{deletingIds.has(entry.id) ? ( {isDraft ? (
<svg className="w-5 h-5 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> <button
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> onClick={() => handleClearAutoComplete(entry.task_id)}
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> disabled={clearingAutoCompleteIds.has(entry.task_id)}
</svg> className="absolute top-4 right-4 disabled:opacity-50 disabled:cursor-not-allowed"
style={{
color: '#3b82f6',
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '0.25rem',
borderRadius: '0.25rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '24px',
height: '24px',
transition: 'all 0.2s',
opacity: clearingAutoCompleteIds.has(entry.task_id) ? 0.5 : 1,
zIndex: 10
}}
onMouseEnter={(e) => {
if (!clearingAutoCompleteIds.has(entry.task_id)) {
e.currentTarget.style.backgroundColor = '#eff6ff'
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent'
}}
title="Снять автовыполнение в конце дня"
>
{clearingAutoCompleteIds.has(entry.task_id) ? (
<svg className="w-5 h-5 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" title="Автовыполнение в конце дня">
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"></path>
</svg>
)}
</button>
) : ( ) : (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <button
<path d="M3 6h18"></path> onClick={() => handleDelete(entry.id)}
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path> disabled={deletingIds.has(entry.id)}
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path> className="absolute top-4 right-4 disabled:opacity-50 disabled:cursor-not-allowed"
<line x1="10" y1="11" x2="10" y2="17"></line> style={{
<line x1="14" y1="11" x2="14" y2="17"></line> color: '#6b7280',
</svg> background: 'none',
border: 'none',
cursor: 'pointer',
padding: '0.25rem',
borderRadius: '0.25rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '24px',
height: '24px',
transition: 'all 0.2s',
opacity: deletingIds.has(entry.id) ? 0.5 : 1,
zIndex: 10
}}
onMouseEnter={(e) => {
if (!deletingIds.has(entry.id)) {
e.currentTarget.style.backgroundColor = '#f3f4f6'
e.currentTarget.style.color = '#1f2937'
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent'
e.currentTarget.style.color = '#6b7280'
}}
title="Удалить запись"
>
{deletingIds.has(entry.id) ? (
<svg className="w-5 h-5 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
) : (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 6h18"></path>
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path>
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
</svg>
)}
</button>
)} )}
</button> <div className="text-gray-800 whitespace-pre-wrap pr-8">
<div className="text-gray-800 whitespace-pre-wrap pr-8"> {formatEntryText(entry.text, entry.nodes)}
{formatEntryText(entry.text, entry.nodes)}
</div>
{entry.created_date && (
<div className="text-xs text-gray-500 mt-2">
{new Date(entry.created_date).toLocaleTimeString('ru-RU', {
hour: '2-digit',
minute: '2-digit'
})}
</div> </div>
)} <div className="text-xs text-gray-500 mt-2">
</div> {isDraft ? 'в конце дня' : (
))} entry.created_date && new Date(entry.created_date).toLocaleTimeString('ru-RU', {
hour: '2-digit',
minute: '2-digit'
})
)}
</div>
</div>
)
})}
</div> </div>
</div> </div>
) )