Compare commits

...

2 Commits

Author SHA1 Message Date
mirivlad ceee03959b cleanup: remove stale frontend-dist assets 2026-06-05 16:17:31 +08:00
mirivlad 2ed2ecf77a Today screen: tabs (feed, suggestions, in-progress, captured) + inbox sort/group
- Новый экран 'Сегодня' разбит на 4 вкладки: Лента, Предложения,
  В работе, Захвачено
- Лента отображает события за сегодня с кликабельными сущностями
- Предложения вынесены в отдельную вкладку (только предложения)
- В работе: изменённые файлы/заметки/действия за сегодня с сортировками
- Захвачено: захваченные элементы за сегодня с сортировками
- Неразобранное: сортировка по дате/имени/типу с направлением
- Неразобранное: переключатель 'Группировать по месту захвата'
- TodayScreen.svelte: новый компонент с 4 вкладками
- Новые i18n ключи для вкладок и сортировок
- Backend: ListTodayInProgress, ListTodayCaptures bindings
- Все переходы из вкладок ведут в соответствующее место программы
2026-06-05 16:17:22 +08:00
11 changed files with 717 additions and 117 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

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>
{: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 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}
<div class="today-empty">
<p>{t('today.empty')}</p>
<p class="hint">{t('today.emptyHint')}</p>
</div>
{/if}
</div>
{/if}
{: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': 'Нет записей за выбранный период',