Compact request activity into a live summary and modal history

This commit is contained in:
2026-09-06 22:20:33 +12:00
parent 009bb35032
commit 74c49fad5b
10 changed files with 223 additions and 71 deletions
+2
View File
@@ -8,6 +8,7 @@
- Keep technical IDs and pipeline labels monospace. Use sentence case for ordinary labels and descriptions.
- Preserve the six-stage request pipeline: three columns on desktop, two on tablet, one on narrow screens. Issue reports remain a right-hand column on desktop and stack on smaller screens.
- A media repair starts a new collection cycle: preserve Requested/Approved and unaffected TV episodes, but do not reuse an old torrent or Jellyfin entry to mark the replacement ready. Show Pending → Downloading → Indexing → Available from collector/file evidence. Subtitle-only repairs must not reset video availability.
- Request action feedback uses a compact Latest activity card beside download status; full event history opens in a native modal dialog without expanding the page. Keep messages outcome-based and do not equate a successful service response with playable media.
- Desktop navigation stays at the top; mobile navigation stays at the bottom. Dialogs must remain clear of both.
- The guided issue form uses `portal/IssueFlowStep.tsx`: show one expanded step, collapse completed answers into Change rows, and keep repairs behind the final submit action. Movies use the selected title's managed file directly; only TV needs a season/episode picker. Multi-select controls must expose `aria-pressed` and a visible selected state.
@@ -19,6 +20,7 @@ Build the frontend before reviewing. The scripts in `scripts/` run using Node an
- `review_account_ui.cjs`: fixture-only login and profile interaction checks.
- `review_issue_flow_ui.cjs`: fixture-only movie/TV issue flow, multi-device report payloads, subtitle routing, permissions, and collapsed-step navigation.
- `review_repair_pipeline_ui.cjs`: fixture-only movie/TV replacement stages, old-cycle progress rejection, desktop/mobile layout and automatic availability transitions.
- `review_activity_ui.cjs`: fixture-only latest activity, desktop/tablet/mobile placement, modal history, keyboard dismissal and focus restoration.
- `review_settings_ui.cjs`: settings state, region-only saves, secret preservation, responsive controls and issue dialog placement.
The layout/settings reviews accept `REVIEW_BASE`, `REVIEW_LIVE_BASE`, `REVIEW_PLAYWRIGHT`, and `REVIEW_DIR`. Provide an authorised short-lived session through `REVIEW_SESSION` as `{ "name": "cookie-name", "token": "..." }` in the process environment, never in a committed file. Live writes are blocked; submission checks use fixtures. Screenshots may contain account information and must stay outside the repository.
@@ -0,0 +1,57 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import './latest-activity.css'
type Event = { id: string; service: string; state: string; message: string; started_at?: string; finished_at?: string }
type Operation = { id: string; label: string; status: string; events: Event[] }
export default function LatestActivity({ operation, besideDownload, onDismiss }: {
operation: Operation; besideDownload: boolean; onDismiss: () => void
}) {
const dialog = useRef<HTMLDialogElement>(null)
const trigger = useRef<HTMLButtonElement>(null)
const [open, setOpen] = useState(false)
const latest = [...operation.events].sort((a, b) =>
(a.finished_at ?? a.started_at ?? '').localeCompare(b.finished_at ?? b.started_at ?? '')
).at(-1)
const status = operation.status === 'running' ? 'Working' : operation.status === 'complete' ? 'Finished' : 'Needs attention'
useEffect(() => {
if (!open) return
const element = dialog.current
element?.showModal()
const previous = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
element?.close()
document.body.style.overflow = previous
trigger.current?.focus()
}
}, [open])
return (
<div className={`request-overview-block latest-activity ${besideDownload ? 'beside-download' : 'full-row'}`}>
<button ref={trigger} type="button" className="latest-activity-trigger" onClick={() => setOpen(true)} aria-haspopup="dialog" aria-expanded={open}>
<span className="latest-activity-heading"><span className="request-overview-label">Latest activity</span><span className={`latest-activity-badge is-${operation.status}`}>{status}</span></span>
<span className="latest-activity-message" role="status">{latest?.message || 'Getting ready to check your request…'}</span>
<span className="latest-activity-more">View all activity ({operation.events.length}) <span aria-hidden="true"></span></span>
</button>
<dialog ref={dialog} className="activity-dialog" aria-labelledby="activity-dialog-title" onCancel={() => setOpen(false)} onClose={() => setOpen(false)} onClick={(event) => { if (event.target === event.currentTarget) setOpen(false) }}>
<div className="activity-dialog-content">
<header>
<div><span className="request-overview-label">Activity details</span><h2 id="activity-dialog-title">{operation.label}</h2><small>{status} · {operation.events.length} updates</small></div>
<button type="button" onClick={() => setOpen(false)} autoFocus>Close</button>
</header>
<ol className="activity-dialog-events" aria-label="All activity, oldest first">
{operation.events.map((event) => <li key={event.id} className={`is-${event.state}`}>
<span className="activity-event-state">{event.state === 'active' ? 'Working' : event.state === 'error' ? 'Needs attention' : 'Done'}</span>
<div><strong>{event.service}</strong><p>{event.message}</p></div>
</li>)}
</ol>
{operation.status !== 'running' && <footer><button type="button" onClick={() => { setOpen(false); onDismiss() }}>Dismiss activity</button></footer>}
</div>
</dialog>
</div>
)
}
@@ -0,0 +1,28 @@
.latest-activity.beside-download { grid-column: 7 / -1; grid-row: 2; }
.latest-activity.full-row { grid-column: 1 / -1; }
.latest-activity .latest-activity-trigger { display: grid; gap: .6rem; width: 100%; padding: 0; border: 0; background: transparent; color: inherit; text-align: left; box-shadow: none; text-transform: none; }
.latest-activity-trigger:focus-visible { outline: 2px solid var(--ops-accent, #83d7f7); outline-offset: 6px; }
.latest-activity-heading { display: flex; align-items: center; justify-content: space-between; gap: .75rem; }
.latest-activity-message { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; font-size: .875rem; line-height: 1.5; font-weight: 400; }
.latest-activity-more { color: var(--ops-accent, #83d7f7); font-size: .75rem; }
.latest-activity-badge { font-size: .7rem; font-weight: 500; color: var(--ops-muted, #bbb); }
.latest-activity-badge.is-error { color: #ff9b9b; }
.latest-activity-badge.is-complete { color: #55dec0; }
.activity-dialog { position: fixed; inset: 0; margin: auto; width: min(720px, calc(100vw - 32px)); max-width: none; max-height: min(760px, calc(100dvh - 40px)); padding: 0; border: 1px solid var(--ops-border, #444); border-radius: 16px; color: var(--ops-text, #eee); background: var(--ops-surface, #1c1c1e); overflow: auto; box-shadow: 0 24px 80px #0008; }
.activity-dialog::backdrop { background: #000a; backdrop-filter: blur(5px); }
.activity-dialog-content { padding: 1.25rem; }
.activity-dialog header { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; }
.activity-dialog h2 { font-size: 1.2rem; margin: .4rem 0; }
.activity-dialog small { color: var(--ops-muted, #bbb); }
.activity-dialog-events { list-style: none; padding: 0; margin: 1.25rem 0 0; display: grid; gap: .65rem; }
.activity-dialog-events li { display: grid; grid-template-columns: 85px minmax(0, 1fr); gap: .8rem; padding: .9rem; border: 1px solid var(--ops-border, #444); border-radius: 10px; }
.activity-dialog-events strong { font-size: .8rem; }
.activity-dialog-events p { margin: .3rem 0 0; font-size: .875rem; line-height: 1.5; overflow-wrap: anywhere; }
.activity-event-state { font-size: .7rem; color: #55dec0; }
.is-error > .activity-event-state { color: #ff9b9b; }
.is-active > .activity-event-state { color: #83d7f7; }
.activity-dialog footer { display: flex; justify-content: flex-end; margin-top: 1rem; }
@media (max-width: 720px) {
.latest-activity.beside-download { grid-column: 1 / -1; grid-row: auto; }
.activity-dialog-events li { grid-template-columns: 1fr; gap: .4rem; }
}
+20 -45
View File
@@ -1,6 +1,7 @@
'use client'
import PageHeading from '../../ui/PageHeading'
import LatestActivity from './LatestActivity'
import Image from 'next/image'
import { useParams, useRouter } from 'next/navigation'
@@ -200,12 +201,6 @@ const torrentProgress = (torrent: Record<string, any>) => {
const formatProgress = (progress: number) => `${progress.toFixed(1).replace(/\.0$/, '')}% complete`
const formatDuration = (duration?: number | null) => {
if (typeof duration !== 'number' || Number.isNaN(duration)) return null
if (duration < 1000) return `${Math.max(0, Math.round(duration))}ms`
return `${(duration / 1000).toFixed(1)}s`
}
const mergeLiveDownload = (current: Snapshot, live: LiveDownloadProgress): Snapshot => {
if (String(current.request_id) !== String(live.request_id)) return current
if ((current.presentation?.repairCycle ?? null) !== (live.repairCycle ?? null)) return current
@@ -583,7 +578,10 @@ export default function RequestTimelinePage() {
})
if (!stopped && progressResponse.ok) {
const progress = await progressResponse.json()
if (Array.isArray(progress?.events)) setOperationProgress(progress)
if (Array.isArray(progress?.events)) {
setOperationProgress(progress)
return progress as OperationProgress
}
}
} catch (error) {
if (!stopped) console.error(error)
@@ -594,8 +592,21 @@ export default function RequestTimelinePage() {
const timer = window.setInterval(() => void refreshProgress(), 650)
try {
const response = await request
await refreshProgress()
const finalProgress = await refreshProgress()
if (!finalProgress || finalProgress.status === 'running') {
setOperationProgress((current) => current?.id === operationId ? {
...current, status: response.ok ? 'complete' : 'error',
events: [...current.events, { id: 'result', service: 'Magent', state: response.ok ? 'complete' : 'error',
message: response.ok ? 'This action has finished. Check the request status for what happens next.' : 'This action could not be completed. Check the message beside the request controls.' }],
} : current)
}
return response
} catch (error) {
setOperationProgress((current) => current?.id === operationId ? {
...current, status: 'error', events: [...current.events, { id: 'connection-error', service: 'Magent', state: 'error',
message: 'The connection was interrupted. Recheck the request before trying the action again—it may already have started.' }],
} : current)
throw error
} finally {
stopped = true
window.clearInterval(timer)
@@ -753,6 +764,7 @@ export default function RequestTimelinePage() {
{download?.lastSeenAt && !download?.torrents?.length && <small>Last observed {formatWhen(download.lastSeenAt)}</small>}
</div>
)}
{operationProgress && <LatestActivity operation={operationProgress} besideDownload={downloadVisible} onDismiss={() => setOperationProgress(null)} />}
<div className="request-overview-block request-next-step">
<div className="request-next-step-main">
<div className="request-next-step-copy">
@@ -793,43 +805,6 @@ export default function RequestTimelinePage() {
{actionError ?? actionMessage}
</div>
)}
{operationProgress && (
<div className={`request-operation-progress is-${operationProgress.status}`} aria-live="polite">
<div className="request-operation-heading">
<div>
<span className="request-overview-label">Remote activity</span>
<strong>{operationProgress.label}</strong>
</div>
<div className="request-operation-heading-actions">
{formatDuration(operationProgress.duration_ms) && (
<span>{formatDuration(operationProgress.duration_ms)}</span>
)}
<span className={`request-operation-status is-${operationProgress.status}`}>
{operationProgress.status === 'running' ? 'In progress' : operationProgress.status}
</span>
{operationProgress.status !== 'running' && (
<button type="button" onClick={() => setOperationProgress(null)}>Dismiss</button>
)}
</div>
</div>
<div className="request-operation-events">
{operationProgress.events.map((event) => (
<div className={`request-operation-event is-${event.state}`} key={event.id}>
<i aria-hidden="true" />
<div>
<strong>{event.service}</strong>
<span>{event.message}</span>
</div>
<small>
{event.state === 'active'
? 'Waiting…'
: formatDuration(event.duration_ms) ?? (event.status_code ? `HTTP ${event.status_code}` : 'Done')}
</small>
</div>
))}
</div>
</div>
)}
</section>
{repairActivity?.visible && (