Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions frontend/src/i18n/workspaceCopy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,14 @@ type WorkspaceCopy = {
projectPulseDone: string
projectPulseEmptyBoard: string
projectPulseEmptyTimeline: string
loadingTimeline: string
timelineCaptured: string
timelineConverted: string
timelineUpdated: string
timelineDeleted: string
timelineRestored: string
timelineStatusChanged: string
timelineUndo: string
loadMore: string
loadingMore: string
recycleBinTitle: string
Expand Down Expand Up @@ -126,8 +132,14 @@ export const workspaceCopy: Record<Locale, WorkspaceCopy> = {
projectPulseDone: 'Done',
projectPulseEmptyBoard: 'Nothing here yet.',
projectPulseEmptyTimeline: 'No timeline events yet.',
loadingTimeline: 'Loading timeline...',
timelineCaptured: 'Capture added',
timelineConverted: 'Converted to item',
timelineUpdated: 'Content updated',
timelineDeleted: 'Deleted',
timelineRestored: 'Restored',
timelineStatusChanged: 'Status updated',
timelineUndo: 'Conversion undone',
loadMore: 'Load More',
loadingMore: 'Loading...',
recycleBinTitle: 'Recycle Bin',
Expand Down Expand Up @@ -207,8 +219,14 @@ export const workspaceCopy: Record<Locale, WorkspaceCopy> = {
projectPulseDone: 'Done',
projectPulseEmptyBoard: 'Chưa có dữ liệu.',
projectPulseEmptyTimeline: 'Chưa có sự kiện timeline.',
loadingTimeline: 'Đang tải timeline...',
timelineCaptured: 'Đã tạo capture',
timelineConverted: 'Đã chuyển thành item',
timelineUpdated: 'Đã cập nhật nội dung',
timelineDeleted: 'Đã xóa',
timelineRestored: 'Đã khôi phục',
timelineStatusChanged: 'Đã cập nhật trạng thái',
timelineUndo: 'Đã hoàn tác chuyển đổi',
loadMore: 'Tải thêm',
loadingMore: 'Đang tải...',
recycleBinTitle: 'Thùng rác',
Expand Down
12 changes: 12 additions & 0 deletions frontend/src/pages/WorkspacePage.css
Original file line number Diff line number Diff line change
Expand Up @@ -549,6 +549,18 @@
color: color-mix(in srgb, #2563eb 70%, var(--text));
}

.workspace-page .timeline-event.deleted span {
color: color-mix(in srgb, #dc2626 78%, var(--text));
}

.workspace-page .timeline-event.restored span {
color: color-mix(in srgb, #0f766e 78%, var(--text));
}

.workspace-page .timeline-event.updated span {
color: color-mix(in srgb, #7c3aed 74%, var(--text));
}

.workspace-page .pulse-empty {
margin: 12px 0 0;
color: var(--muted);
Expand Down
171 changes: 135 additions & 36 deletions frontend/src/pages/WorkspacePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
deleteItem,
getCapture,
getItem,
listActivities,
listCaptures,
listItems,
restoreCapture,
Expand All @@ -22,7 +23,7 @@ import {
updateItem,
updateItemStatus,
} from '../services/api'
import type { Capture, Item, ItemStatus, ItemType } from '../types'
import type { ActivityAction, ActivityLog, Capture, Item, ItemStatus, ItemType } from '../types'
import { closeDetailsMenu } from '../utils/detailsMenu'
import './WorkspacePage.css'

Expand Down Expand Up @@ -89,6 +90,11 @@ function WorkspacePage() {
const [recycleBinError, setRecycleBinError] = useState<string | null>(null)
const [isPulseVisible, setIsPulseVisible] = useState(true)
const [pulseView, setPulseView] = useState<PulseView>('board')
const [activities, setActivities] = useState<ActivityLog[]>([])
const [activityCursor, setActivityCursor] = useState<string | null>(null)
const [isLoadingActivities, setIsLoadingActivities] = useState(true)
const [isLoadingMoreActivities, setIsLoadingMoreActivities] = useState(false)
const [activityLoadError, setActivityLoadError] = useState<string | null>(null)

const ui = workspaceCopy[locale]
const themeLabel = ui[
Expand All @@ -98,7 +104,6 @@ function WorkspacePage() {
const convertedQuery = captureFilter === 'all' ? undefined : captureFilter === 'converted'
const statusQuery = itemStatusFilter === 'ALL' ? undefined : itemStatusFilter
const typeQuery = itemTypeFilter === 'ALL' ? undefined : itemTypeFilter
const itemById = new Map(items.map((item) => [item.id, item] as const))

useEffect(() => {
const timeoutId = window.setTimeout(() => {
Expand Down Expand Up @@ -128,43 +133,78 @@ function WorkspacePage() {
const planningCaptures = captures.filter((capture) => !capture.convertedItemId).slice(0, 4)
const inProgressItems = items.filter((item) => item.status === 'TODO').slice(0, 4)
const doneItems = items.filter((item) => item.status === 'ACTIVE').slice(0, 4)
const timelineEvents = captures
.flatMap((capture) => {
const events: Array<{ id: string; kind: 'captured' | 'converted'; content: string; at: string }> = [
{
id: `capture-${capture.id}`,
kind: 'captured',
content: capture.content,
at: capture.createdAt,
},
]
if (capture.convertedAt && capture.convertedItemId) {
const convertedType = itemById.get(capture.convertedItemId)?.type
events.push({
id: `converted-${capture.id}`,
kind: 'converted',
content: convertedType ? `${capture.content} -> ${ui.typeLabels[convertedType]}` : capture.content,
at: capture.convertedAt,
})
}
return events
})
.sort((left, right) => new Date(right.at).getTime() - new Date(left.at).getTime())
.slice(0, 7)
const captureById = new Map([...captures, ...deletedCaptures].map((capture) => [capture.id, capture] as const))
const itemById = new Map([...items, ...deletedItems].map((item) => [item.id, item] as const))

const getActivityLabel = (action: ActivityAction) => {
switch (action) {
case 'CAPTURE_CREATED':
return ui.timelineCaptured
case 'CAPTURE_CONVERTED':
return ui.timelineConverted
case 'CAPTURE_UPDATED':
case 'ITEM_UPDATED':
return ui.timelineUpdated
case 'CAPTURE_DELETED':
case 'ITEM_DELETED':
return ui.timelineDeleted
case 'CAPTURE_RESTORED':
case 'ITEM_RESTORED':
return ui.timelineRestored
case 'ITEM_STATUS_UPDATED':
return ui.timelineStatusChanged
case 'CONVERSION_UNDONE':
return ui.timelineUndo
default:
return action
}
}

const timelineEvents = activities.map((activity) => {
const capture = activity.captureId ? captureById.get(activity.captureId) : null
const item = activity.itemId ? itemById.get(activity.itemId) : null
const content =
capture?.content ??
item?.content ??
activity.metadata ??
[activity.captureId ? `capture:${activity.captureId}` : null, activity.itemId ? `item:${activity.itemId}` : null]
.filter(Boolean)
.join(' | ')

return {
id: activity.id,
at: activity.createdAt,
label: getActivityLabel(activity.action),
content: content || '-',
kind:
activity.action === 'CAPTURE_CREATED' || activity.action === 'CAPTURE_CONVERTED'
? 'captured'
: activity.action === 'CAPTURE_DELETED' || activity.action === 'ITEM_DELETED'
? 'deleted'
: activity.action === 'CAPTURE_RESTORED' || activity.action === 'ITEM_RESTORED'
? 'restored'
: 'updated',
}
})

useEffect(() => {
const loadData = async () => {
setIsLoadingCaptures(true)
setIsLoadingActivities(true)
setCaptureLoadError(null)
setActivityLoadError(null)
try {
const [capturePage, itemPage] = await Promise.all([
const [capturePage, itemPage, activityPage] = await Promise.all([
listCaptures({ limit: PAGE_SIZE, converted: convertedQuery, q: captureSearchQuery }),
listItems({ limit: PAGE_SIZE, status: statusQuery, type: typeQuery, q: itemSearchQuery }),
listActivities({ limit: 7 }),
])
setCaptures(capturePage.data)
setItems(itemPage.data)
setCaptureCursor(capturePage.nextCursor)
setItemCursor(itemPage.nextCursor)
setActivities(activityPage.data)
setActivityCursor(activityPage.nextCursor)

const [deletedCapturePage, deletedItemPage] = await Promise.allSettled([
listCaptures({ limit: PAGE_SIZE, deleted: true }),
Expand All @@ -181,8 +221,10 @@ function WorkspacePage() {
} catch (error) {
const message = error instanceof Error ? error.message : ui.failedToLoad
setCaptureLoadError(message)
setActivityLoadError(message)
} finally {
setIsLoadingCaptures(false)
setIsLoadingActivities(false)
}
}

Expand Down Expand Up @@ -222,6 +264,16 @@ function WorkspacePage() {
}
}, [convertedQuery, captureSearchQuery, isSubmittingCapture])

const refreshActivities = async () => {
try {
const page = await listActivities({ limit: 7 })
setActivities(page.data)
setActivityCursor(page.nextCursor)
} catch {
// activity refresh errors are non-blocking for primary workflow
}
}

const refreshDeletedData = async () => {
try {
const [deletedCapturePage, deletedItemPage] = await Promise.all([
Expand All @@ -243,6 +295,7 @@ function WorkspacePage() {
if (captureFilter !== 'converted') {
setCaptures((currentCaptures) => [createdCapture, ...currentCaptures])
}
void refreshActivities()
} catch (error) {
const message = error instanceof Error ? error.message : ui.failedToCreate
setCaptureSubmitError(message)
Expand Down Expand Up @@ -302,6 +355,7 @@ function WorkspacePage() {
}
return currentCaptures.map((capture) => (capture.id === captureId ? refreshedCapture : capture))
})
void refreshActivities()
} catch (error) {
const message = error instanceof Error ? error.message : ui.failedToConvert
setCaptureConvertError(message)
Expand All @@ -321,6 +375,7 @@ function WorkspacePage() {
setFocusedCapture(null)
}
await refreshDeletedData()
void refreshActivities()
} catch (error) {
const message = error instanceof Error ? error.message : ui.failedToLoad
setCaptureConvertError(message)
Expand All @@ -337,6 +392,7 @@ function WorkspacePage() {
setItems((current) => current.filter((item) => item.id !== itemId))
setCaptures((current) => current.filter((capture) => capture.id !== sourceCaptureId))
await refreshDeletedData()
void refreshActivities()
} catch (error) {
const message = error instanceof Error ? error.message : ui.failedToUndo
setItemActionError(message)
Expand All @@ -359,6 +415,7 @@ function WorkspacePage() {
item.sourceCaptureId === captureId ? { ...item, content: updatedCapture.content } : item,
),
)
void refreshActivities()
} catch (error) {
const message = error instanceof Error ? error.message : ui.failedToConvert
setCaptureConvertError(message)
Expand All @@ -378,6 +435,7 @@ function WorkspacePage() {
capture.id === updatedItem.sourceCaptureId ? { ...capture, content: updatedItem.content } : capture,
),
)
void refreshActivities()
} catch (error) {
const message = error instanceof Error ? error.message : ui.failedToConvert
setItemActionError(message)
Expand Down Expand Up @@ -424,6 +482,7 @@ function WorkspacePage() {
}

await refreshDeletedData()
void refreshActivities()
} catch (error) {
const message = error instanceof Error ? error.message : ui.failedToRestore
setRecycleBinError(message)
Expand Down Expand Up @@ -472,6 +531,7 @@ function WorkspacePage() {
}

await refreshDeletedData()
void refreshActivities()
} catch (error) {
const message = error instanceof Error ? error.message : ui.failedToRestore
setRecycleBinError(message)
Expand Down Expand Up @@ -529,6 +589,7 @@ function WorkspacePage() {
}
return [refreshedCapture, ...currentCaptures]
})
void refreshActivities()
} catch (error) {
const message =
error instanceof Error &&
Expand Down Expand Up @@ -561,6 +622,7 @@ function WorkspacePage() {
} else {
setItems((current) => current.map((it) => (it.id === itemId ? updated : it)))
}
void refreshActivities()
} catch (error) {
// rollback
setItems(prevItems)
Expand Down Expand Up @@ -648,6 +710,25 @@ function WorkspacePage() {
}
}

const handleLoadMoreActivities = async () => {
if (!activityCursor || isLoadingMoreActivities) return
setIsLoadingMoreActivities(true)
setActivityLoadError(null)
try {
const page = await listActivities({
limit: 7,
cursor: activityCursor,
})
setActivities((current) => [...current, ...page.data])
setActivityCursor(page.nextCursor)
} catch (error) {
const message = error instanceof Error ? error.message : ui.failedToLoad
setActivityLoadError(message)
} finally {
setIsLoadingMoreActivities(false)
}
}

return (
<main className={`workspace-page theme-${theme}`}>
<div className="layout">
Expand Down Expand Up @@ -815,16 +896,34 @@ function WorkspacePage() {
</article>
</div>
) : (
<ol className="pulse-timeline">
{timelineEvents.length === 0 ? <li className="pulse-empty">{ui.projectPulseEmptyTimeline}</li> : null}
{timelineEvents.map((event) => (
<li key={event.id} className={`timeline-event ${event.kind}`}>
<time>{new Date(event.at).toLocaleString(ui.localeCode)}</time>
<span>{event.kind === 'captured' ? ui.timelineCaptured : ui.timelineConverted}</span>
<p>{event.content}</p>
</li>
))}
</ol>
<>
{isLoadingActivities ? <p className="pulse-empty">{ui.loadingTimeline}</p> : null}
{activityLoadError ? <p className="error">{activityLoadError}</p> : null}
<ol className="pulse-timeline">
{timelineEvents.length === 0 && !isLoadingActivities ? (
<li className="pulse-empty">{ui.projectPulseEmptyTimeline}</li>
) : null}
{timelineEvents.map((event) => (
<li key={event.id} className={`timeline-event ${event.kind}`}>
<time>{new Date(event.at).toLocaleString(ui.localeCode)}</time>
<span>{event.label}</span>
<p>{event.content}</p>
</li>
))}
</ol>
{activityCursor ? (
<button
type="button"
className="secondary"
onClick={() => {
void handleLoadMoreActivities()
}}
disabled={isLoadingMoreActivities}
>
{isLoadingMoreActivities ? ui.loadingMore : ui.loadMore}
</button>
) : null}
</>
)}
</>
) : null}
Expand Down
Loading
Loading