Today screen: tabs (feed, suggestions, in-progress, captured) + inbox sort/group

- Новый экран 'Сегодня' разбит на 4 вкладки: Лента, Предложения,
  В работе, Захвачено
- Лента отображает события за сегодня с кликабельными сущностями
- Предложения вынесены в отдельную вкладку (только предложения)
- В работе: изменённые файлы/заметки/действия за сегодня с сортировками
- Захвачено: захваченные элементы за сегодня с сортировками
- Неразобранное: сортировка по дате/имени/типу с направлением
- Неразобранное: переключатель 'Группировать по месту захвата'
- TodayScreen.svelte: новый компонент с 4 вкладками
- Новые i18n ключи для вкладок и сортировок
- Backend: ListTodayInProgress, ListTodayCaptures bindings
- Все переходы из вкладок ведут в соответствующее место программы
This commit is contained in:
mirivlad 2026-06-05 16:17:22 +08:00
parent c8aaf36533
commit 2ed2ecf77a
9 changed files with 717 additions and 113 deletions

View File

@ -220,3 +220,43 @@ func (a *App) eventDTOWithPath(e activity.Event) EventDTO {
dto.NodePath = a.nodes.Path(e.NodeID)
return dto
}
// ListTodayInProgress returns today's modification events — items the user worked on.
func (a *App) ListTodayInProgress() ([]EventDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
events, err := a.activity.ListTodayEvents()
if err != nil {
return nil, err
}
modTypes := map[string]bool{
activity.TypeNoteCreated: true,
activity.TypeNoteUpdated: true,
activity.TypeNoteDeleted: true,
activity.TypeFileAdded: true,
activity.TypeFileDeleted: true,
activity.TypeFileRenamed: true,
activity.TypeFileCopied: true,
activity.TypeFileMoved: true,
activity.TypeFolderAdded: true,
activity.TypeFolderDeleted: true,
activity.TypeFolderRenamed: true,
activity.TypeFolderMoved: true,
activity.TypeNodeCreated: true,
activity.TypeNodeUpdated: true,
activity.TypeNodeDeleted: true,
activity.TypeActionCreated: true,
activity.TypeActionDone: true,
}
result := make([]EventDTO, 0, len(events))
for _, e := range events {
if modTypes[e.EventType] {
result = append(result, a.eventDTOWithPath(e))
}
}
sort.Slice(result, func(i, j int) bool {
return result[i].CreatedAt > result[j].CreatedAt
})
return result, nil
}

View File

