6.15.0: Драфты авто-выполнения в статистике
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m22s
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:
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,11 +237,58 @@ 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) => {
|
||||||
|
const isDraft = entry.is_draft === true
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={entry.id}
|
key={isDraft ? `draft-${entry.task_id}` : entry.id}
|
||||||
className="bg-white rounded-lg p-4 shadow-sm border border-gray-200 relative group"
|
className={`bg-white rounded-lg p-4 shadow-sm border relative group ${
|
||||||
|
isDraft ? 'border-blue-400' : 'border-gray-200'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
|
{isDraft ? (
|
||||||
|
<button
|
||||||
|
onClick={() => handleClearAutoComplete(entry.task_id)}
|
||||||
|
disabled={clearingAutoCompleteIds.has(entry.task_id)}
|
||||||
|
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>
|
||||||
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(entry.id)}
|
onClick={() => handleDelete(entry.id)}
|
||||||
disabled={deletingIds.has(entry.id)}
|
disabled={deletingIds.has(entry.id)}
|
||||||
@@ -249,19 +336,21 @@ function TodayEntriesList({ data, loading, error, onRetry, onDelete }) {
|
|||||||
</svg>
|
</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>
|
</div>
|
||||||
{entry.created_date && (
|
|
||||||
<div className="text-xs text-gray-500 mt-2">
|
<div className="text-xs text-gray-500 mt-2">
|
||||||
{new Date(entry.created_date).toLocaleTimeString('ru-RU', {
|
{isDraft ? 'в конце дня' : (
|
||||||
|
entry.created_date && new Date(entry.created_date).toLocaleTimeString('ru-RU', {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit'
|
minute: '2-digit'
|
||||||
})}
|
})
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user