@ -1,6 +1,11 @@
package main
import "fmt"
import (
"fmt"
"time"
"verstak/internal/core/activity"
)
type InboxNodeDTO struct {
NodeDTO
@ -189,6 +194,34 @@ func (a *App) clearCaptureMeta(nodeID string) error {
return err
}
// ListTodayCaptures returns inbox nodes captured today.
func (a *App) ListTodayCaptures() ([]InboxNodeDTO, error) {
if err := a.requireVault(); err != nil {
return nil, err
}
all, err := a.ListInboxNodes()
if err != nil {
return nil, err
}
start, end := activity.TodayBoundaries()
startTime, _ := time.Parse(time.RFC3339, start)
endTime, _ := time.Parse(time.RFC3339, end)
result := make([]InboxNodeDTO, 0, len(all))
for _, item := range all {
if item.CapturedAt == "" {
continue
}
t, err := time.Parse(time.RFC3339, item.CapturedAt)
if err != nil {
continue
}
if (t.Equal(startTime) || t.After(startTime)) && t.Before(endTime) {
result = append(result, item)
}
}
return result, nil
}
func (a *App) captureMeta(nodeID, key string) string {
v, ok, err := a.nodes.MetaGet(nodeID, key)
if err != nil || !ok {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -19,8 +19,8 @@
background: #13131f;
}
</style>
<script type="module" crossorigin src="/assets/main-DzZpCe3r.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-BoZGEeiu.css">
<script type="module" crossorigin src="/assets/main-DNvOo-CH.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-CXWRKHDk.css">
</head>
<body>
<div id="app"></div>

View File

@ -9,6 +9,7 @@
import VaultRecovery from './lib/VaultRecovery.svelte'
import SettingsWindow from './lib/SettingsWindow.svelte'
import SyncStatus from './lib/SyncStatus.svelte'
import TodayScreen from './lib/TodayScreen.svelte'
import { onMount, onDestroy } from 'svelte'
import { actionIcon } from './lib/actionIcons.js'
import { canPreviewFile, needsBase64Preview, needsTextPreview } from './lib/fileUtils.js'
@ -92,10 +93,15 @@
let acceptingSuggestion = null
let suggestions = []
let suggestionCount = 0
let inProgressItems = []
let todayCaptures = []
let inboxNodes = []
let localInboxNodes = []
let inboxCaptureBusy = false
let inboxCaptureStatus = ''
let inboxSortBy = 'date'
let inboxSortDir = 'desc'
let inboxGroupByContext = false
let links = []
let editingLink = null
let linkTitle = ''
@ -197,6 +203,42 @@
let unlistenDrop = null
$: visibleTrashNodes = computeVisibleTrashNodes(trashInfo?.nodes || [], trashFolderId)
$: sortedInboxNodes = sortInboxNodes(inboxNodes, inboxSortBy, inboxSortDir)
$: groupedInboxNodes = inboxGroupByContext ? groupInboxByContext(sortedInboxNodes) : null
function sortInboxNodes(nodes, sortBy, sortDir) {
const sorted = [...nodes]
sorted.sort((a, b) => {
let cmp = 0
if (sortBy === 'name') {
cmp = (a.title || '').localeCompare(b.title || '')
} else if (sortBy === 'type') {
const aKind = a.captureKind || a.sourceKind || a.type || ''
const bKind = b.captureKind || b.sourceKind || b.type || ''
cmp = aKind.localeCompare(bKind)
} else {
cmp = (a.capturedAt || a.createdAt || '').localeCompare(b.capturedAt || b.createdAt || '')
}
return sortDir === 'asc' ? cmp : -cmp
})
return sorted
}
function groupInboxByContext(nodes) {
const groups = {}
for (const item of nodes) {
const key = item.captureContextLabel || t('inbox.noContext')
if (!groups[key]) groups[key] = []
groups[key].push(item)
}
const result = []
for (const [label, items] of Object.entries(groups)) {
result.push({ label, items })
}
result.sort((a, b) => a.label.localeCompare(b.label))
return result
}
$: currentTrashPath = trashFolderStack.length === 0 ? t('nav.trash') : [t('nav.trash'), ...trashFolderStack.map(node => node.title)].join(' / ')
$: currentTrashTitle = currentTrashFolder()?.title || t('trash.deletedNodes')
@ -378,6 +420,8 @@
showCreateNode = false
error = ''
todayDashboard = null
inProgressItems = []
todayCaptures = []
activityFeed = []
activityOffset = 0
activityHasMore = true
@ -388,6 +432,8 @@
todayDashboard = await wailsCall('ListTodayView') || { cases: [] }
suggestions = await wailsCall('GetSuggestions') || []
suggestionCount = suggestions.length
inProgressItems = await wailsCall('ListTodayInProgress') || []
todayCaptures = await wailsCall('ListTodayCaptures') || []
} else if (id === 'inbox') {
inboxNodes = await wailsCall('ListInboxNodes') || []
} else if (id === 'trash') {
@ -1673,7 +1719,7 @@
const labels = {
'note_created': t('event.noteCreated'),
'note_updated': t('event.noteUpdated'),
'note_deleted': 'Заметка удалена',
'note_deleted': t('event.noteDeleted'),
'file_added': t('event.fileAdded'),
'file_deleted': t('event.fileDeleted'),
'file_renamed': t('event.fileRenamed'),
@ -1682,13 +1728,13 @@
'folder_added': t('event.folderAdded'),
'folder_deleted': t('event.folderDeleted'),
'folder_renamed': t('event.folderRenamed'),
'folder_moved': 'Папка перемещена',
'folder_moved': t('event.folderMoved'),
'node_created': t('event.caseCreated'),
'node_updated': t('event.caseUpdated'),
'node_deleted': 'Узел удалён',
'action_created': 'Действие создано',
'action_done': 'Действие выполнено',
'worklog_added': 'Запись времени добавлена',
'node_deleted': t('event.nodeDeleted'),
'action_created': t('event.actionCreated'),
'action_done': t('event.actionDone'),
'worklog_added': t('event.worklogAdded'),
}
return labels[type] || type
}
@ -2844,13 +2890,71 @@
{/if}
</div>
</div>
{#if inboxNodes.length > 0}
<div class="inbox-controls">
<div class="inbox-sort-row">
<button class="sort-btn" class:active={inboxSortBy === 'date'} on:click={() => { inboxSortBy = 'date'; inboxSortDir = inboxSortBy === 'date' && inboxSortDir === 'desc' ? 'asc' : 'desc' }}>{t('inbox.sortByDate')}</button>
<button class="sort-btn" class:active={inboxSortBy === 'name'} on:click={() => { inboxSortBy = 'name'; inboxSortDir = inboxSortBy === 'name' && inboxSortDir === 'desc' ? 'asc' : 'desc' }}>{t('inbox.sortByName')}</button>
<button class="sort-btn" class:active={inboxSortBy === 'type'} on:click={() => { inboxSortBy = 'type'; inboxSortDir = inboxSortBy === 'type' && inboxSortDir === 'desc' ? 'asc' : 'desc' }}>{t('inbox.sortByType')}</button>
<span class="sort-dir">{inboxSortDir === 'asc' ? '↑' : '↓'}</span>
<span class="inbox-controls-divider"></span>
<label class="checkbox-label-inline">
<input type="checkbox" bind:checked={inboxGroupByContext} />
<span>{t('inbox.groupByContext')}</span>
</label>
</div>
</div>
{/if}
{#if inboxNodes.length === 0}
<div class="empty-state">
<p>{t('inbox.empty')}</p>
</div>
{:else if groupedInboxNodes}
{#each groupedInboxNodes as group}
<div class="inbox-group">
<div class="inbox-group-header">{group.label} <span class="inbox-group-count">{group.items.length}</span></div>
<div class="inbox-list">
{#each group.items as item}
<div class="inbox-item" role="button" tabindex="0" on:click={() => openInboxArtifact(item)} on:keydown={(e) => e.key === 'Enter' && openInboxArtifact(item)}>
<div class="inbox-item-main">
<span class="inbox-item-title">{item.title}</span>
<span class="inbox-item-meta">{inboxMetaText(item)}</span>
</div>
<div class="inbox-item-actions">
{#if item.suggestedTargetNodeId}
<button class="inbox-icon-btn inbox-icon-btn-primary" title={t('inbox.keepHere')} aria-label={t('inbox.keepHere')} on:click|stopPropagation={() => resolveInboxHere(item)}>
{@html actionIcon('attach')}
</button>
{:else}
<span class="inbox-icon-slot" aria-hidden="true"></span>
{/if}
<button class="inbox-icon-btn" title={t('inbox.assign')} aria-label={t('inbox.assign')} on:click|stopPropagation={() => openAssignInbox(item)}>
{@html actionIcon('move')}
</button>
<button class="inbox-icon-btn" title={t('common.open')} aria-label={t('common.open')} on:click|stopPropagation={() => openInboxArtifact(item)}>
{@html actionIcon('open')}
</button>
{#if canShowInboxArtifactInFolder(item)}
<button class="inbox-icon-btn" title={t('file.showInExplorer')} aria-label={t('file.showInExplorer')} on:click|stopPropagation={() => openNodeFolder(item)}>
{@html actionIcon('showInExplorer')}
</button>
{:else}
<span class="inbox-icon-slot" aria-hidden="true"></span>
{/if}
<button class="inbox-icon-btn inbox-icon-btn-danger" title={t('common.delete')} aria-label={t('common.delete')} on:click|stopPropagation={() => confirmDeleteInbox(item)}>
{@html actionIcon('delete')}
</button>
</div>
</div>
{/each}
</div>
</div>
{/each}
{:else}
<div class="inbox-list">
{#each inboxNodes as item}
{#each sortedInboxNodes as item}
<div class="inbox-item" role="button" tabindex="0" on:click={() => openInboxArtifact(item)} on:keydown={(e) => e.key === 'Enter' && openInboxArtifact(item)}>
<div class="inbox-item-main">
<span class="inbox-item-title">{item.title}</span>
@ -3181,110 +3285,36 @@
{/if}
</div>
{:else if selectedSection === 'today' && todayDashboard}
<div class="today-dashboard">
<div class="today-header">
<h2>{t('today.title')}</h2>
<span class="today-date">{todayDashboard.date}</span>
</div>
{#if todayDashboard.summary}
<div class="today-summary">
{#if todayDashboard.summary.changedCases > 0}<span class="summary-chip">{todayDashboard.summary.changedCases} {pluralize(todayDashboard.summary.changedCases, t('today.plural.case_one'), t('today.plural.case_few'), t('today.plural.case_many'))}</span>{/if}
{#if todayDashboard.summary.notes > 0}<span class="summary-chip">{todayDashboard.summary.notes} {pluralize(todayDashboard.summary.notes, t('today.plural.note_one'), t('today.plural.note_few'), t('today.plural.note_many'))}</span>{/if}
{#if todayDashboard.summary.files > 0}<span class="summary-chip">{todayDashboard.summary.files} {pluralize(todayDashboard.summary.files, t('today.plural.file_one'), t('today.plural.file_few'), t('today.plural.file_many'))}</span>{/if}
</div>
{/if}
{#if suggestions.length > 0}
<div class="today-suggestions">
<div class="suggestions-title">{t('suggest.title')}</div>
{#each suggestions as s}
<div class="suggestion-card" class:expanded={s._expanded}>
<div class="suggestion-main" on:click={() => s._expanded = !s._expanded} role="button" tabindex="0" on:keydown={(e) => e.key === 'Enter' && (s._expanded = !s._expanded)}>
<div class="suggestion-info">
<button class="suggestion-node link-btn" on:click|stopPropagation={() => openNodeById(s.nodeId)}>{s.nodeTitle}</button>
<span class="suggestion-summary">{s.summary}</span>
<span class="suggestion-meta">{s.suggestedMin} {t('worklog.min')}{#if s.events && s.events.length > 0} · {s.events.length} {t('suggest.detectedEvents')}{/if}</span>
</div>
<div class="suggestion-actions">
<span class="suggestion-confidence-dot" class:low={s.confidence === 'low'} class:medium={s.confidence === 'medium'} class:high={s.confidence === 'high'} title={t('suggest.confidence.' + s.confidence)} aria-label={t('suggest.confidence.' + s.confidence)}></span>
<input type="number" class="suggestion-min-input" value={s.suggestedMin} min="1" max="480"
on:change|stopPropagation={(e) => s.suggestedMin = parseInt(e.target.value)} />
<span class="suggestion-min-label">{t('suggest.minutes')}</span>
<button class="btn btn-sm" on:click|stopPropagation={() => openSuggestionWorklogModal(s)}>{t('suggest.edit')}</button>
<button class="btn btn-sm btn-primary" on:click|stopPropagation={() => acceptTodaySuggestion(s)}>{t('suggest.apply')}</button>
<button class="btn btn-sm btn-danger" on:click|stopPropagation={() => deleteSuggestion(s)}>{t('common.delete')}</button>
</div>
</div>
{#if s._expanded && s.events && s.events.length > 0}
<div class="suggestion-detail">
<div class="suggestion-detail-title">{t('suggest.detectedEvents')}</div>
{#each s.events as ev}
<div class="suggestion-detail-event">
<span class="suggestion-event-time">{formatTime(ev.createdAt)}</span>
{#if ev.nodePath}<span class="suggestion-event-path">{ev.nodePath}</span>{/if}
<span class="suggestion-event-type">{eventLabel(ev.eventType)}</span>
<span class="suggestion-event-title">{ev.title}</span>
<button class="link-btn" on:click={() => openActivityTarget(ev)}>{t('common.open')}</button>
{#if ev.targetType === 'file' || ev.eventType.startsWith('file_')}
<button class="link-btn" on:click={() => openNodeFolder(ev.nodeId)}>{t('file.showInExplorer')}</button>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
{/if}
{#if todayDashboard.groups && todayDashboard.groups.length > 0}
{#each todayDashboard.groups as group}
<div class="today-case">
<div class="today-case-header" role="button" tabindex="0" on:click={() => openNodeById(group.nodeId)} on:keydown={(e) => e.key === 'Enter' && openNodeById(group.nodeId)}>
<span class="today-case-title">{group.nodeTitle}</span>
<span class="today-case-type">{nodeKindLabel(group.nodeKind)}</span>
{#if group.events}<span class="today-case-count">{group.events.length} {pluralize(group.events.length, t('today.plural.event_one'), t('today.plural.event_few'), t('today.plural.event_many'))}</span>{/if}
<span class="today-case-time">{formatTime(group.lastActivityAt)}</span>
</div>
{#if group.events && group.events.length > 0}
<div class="today-events">
{#each group.events as ev}
<div class="today-event" role="button" tabindex="0" on:click={() => openNodeById(group.nodeId)} on:keydown={(e) => e.key === 'Enter' && openNodeById(group.nodeId)}>
<span class="today-event-icon">{eventIcon(ev.eventType)}</span>
<span class="today-event-title">{ev.title}</span>
<span class="today-event-type">{eventLabel(ev.eventType)}</span>
{#if ev.targetType}<span class="activity-event-target">{ev.targetType}</span>{/if}
<span class="today-event-time">{formatTime(ev.createdAt)}</span>
</div>
{/each}
</div>
{:else}
<div class="today-events-empty">{t('today.changedCases')}</div>
{/if}
</div>
{/each}
{#if todayDashboard.events && todayDashboard.events.length > 0}
<div class="today-timeline">
<h3>{t('today.timeline')}</h3>
{#each todayDashboard.events as ev}
<div class="timeline-event" role="button" tabindex="0" on:click={() => openActivityEvent(ev)} on:keydown={(e) => e.key === 'Enter' && openActivityEvent(ev)}>
<span class="timeline-dot"></span>
<span class="timeline-title">{ev.title}</span>
<span class="timeline-type">{eventLabel(ev.eventType)}</span>
<span class="timeline-time">{formatTime(ev.createdAt)}</span>
</div>
{/each}
</div>
{/if}
{:else if selectedSection === 'today'}
{#if todayDashboard}
<TodayScreen
{todayDashboard}
{suggestions}
{inProgressItems}
{todayCaptures}
suggestionCount={suggestions.length}
{formatTime}
{formatDate}
{eventLabel}
{eventIcon}
{nodeKindLabel}
{captureKindLabel}
{inboxMetaText}
onOpenNodeById={(id) => openNodeById(id)}
onOpenActivityEvent={(ev) => openActivityEvent(ev)}
onOpenActivityTarget={(ev) => openActivityTarget(ev)}
onOpenSuggestionWorklog={(s) => openSuggestionWorklogModal(s)}
onAcceptSuggestion={(s) => acceptTodaySuggestion(s)}
onDeleteSuggestion={(s) => deleteSuggestion(s)}
onOpenNodeFolder={(id) => openNodeFolder(id)}
onOpenInboxArtifact={(item) => openInboxArtifact(item)}
/>
{:else}
<div class="today-empty">
<p>{t('today.empty')}</p>
<p class="hint">{t('today.emptyHint')}</p>
</div>
{/if}
</div>
{:else if selectedSection === 'activity'}
<div class="activity-feed">
@ -3773,6 +3803,18 @@
.inbox-icon-btn-danger:hover { color: #fff; border-color: #dc2626; background: #3a1f24; }
.inbox-tab { padding: 24px; }
/* Inbox sort/group controls */
.inbox-controls { margin-bottom: 12px; }
.inbox-sort-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.sort-btn { padding: 4px 10px; border: 1px solid #2a2a3c; background: #13131f; color: #888; border-radius: 4px; font-size: 12px; cursor: pointer; font-family: inherit; }
.sort-btn:hover { border-color: #3a3a5c; color: #ccc; }
.sort-btn.active { border-color: #6366f1; color: #a5b4fc; background: rgba(99,102,241,0.08); }
.sort-dir { font-size: 12px; color: #8888a0; margin-left: 2px; }
.inbox-controls-divider { width: 1px; height: 20px; background: #2a2a3c; margin: 0 4px; }
.inbox-group { margin-bottom: 16px; }
.inbox-group-header { font-size: 12px; font-weight: 600; color: #a5b4fc; text-transform: uppercase; letter-spacing: 0.5px; padding: 8px 0 6px; margin-bottom: 6px; border-bottom: 1px solid #2a2a3c; display: flex; align-items: center; gap: 8px; }
.inbox-group-count { font-size: 10px; color: #8888a0; background: #1e1e30; padding: 1px 6px; border-radius: 8px; }
/* Links tab */
.links-tab { padding: 24px; }
.links-list { display: flex; flex-direction: column; gap: 8px; }

View File

@ -0,0 +1,433 @@
<script>
import { t } from './i18n'
export let todayDashboard = null
export let suggestions = []
export let inProgressItems = []
export let todayCaptures = []
export let suggestionCount = 0
export let formatTime = (iso) => ''
export let formatDate = (iso) => ''
export let eventLabel = (type) => type
export let eventIcon = (type) => ''
export let nodeKindLabel = (kind) => kind
export let captureKindLabel = (kind) => kind
export let inboxMetaText = (item) => ''
export let onOpenNodeById = (id) => {}
export let onOpenActivityEvent = (ev) => {}
export let onOpenActivityTarget = (ev) => {}
export let onOpenSuggestionWorklog = (s) => {}
export let onAcceptSuggestion = (s) => {}
export let onDeleteSuggestion = (s) => {}
export let onOpenNodeFolder = (id) => {}
export let onOpenInboxArtifact = (item) => {}
let activeTab = 'feed'
function pluralize(n, one, few, many) {
n = Math.abs(n) % 100
if (n >= 5 && n <= 20) return many
n %= 10
if (n === 1) return one
if (n >= 2 && n <= 4) return few
return many
}
function extractEventIds(s) {
if (s.eventIds && s.eventIds.length) return s.eventIds
if (s.events && s.events.length) return s.events.map(ev => ev.id).filter(Boolean)
return []
}
function handleAcceptSuggestion(s) {
onAcceptSuggestion(s)
}
function handleDeleteSuggestion(s) {
onDeleteSuggestion(s)
}
function feedItemLabel(ev) {
return eventLabel(ev.eventType)
}
function feedItemIcon(type) {
if (type === 'note_created' || type === 'file_added' || type === 'folder_added' || type === 'node_created') return '+'
if (type === 'file_deleted' || type === 'folder_deleted') return '×'
if (type === 'file_renamed' || type === 'folder_renamed' || type === 'node_updated') return '~'
if (type === 'file_copied') return '⧉'
if (type === 'file_moved' || type === 'folder_moved') return '→'
if (type === 'note_updated') return '✎'
if (type === 'action_created') return '▶'
if (type === 'action_done') return '✓'
if (type === 'worklog_added') return '⏱'
return '•'
}
function handleFeedTitleClick(ev) {
if (ev.eventType === 'folder_deleted') {
onOpenNodeById(ev.nodeId)
return
}
if (['file_added','file_deleted','file_renamed','file_copied','file_moved','folder_added','folder_renamed','folder_moved'].includes(ev.eventType)) {
if (ev.targetId) {
onOpenActivityTarget(ev)
} else {
onOpenNodeById(ev.nodeId)
}
return
}
if (['note_created','note_updated','note_deleted'].includes(ev.eventType)) {
if (ev.targetType === 'note' && ev.targetId) {
onOpenActivityTarget(ev)
} else {
onOpenNodeById(ev.nodeId)
}
return
}
if (ev.eventType === 'worklog_added') {
onOpenNodeById(ev.nodeId)
return
}
if (['action_created','action_done'].includes(ev.eventType)) {
onOpenNodeById(ev.nodeId)
return
}
onOpenActivityEvent(ev)
}
function feedItemSubtitle(ev) {
const parts = []
if (ev.nodePath) parts.push(ev.nodePath)
return parts.length > 0 ? parts.join(' → ') : ''
}
function inProgressSort(items, sortBy, sortDir) {
const sorted = [...items]
sorted.sort((a, b) => {
let cmp = 0
if (sortBy === 'name') {
cmp = (a.title || '').localeCompare(b.title || '')
} else if (sortBy === 'type') {
cmp = (eventLabel(a.eventType) || '').localeCompare(eventLabel(b.eventType) || '')
} else {
cmp = (a.createdAt || '').localeCompare(b.createdAt || '')
}
return sortDir === 'asc' ? cmp : -cmp
})
return sorted
}
let ipSortBy = 'time'
let ipSortDir = 'desc'
let captureSortBy = 'time'
let captureSortDir = 'desc'
function captureSort(items, sortBy, sortDir) {
const sorted = [...items]
sorted.sort((a, b) => {
let cmp = 0
if (sortBy === 'name') {
cmp = (a.title || '').localeCompare(b.title || '')
} else if (sortBy === 'type') {
const aKind = a.captureKind || a.sourceKind || ''
const bKind = b.captureKind || b.sourceKind || ''
cmp = aKind.localeCompare(bKind)
} else {
cmp = (a.capturedAt || a.createdAt || '').localeCompare(b.capturedAt || b.createdAt || '')
}
return sortDir === 'asc' ? cmp : -cmp
})
return sorted
}
function captureKindIcon(kind) {
const map = { text: '📝', url: '🔗', file: '📄', image: '🖼', folder: '📁' }
return map[kind] || '📦'
}
</script>
<div class="today-screen">
<div class="today-header">
<h2>{t('today.title')}</h2>
<span class="today-date">{todayDashboard?.date || ''}</span>
</div>
<div class="today-tabs">
<button class="today-tab" class:active={activeTab === 'feed'} on:click={() => activeTab = 'feed'}>
{t('today.feed')}
</button>
<button class="today-tab" class:active={activeTab === 'suggestions'} on:click={() => activeTab = 'suggestions'}>
{t('today.suggestionsTab')}
{#if suggestionCount > 0}<span class="tab-badge">{suggestionCount}</span>{/if}
</button>
<button class="today-tab" class:active={activeTab === 'inprogress'} on:click={() => activeTab = 'inprogress'}>
{t('today.inProgress')}
</button>
<button class="today-tab" class:active={activeTab === 'captured'} on:click={() => activeTab = 'captured'}>
{t('today.captured')}
{#if todayCaptures.length > 0}<span class="tab-badge">{todayCaptures.length}</span>{/if}
</button>
</div>
{#if activeTab === 'feed'}
<div class="today-tab-content">
{#if !todayDashboard || !todayDashboard.events || todayDashboard.events.length === 0}
<div class="today-empty">
<p>{t('today.feedEmpty')}</p>
<p class="hint">{t('today.feedHint')}</p>
</div>
{:else}
<div class="feed-list">
{#each todayDashboard.events as ev}
<div class="feed-item" role="button" tabindex="0"
on:click={() => handleFeedTitleClick(ev)}
on:keydown={(e) => e.key === 'Enter' && handleFeedTitleClick(ev)}>
<span class="feed-icon">{feedItemIcon(ev.eventType)}</span>
<div class="feed-body">
<div class="feed-title-line">
<span class="feed-type">{feedItemLabel(ev.eventType)}</span>
<span class="feed-colon">:</span>
<span class="feed-title link-btn">{ev.title}</span>
</div>
<div class="feed-meta-line">
{#if feedItemSubtitle(ev)}
<span class="feed-path">{feedItemSubtitle(ev)}</span>
{/if}
<span class="feed-event-type">{eventLabel(ev.eventType)}</span>
<span class="feed-time">{formatTime(ev.createdAt)}</span>
</div>
</div>
<button class="feed-nav-btn" title={t('common.open')} on:click|stopPropagation={() => handleFeedTitleClick(ev)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
</button>
</div>
{/each}
</div>
{/if}
</div>
{:else if activeTab === 'suggestions'}
<div class="today-tab-content">
{#if suggestions.length === 0}
<div class="today-empty">
<p>{t('suggest.noSuggestions')}</p>
</div>
{:else}
<div class="suggestions-list">
{#each suggestions as s}
<div class="suggestion-card" class:expanded={s._expanded}>
<div class="suggestion-main" on:click={() => s._expanded = !s._expanded} role="button" tabindex="0" on:keydown={(e) => e.key === 'Enter' && (s._expanded = !s._expanded)}>
<div class="suggestion-info">
<button class="suggestion-node link-btn" on:click|stopPropagation={() => onOpenNodeById(s.nodeId)}>{s.nodeTitle}</button>
<span class="suggestion-summary">{s.summary}</span>
<span class="suggestion-meta">{s.suggestedMin} {t('worklog.min')}{#if s.events && s.events.length > 0} · {s.events.length} {t('suggest.detectedEvents')}{/if}</span>
</div>
<div class="suggestion-actions">
<span class="suggestion-confidence-dot" class:low={s.confidence === 'low'} class:medium={s.confidence === 'medium'} class:high={s.confidence === 'high'} title={t('suggest.confidence.' + s.confidence)} aria-label={t('suggest.confidence.' + s.confidence)}></span>
<input type="number" class="suggestion-min-input" value={s.suggestedMin} min="1" max="480"
on:change|stopPropagation={(e) => s.suggestedMin = parseInt(e.target.value)} />
<span class="suggestion-min-label">{t('suggest.minutes')}</span>
<button class="btn btn-sm" on:click|stopPropagation={() => onOpenSuggestionWorklog(s)}>{t('suggest.edit')}</button>
<button class="btn btn-sm btn-primary" on:click|stopPropagation={() => handleAcceptSuggestion(s)}>{t('suggest.apply')}</button>
<button class="btn btn-sm btn-danger" on:click|stopPropagation={() => handleDeleteSuggestion(s)}>{t('common.delete')}</button>
</div>
</div>
{#if s._expanded && s.events && s.events.length > 0}
<div class="suggestion-detail">
<div class="suggestion-detail-title">{t('suggest.detectedEvents')}</div>
{#each s.events as ev}
<div class="suggestion-detail-event">
<span class="suggestion-event-time">{formatTime(ev.createdAt)}</span>
{#if ev.nodePath}<span class="suggestion-event-path">{ev.nodePath}</span>{/if}
<span class="suggestion-event-type">{eventLabel(ev.eventType)}</span>
<span class="suggestion-event-title">{ev.title}</span>
<button class="link-btn" on:click={() => onOpenActivityTarget(ev)}>{t('common.open')}</button>
{#if ev.targetType === 'file' || ev.eventType.startsWith('file_')}
<button class="link-btn" on:click={() => onOpenNodeFolder(ev.nodeId)}>{t('file.showInExplorer')}</button>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{:else if activeTab === 'inprogress'}
<div class="today-tab-content">
<div class="tab-controls">
<div class="sort-controls">
<button class="sort-btn" class:active={ipSortBy === 'time'} on:click={() => { ipSortBy = 'time'; ipSortDir = ipSortBy === 'time' && ipSortDir === 'desc' ? 'asc' : 'desc' }}>{t('today.sortByTime')}</button>
<button class="sort-btn" class:active={ipSortBy === 'name'} on:click={() => { ipSortBy = 'name'; ipSortDir = ipSortBy === 'name' && ipSortDir === 'desc' ? 'asc' : 'desc' }}>{t('today.sortByName')}</button>
<button class="sort-btn" class:active={ipSortBy === 'type'} on:click={() => { ipSortBy = 'type'; ipSortDir = ipSortBy === 'type' && ipSortDir === 'desc' ? 'asc' : 'desc' }}>{t('today.sortByType')}</button>
<span class="sort-dir">{ipSortDir === 'asc' ? '↑' : '↓'}</span>
</div>
</div>
{#if inProgressItems.length === 0}
<div class="today-empty">
<p>{t('today.inProgressEmpty')}</p>
</div>
{:else}
<div class="ip-list">
{#each inProgressSort(inProgressItems, ipSortBy, ipSortDir) as item}
<div class="ip-item" role="button" tabindex="0"
on:click={() => onOpenActivityEvent(item)}
on:keydown={(e) => e.key === 'Enter' && onOpenActivityEvent(item)}>
<span class="ip-icon">{eventIcon(item.eventType)}</span>
<div class="ip-body">
<span class="ip-title">{item.title}</span>
{#if item.nodePath}
<span class="ip-path">{item.nodePath}</span>
{/if}
</div>
<span class="ip-type">{eventLabel(item.eventType)}</span>
<span class="ip-time">{formatTime(item.createdAt)}</span>
</div>
{/each}
</div>
{/if}
</div>
{:else if activeTab === 'captured'}
<div class="today-tab-content">
<div class="tab-controls">
<div class="sort-controls">
<button class="sort-btn" class:active={captureSortBy === 'time'} on:click={() => { captureSortBy = 'time'; captureSortDir = captureSortBy === 'time' && captureSortDir === 'desc' ? 'asc' : 'desc' }}>{t('today.sortByTime')}</button>
<button class="sort-btn" class:active={captureSortBy === 'name'} on:click={() => { captureSortBy = 'name'; captureSortDir = captureSortBy === 'name' && captureSortDir === 'desc' ? 'asc' : 'desc' }}>{t('today.sortByName')}</button>
<button class="sort-btn" class:active={captureSortBy === 'type'} on:click={() => { captureSortBy = 'type'; captureSortDir = captureSortBy === 'type' && captureSortDir === 'desc' ? 'asc' : 'desc' }}>{t('today.sortByType')}</button>
<span class="sort-dir">{captureSortDir === 'asc' ? '↑' : '↓'}</span>
</div>
</div>
{#if todayCaptures.length === 0}
<div class="today-empty">
<p>{t('today.capturedEmpty')}</p>
</div>
{:else}
<div class="capture-list">
{#each captureSort(todayCaptures, captureSortBy, captureSortDir) as item}
<div class="capture-item" role="button" tabindex="0"
on:click={() => onOpenInboxArtifact(item)}
on:keydown={(e) => e.key === 'Enter' && onOpenInboxArtifact(item)}>
<span class="capture-icon">{captureKindIcon(item.captureKind || item.sourceKind)}</span>
<div class="capture-body">
<span class="capture-title">{item.title}</span>
<span class="capture-meta">{inboxMetaText(item)}</span>
</div>
<span class="capture-time">{formatTime(item.capturedAt || item.createdAt)}</span>
</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>
<style>
.today-screen { padding: 24px; overflow-y: auto; flex: 1; }
.today-header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 16px; }
.today-header h2 { font-size: 24px; margin: 0; }
.today-date { font-size: 13px; color: #666; }
.today-tabs { display: flex; gap: 0; border-bottom: 1px solid #2a2a3c; margin-bottom: 16px; }
.today-tab { padding: 10px 16px; border: none; background: none; color: #888; font-size: 13px; cursor: pointer; border-bottom: 2px solid transparent; font-family: inherit; position: relative; }
.today-tab:hover { color: #a5b4fc; }
.today-tab.active { color: #e4e4ef; border-bottom-color: #818cf8; background: rgba(99,102,241,0.12); font-weight: 600; }
.tab-badge { background: #6366f1; color: #fff; font-size: 10px; font-weight: 700; border-radius: 10px; padding: 1px 6px; margin-left: 6px; line-height: 1.4; vertical-align: middle; }
.today-tab-content { flex: 1; }
.today-empty { padding: 48px 24px; text-align: center; }
.today-empty p { color: #666; font-size: 14px; margin: 0; }
.today-empty .hint { font-size: 12px; color: #555; margin-top: 8px; }
.tab-controls { margin-bottom: 12px; }
.sort-controls { display: flex; align-items: center; gap: 6px; }
.sort-btn { padding: 4px 10px; border: 1px solid #2a2a3c; background: #13131f; color: #888; border-radius: 4px; font-size: 12px; cursor: pointer; font-family: inherit; }
.sort-btn:hover { border-color: #3a3a5c; color: #ccc; }
.sort-btn.active { border-color: #6366f1; color: #a5b4fc; background: rgba(99,102,241,0.08); }
.sort-dir { font-size: 12px; color: #8888a0; margin-left: 4px; }
/* Feed */
.feed-list { display: flex; flex-direction: column; gap: 2px; }
.feed-item { display: flex; align-items: flex-start; gap: 10px; padding: 8px 12px; border-radius: 8px; cursor: pointer; }
.feed-item:hover { background: #1a1a28; }
.feed-icon { width: 20px; text-align: center; color: #6366f1; font-size: 14px; flex-shrink: 0; margin-top: 2px; }
.feed-body { flex: 1; min-width: 0; }
.feed-title-line { display: flex; align-items: baseline; gap: 4px; flex-wrap: wrap; }
.feed-type { font-size: 13px; color: #8888a0; }
.feed-colon { color: #555; }
.feed-title { font-size: 14px; color: #e4e4ef; font-weight: 500; }
.feed-title:hover { text-decoration: underline; }
.feed-meta-line { display: flex; align-items: center; gap: 8px; margin-top: 2px; }
.feed-path { font-size: 11px; color: #8ea0d8; max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.feed-event-type { font-size: 10px; color: #555; background: #1e1e2e; padding: 1px 6px; border-radius: 8px; }
.feed-time { font-size: 11px; color: #555; margin-left: auto; flex-shrink: 0; }
.feed-nav-btn { background: none; border: none; color: #555; cursor: pointer; padding: 4px; border-radius: 4px; flex-shrink: 0; margin-top: 2px; }
.feed-nav-btn:hover { color: #a5b4fc; background: #1e1e30; }
.link-btn { background: none; border: none; padding: 0; color: #a5b4fc; font: inherit; cursor: pointer; text-align: left; }
.link-btn:hover { text-decoration: underline; }
/* Suggestions */
.suggestions-list { display: flex; flex-direction: column; gap: 6px; }
.suggestion-card { display: flex; flex-direction: column; align-items: stretch; padding: 0; background: #1e1e32; border-radius: 6px; margin-bottom: 6px; gap: 0; border: 1px solid transparent; }
.suggestion-card:last-child { margin-bottom: 0; }
.suggestion-card.expanded { border-color: #3a3a5c; }
.suggestion-main { display: flex; align-items: center; justify-content: space-between; padding: 7px 10px; cursor: pointer; gap: 10px; min-height: 38px; }
.suggestion-info { flex: 1; display: flex; flex-direction: column; gap: 2px; }
.suggestion-node { color: #a5b4fc; font-weight: 600; font-size: 13px; text-decoration: none; cursor: pointer; }
.suggestion-node:hover { text-decoration: underline; }
.suggestion-summary { font-size: 14px; color: #e4e4ef; }
.suggestion-meta { font-size: 12px; color: #8888a0; margin-top: 2px; }
.suggestion-actions { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
.suggestion-min-input { width: 54px; padding: 3px 7px; border: 1px solid #2a2a3c; background: #13131f; color: #e4e4ef; border-radius: 4px; font-size: 13px; text-align: center; }
.suggestion-min-label { font-size: 12px; color: #8888a0; }
.suggestion-confidence-dot { width: 9px; height: 9px; border-radius: 999px; background: #77798a; box-shadow: 0 0 0 3px rgba(119,121,138,0.12); flex: 0 0 auto; }
.suggestion-confidence-dot.low { background: #ef4444; box-shadow: 0 0 0 3px rgba(239,68,68,0.12); }
.suggestion-confidence-dot.medium { background: #fbbf24; box-shadow: 0 0 0 3px rgba(251,191,36,0.12); }
.suggestion-confidence-dot.high { background: #34d399; box-shadow: 0 0 0 3px rgba(52,211,153,0.12); }
.suggestion-detail { width: 100%; padding: 6px 10px 8px; border-top: 1px solid #2a2a3c; }
.suggestion-detail-title { font-size: 10px; font-weight: 600; color: #a5b4fc; text-transform: uppercase; letter-spacing: 0.3px; padding: 4px 0; }
.suggestion-detail-event { display: flex; align-items: center; gap: 7px; padding: 2px 0; font-size: 12px; color: #b0b0c0; }
.suggestion-event-time { color: #a0a0b8; font-variant-numeric: tabular-nums; white-space: nowrap; min-width: 48px; }
.suggestion-event-type { color: #8888a0; font-size: 11px; background: #1a1a2e; padding: 1px 6px; border-radius: 3px; }
.suggestion-event-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.suggestion-event-path { max-width: 220px; color: #8ea0d8; font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.btn { padding: 8px 16px; border: 1px solid #2a2a3c; background: #1a1a28; color: #ccc; border-radius: 6px; cursor: pointer; font-size: 13px; font-family: inherit; display: inline-flex; align-items: center; gap: 6px; }
.btn:hover { background: #222233; }
.btn-primary { background: #6366f1; border-color: #6366f1; color: #fff; }
.btn-primary:hover { background: #4f46e5; }
.btn-sm { padding: 4px 10px; font-size: 12px; }
.btn-danger { color: #ff6b6b; border-color: #4a2222; }
.btn-danger:hover { background: #3a2222; }
/* In Progress */
.ip-list { display: flex; flex-direction: column; gap: 2px; }
.ip-item { display: flex; align-items: center; gap: 10px; padding: 8px 12px; border-radius: 8px; cursor: pointer; }
.ip-item:hover { background: #1a1a28; }
.ip-icon { width: 20px; text-align: center; color: #6366f1; font-size: 13px; flex-shrink: 0; }
.ip-body { flex: 1; min-width: 0; }
.ip-title { font-size: 14px; color: #e4e4ef; display: block; }
.ip-path { font-size: 11px; color: #8ea0d8; max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; }
.ip-type { font-size: 11px; color: #666; flex-shrink: 0; }
.ip-time { font-size: 11px; color: #555; flex-shrink: 0; margin-left: 8px; }
/* Captured */
.capture-list { display: flex; flex-direction: column; gap: 6px; }
.capture-item { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border: 1px solid #2a2a3c; border-radius: 8px; background: #1a1a28; cursor: pointer; }
.capture-item:hover { border-color: #3a3a5c; background: #1e1e32; }
.capture-icon { font-size: 18px; width: 28px; text-align: center; flex-shrink: 0; }
.capture-body { flex: 1; min-width: 0; }
.capture-title { font-size: 14px; color: #e4e4ef; font-weight: 500; display: block; }
.capture-meta { font-size: 12px; color: #8888a0; display: block; margin-top: 2px; }
.capture-time { font-size: 11px; color: #555; flex-shrink: 0; }
</style>

View File

@ -25,6 +25,13 @@ export default {
'nav.createNode': 'Create element',
'nav.moveToRoot': 'Move to root',
'inbox.subtitle': 'Captured materials that still need to be assigned to cases',
'inbox.sortByDate': 'by date',
'inbox.sortByName': 'by name',
'inbox.sortByType': 'by type',
'inbox.sortAsc': 'ascending',
'inbox.sortDesc': 'descending',
'inbox.groupByContext': 'Group by capture location',
'inbox.noContext': 'No capture location',
'inbox.empty': 'No unprocessed items',
'inbox.pasteClipboard': 'Paste from clipboard',
'inbox.captured': 'Added',
@ -116,6 +123,12 @@ export default {
'event.folderRenamed': 'Folder renamed',
'event.caseCreated': 'Case created',
'event.caseUpdated': 'Case updated',
'event.noteDeleted': 'Note deleted',
'event.folderMoved': 'Folder moved',
'event.nodeDeleted': 'Node deleted',
'event.actionCreated': 'Action created',
'event.actionDone': 'Action completed',
'event.worklogAdded': 'Time entry added',
'kind.project': 'Project',
'kind.client': 'Client',
'kind.document': 'Document',
@ -251,6 +264,19 @@ export default {
'today.plural.event_one': 'event',
'today.plural.event_few': 'events',
'today.plural.event_many': 'events',
'today.feed': 'Feed',
'today.suggestionsTab': 'Suggestions',
'today.inProgress': 'In Progress',
'today.captured': 'Captured',
'today.feedEmpty': 'No events today',
'today.inProgressEmpty': 'Nothing modified today',
'today.capturedEmpty': 'Nothing captured today',
'today.feedHint': 'Today\'s events: file changes, notes, actions and other events.',
'today.sortByTime': 'by time',
'today.sortByName': 'by name',
'today.sortByType': 'by type',
'today.sortAsc': 'ascending',
'today.sortDesc': 'descending',
'journal.title': 'Work Log',
'journal.empty': 'No entries for the selected period',
'journal.dateFrom': 'From',

View File

@ -26,6 +26,13 @@ export default {
'nav.moveToRoot': 'Переместить в корень',
'inbox.subtitle': 'Захваченные материалы, которые нужно разложить по делам',
'inbox.sortByDate': 'по дате',
'inbox.sortByName': 'по имени',
'inbox.sortByType': 'по типу',
'inbox.sortAsc': 'по возрастанию',
'inbox.sortDesc': 'по убыванию',
'inbox.groupByContext': 'Группировать по месту захвата',
'inbox.noContext': 'Без места захвата',
'inbox.empty': 'Неразобранных элементов нет',
'inbox.pasteClipboard': 'Вставить из буфера',
'inbox.captured': 'Добавлено',
@ -123,6 +130,12 @@ export default {
'event.folderRenamed': 'Папка переименована',
'event.caseCreated': 'Дело создано',
'event.caseUpdated': 'Дело изменено',
'event.noteDeleted': 'Заметка удалена',
'event.folderMoved': 'Папка перемещена',
'event.nodeDeleted': 'Узел удалён',
'event.actionCreated': 'Действие создано',
'event.actionDone': 'Действие выполнено',
'event.worklogAdded': 'Запись времени добавлена',
'kind.project': 'Проект',
'kind.client': 'Клиент',
@ -266,6 +279,19 @@ export default {
'today.plural.event_one': 'событие',
'today.plural.event_few': 'события',
'today.plural.event_many': 'событий',
'today.feed': 'Лента',
'today.suggestionsTab': 'Предложения',
'today.inProgress': 'В работе',
'today.captured': 'Захвачено',
'today.feedEmpty': 'Сегодня событий нет',
'today.inProgressEmpty': 'Сегодня ничего не изменяли',
'today.capturedEmpty': 'Сегодня ничего не захвачено',
'today.feedHint': 'События за сегодня: изменения файлов, заметок, действий и другие события.',
'today.sortByTime': 'по времени',
'today.sortByName': 'по имени',
'today.sortByType': 'по типу',
'today.sortAsc': 'по возрастанию',
'today.sortDesc': 'по убыванию',
'journal.title': 'Журнал работы',
'journal.empty': 'Нет записей за выбранный период',