таски готовы
This commit is contained in:
63
frontend/src/lib/components/reports/ReportCard.svelte
Normal file
63
frontend/src/lib/components/reports/ReportCard.svelte
Normal file
@@ -0,0 +1,63 @@
|
||||
<!-- [DEF:ReportCard:Component] -->
|
||||
<script>
|
||||
/**
|
||||
* @TIER: CRITICAL
|
||||
* @SEMANTICS: reports, card, type-profile, accessibility, fallback
|
||||
* @PURPOSE: Render one report with explicit textual type label and profile-driven visual variant.
|
||||
* @LAYER: UI
|
||||
* @RELATION: DEPENDS_ON -> frontend/src/lib/components/reports/reportTypeProfiles.js
|
||||
* @RELATION: DEPENDS_ON -> frontend/src/lib/i18n/index.ts
|
||||
* @INVARIANT: Unknown task type always uses fallback profile.
|
||||
*
|
||||
* @UX_STATE: Ready -> Card displays summary/status/type.
|
||||
* @UX_RECOVERY: Missing fields are rendered with explicit placeholder text.
|
||||
*/
|
||||
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import { getReportTypeProfile } from './reportTypeProfiles.js';
|
||||
|
||||
let { report, selected = false } = $props();
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
const profile = $derived(getReportTypeProfile(report?.task_type));
|
||||
const profileLabel = $derived(typeof profile?.label === 'function' ? profile.label() : profile?.label);
|
||||
|
||||
function getStatusClass(status) {
|
||||
if (status === 'success') return 'bg-green-100 text-green-700';
|
||||
if (status === 'failed') return 'bg-red-100 text-red-700';
|
||||
if (status === 'in_progress') return 'bg-blue-100 text-blue-700';
|
||||
if (status === 'partial') return 'bg-amber-100 text-amber-700';
|
||||
return 'bg-slate-100 text-slate-700';
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return $t.reports?.not_provided || 'Not provided';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return $t.reports?.not_provided || 'Not provided';
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
function onSelect() {
|
||||
dispatch('select', { report });
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="w-full rounded-lg border p-3 text-left transition hover:bg-slate-50 {selected ? 'border-blue-400 bg-blue-50' : 'border-slate-200 bg-white'}"
|
||||
on:click={onSelect}
|
||||
aria-label={`Report ${report?.report_id || ''} type ${profileLabel || ($t.reports?.unknown_type || 'Other / Unknown Type')}`}
|
||||
>
|
||||
<div class="mb-2 flex items-center justify-between gap-2">
|
||||
<span class="rounded px-2 py-0.5 text-xs font-semibold {profile?.variant || 'bg-slate-100 text-slate-700'}">
|
||||
{profileLabel || ($t.reports?.unknown_type || 'Other / Unknown Type')}
|
||||
</span>
|
||||
<span class="rounded px-2 py-0.5 text-xs font-semibold {getStatusClass(report?.status)}">
|
||||
{report?.status || ($t.reports?.not_provided || 'Not provided')}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm font-medium text-slate-800">{report?.summary || ($t.reports?.not_provided || 'Not provided')}</p>
|
||||
<p class="mt-1 text-xs text-slate-500">{formatDate(report?.updated_at)}</p>
|
||||
</button>
|
||||
|
||||
<!-- [/DEF:ReportCard:Component] -->
|
||||
66
frontend/src/lib/components/reports/ReportDetailPanel.svelte
Normal file
66
frontend/src/lib/components/reports/ReportDetailPanel.svelte
Normal file
@@ -0,0 +1,66 @@
|
||||
<!-- [DEF:ReportDetailPanel:Component] -->
|
||||
<script>
|
||||
/**
|
||||
* @TIER: CRITICAL
|
||||
* @SEMANTICS: reports, detail, diagnostics, next-actions, placeholders
|
||||
* @PURPOSE: Display detailed report context with diagnostics and actionable recovery guidance.
|
||||
* @LAYER: UI
|
||||
* @RELATION: DEPENDS_ON -> frontend/src/lib/i18n/index.ts
|
||||
* @INVARIANT: Failed/partial reports surface actionable hints when available.
|
||||
*
|
||||
* @UX_STATE: Ready -> Report detail content visible.
|
||||
* @UX_RECOVERY: Failed/partial report shows next actions and placeholder-safe diagnostics.
|
||||
*/
|
||||
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
let { detail = null } = $props();
|
||||
|
||||
function notProvided(value) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return $t.reports?.not_provided || 'Not provided';
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return $t.reports?.not_provided || 'Not provided';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return $t.reports?.not_provided || 'Not provided';
|
||||
return date.toLocaleString();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-lg border border-slate-200 bg-white p-4">
|
||||
<h3 class="mb-3 text-sm font-semibold text-slate-700">{$t.reports?.view_details || 'View details'}</h3>
|
||||
|
||||
{#if !detail || !detail.report}
|
||||
<p class="text-sm text-slate-500">{$t.reports?.not_provided || 'Not provided'}</p>
|
||||
{:else}
|
||||
<div class="space-y-2 text-sm">
|
||||
<p><span class="text-slate-500">ID:</span> {notProvided(detail.report.report_id)}</p>
|
||||
<p><span class="text-slate-500">Type:</span> {notProvided(detail.report.task_type)}</p>
|
||||
<p><span class="text-slate-500">Status:</span> {notProvided(detail.report.status)}</p>
|
||||
<p><span class="text-slate-500">Summary:</span> {notProvided(detail.report.summary)}</p>
|
||||
<p><span class="text-slate-500">Updated:</span> {formatDate(detail.report.updated_at)}</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<p class="mb-1 text-xs font-semibold uppercase text-slate-500">Diagnostics</p>
|
||||
<pre class="max-h-48 overflow-auto rounded bg-slate-50 p-2 text-xs text-slate-700">{JSON.stringify(detail.diagnostics || { note: $t.reports?.not_provided || 'Not provided' }, null, 2)}</pre>
|
||||
</div>
|
||||
|
||||
{#if (detail.next_actions && detail.next_actions.length > 0) || (detail.report.error_context && detail.report.error_context.next_actions && detail.report.error_context.next_actions.length > 0)}
|
||||
<div class="mt-4">
|
||||
<p class="mb-1 text-xs font-semibold uppercase text-slate-500">Next actions</p>
|
||||
<ul class="list-disc space-y-1 pl-5 text-sm text-slate-700">
|
||||
{#each (detail.next_actions && detail.next_actions.length > 0 ? detail.next_actions : detail.report.error_context.next_actions) as action}
|
||||
<li>{action}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- [/DEF:ReportDetailPanel:Component] -->
|
||||
37
frontend/src/lib/components/reports/ReportsList.svelte
Normal file
37
frontend/src/lib/components/reports/ReportsList.svelte
Normal file
@@ -0,0 +1,37 @@
|
||||
<!-- [DEF:ReportsList:Component] -->
|
||||
<script>
|
||||
/**
|
||||
* @TIER: CRITICAL
|
||||
* @SEMANTICS: reports, list, card, unified, mixed-types
|
||||
* @PURPOSE: Render unified list of normalized reports with canonical minimum fields.
|
||||
* @LAYER: UI
|
||||
* @RELATION: DEPENDS_ON -> frontend/src/lib/i18n/index.ts
|
||||
* @INVARIANT: Every rendered row shows task_type label, status, summary, and updated_at.
|
||||
*
|
||||
* @UX_STATE: Ready -> Mixed-type list visible and scannable.
|
||||
* @UX_FEEDBACK: Click on report emits select event.
|
||||
* @UX_RECOVERY: Unknown/missing values rendered with explicit placeholders.
|
||||
*/
|
||||
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import ReportCard from './ReportCard.svelte';
|
||||
|
||||
let { reports = [], selectedReportId = null } = $props();
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
function handleSelect(event) {
|
||||
dispatch('select', { report: event.detail.report });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-2">
|
||||
{#each reports as report (report.report_id)}
|
||||
<ReportCard
|
||||
{report}
|
||||
selected={selectedReportId === report.report_id}
|
||||
on:select={handleSelect}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- [/DEF:ReportsList:Component] -->
|
||||
@@ -0,0 +1,90 @@
|
||||
// [DEF:reports.fixtures:Module]
|
||||
// @TIER: STANDARD
|
||||
// @SEMANTICS: reports, fixtures, test-data
|
||||
// @PURPOSE: Shared frontend fixtures for unified reports states.
|
||||
// @LAYER: UI
|
||||
|
||||
export const mixedTaskReports = [
|
||||
{
|
||||
report_id: "rep-001",
|
||||
task_id: "task-001",
|
||||
task_type: "llm_verification",
|
||||
status: "success",
|
||||
started_at: "2026-02-22T09:00:00Z",
|
||||
updated_at: "2026-02-22T09:00:30Z",
|
||||
summary: "LLM verification completed",
|
||||
details: { checks_performed: 12, issues_found: 1 }
|
||||
},
|
||||
{
|
||||
report_id: "rep-002",
|
||||
task_id: "task-002",
|
||||
task_type: "backup",
|
||||
status: "failed",
|
||||
started_at: "2026-02-22T09:10:00Z",
|
||||
updated_at: "2026-02-22T09:11:00Z",
|
||||
summary: "Backup failed due to storage limit",
|
||||
error_context: { message: "Not enough disk space", next_actions: ["Free storage", "Retry backup"] }
|
||||
},
|
||||
{
|
||||
report_id: "rep-003",
|
||||
task_id: "task-003",
|
||||
task_type: "migration",
|
||||
status: "in_progress",
|
||||
started_at: "2026-02-22T09:20:00Z",
|
||||
updated_at: "2026-02-22T09:21:00Z",
|
||||
summary: "Migration running",
|
||||
details: { progress_percent: 42 }
|
||||
},
|
||||
{
|
||||
report_id: "rep-004",
|
||||
task_id: "task-004",
|
||||
task_type: "documentation",
|
||||
status: "partial",
|
||||
started_at: "2026-02-22T09:30:00Z",
|
||||
updated_at: "2026-02-22T09:31:00Z",
|
||||
summary: "Documentation generated with partial coverage",
|
||||
error_context: { message: "Missing metadata for 3 columns", next_actions: ["Review missing metadata"] }
|
||||
}
|
||||
];
|
||||
|
||||
export const unknownTypePartialPayload = [
|
||||
{
|
||||
report_id: "rep-unknown-001",
|
||||
task_id: "task-unknown-001",
|
||||
task_type: "unknown",
|
||||
status: "failed",
|
||||
updated_at: "2026-02-22T10:00:00Z",
|
||||
summary: "Unknown task type failed",
|
||||
details: null
|
||||
},
|
||||
{
|
||||
report_id: "rep-partial-001",
|
||||
task_id: "task-partial-001",
|
||||
task_type: "backup",
|
||||
status: "success",
|
||||
updated_at: "2026-02-22T10:05:00Z",
|
||||
summary: "Backup completed",
|
||||
details: {}
|
||||
}
|
||||
];
|
||||
|
||||
export const reportCollections = {
|
||||
ready: {
|
||||
items: mixedTaskReports,
|
||||
total: mixedTaskReports.length,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
has_next: false,
|
||||
applied_filters: { page: 1, page_size: 20, sort_by: "updated_at", sort_order: "desc" }
|
||||
},
|
||||
empty: {
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
has_next: false,
|
||||
applied_filters: { page: 1, page_size: 20, sort_by: "updated_at", sort_order: "desc" }
|
||||
}
|
||||
};
|
||||
|
||||
// [/DEF:reports.fixtures:Module]
|
||||
@@ -0,0 +1,45 @@
|
||||
// [DEF:frontend.src.lib.components.reports.__tests__.report_detail.integration:Module]
|
||||
// @TIER: CRITICAL
|
||||
// @SEMANTICS: tests, reports, detail, recovery-guidance, integration
|
||||
// @PURPOSE: Validate detail-panel behavior for failed reports and recovery guidance visibility.
|
||||
// @LAYER: UI (Tests)
|
||||
// @RELATION: TESTS -> frontend/src/lib/components/reports/ReportDetailPanel.svelte
|
||||
// @RELATION: TESTS -> frontend/src/routes/reports/+page.svelte
|
||||
// @INVARIANT: Failed report detail exposes actionable next actions when available.
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mixedTaskReports } from './fixtures/reports.fixtures.js';
|
||||
|
||||
function buildFailedDetailFixture() {
|
||||
const failed = mixedTaskReports.find((item) => item.status === 'failed');
|
||||
return {
|
||||
report: failed,
|
||||
diagnostics: {
|
||||
error_context: failed?.error_context || { message: 'Not provided', next_actions: [] }
|
||||
},
|
||||
next_actions: failed?.error_context?.next_actions || []
|
||||
};
|
||||
}
|
||||
|
||||
describe('report detail integration - failed report guidance', () => {
|
||||
it('failed fixture includes error context and next actions', () => {
|
||||
const detail = buildFailedDetailFixture();
|
||||
|
||||
expect(detail.report).toBeTruthy();
|
||||
expect(detail.report.status).toBe('failed');
|
||||
expect(detail.diagnostics).toBeTruthy();
|
||||
expect(Array.isArray(detail.next_actions)).toBe(true);
|
||||
expect(detail.next_actions.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('next actions are human-readable strings for operator recovery', () => {
|
||||
const detail = buildFailedDetailFixture();
|
||||
|
||||
for (const action of detail.next_actions) {
|
||||
expect(typeof action).toBe('string');
|
||||
expect(action.trim().length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// [/DEF:frontend.src.lib.components.reports.__tests__.report_detail.integration:Module]
|
||||
@@ -0,0 +1,32 @@
|
||||
// [DEF:frontend.src.lib.components.reports.__tests__.report_type_profiles:Module]
|
||||
// @TIER: CRITICAL
|
||||
// @SEMANTICS: tests, reports, type-profiles, fallback
|
||||
// @PURPOSE: Validate report type profile mapping and unknown fallback behavior.
|
||||
// @LAYER: UI (Tests)
|
||||
// @RELATION: TESTS -> frontend/src/lib/components/reports/reportTypeProfiles.js
|
||||
// @INVARIANT: Unknown task_type always resolves to the fallback profile.
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getReportTypeProfile, REPORT_TYPE_PROFILES } from '../reportTypeProfiles.js';
|
||||
|
||||
describe('report type profiles', () => {
|
||||
it('returns dedicated profiles for known task types', () => {
|
||||
expect(getReportTypeProfile('llm_verification').key).toBe('llm_verification');
|
||||
expect(getReportTypeProfile('backup').key).toBe('backup');
|
||||
expect(getReportTypeProfile('migration').key).toBe('migration');
|
||||
expect(getReportTypeProfile('documentation').key).toBe('documentation');
|
||||
});
|
||||
|
||||
it('returns fallback profile for unknown task type', () => {
|
||||
const profile = getReportTypeProfile('something_new');
|
||||
expect(profile.key).toBe('unknown');
|
||||
expect(profile.fallback).toBe(true);
|
||||
});
|
||||
|
||||
it('contains exactly one fallback profile in registry', () => {
|
||||
const fallbackCount = Object.values(REPORT_TYPE_PROFILES).filter((p) => p.fallback === true).length;
|
||||
expect(fallbackCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// [/DEF:frontend.src.lib.components.reports.__tests__.report_type_profiles:Module]
|
||||
@@ -0,0 +1,48 @@
|
||||
// [DEF:frontend.src.lib.components.reports.__tests__.reports_filter_performance:Module]
|
||||
// @TIER: STANDARD
|
||||
// @SEMANTICS: tests, reports, performance, filtering
|
||||
// @PURPOSE: Guard test for report filter responsiveness on moderate in-memory dataset.
|
||||
// @LAYER: UI (Tests)
|
||||
// @RELATION: TESTS -> frontend/src/routes/reports/+page.svelte
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
function applyFilters(items, { taskType = 'all', status = 'all' } = {}) {
|
||||
return items.filter((item) => {
|
||||
const typeMatch = taskType === 'all' || item.task_type === taskType;
|
||||
const statusMatch = status === 'all' || item.status === status;
|
||||
return typeMatch && statusMatch;
|
||||
});
|
||||
}
|
||||
|
||||
function makeDataset(size = 2000) {
|
||||
const taskTypes = ['llm_verification', 'backup', 'migration', 'documentation'];
|
||||
const statuses = ['success', 'failed', 'in_progress', 'partial'];
|
||||
const out = [];
|
||||
for (let i = 0; i < size; i += 1) {
|
||||
out.push({
|
||||
report_id: `r-${i}`,
|
||||
task_id: `t-${i}`,
|
||||
task_type: taskTypes[i % taskTypes.length],
|
||||
status: statuses[i % statuses.length],
|
||||
summary: `Report ${i}`,
|
||||
updated_at: '2026-02-22T10:00:00Z'
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('reports filter performance guard', () => {
|
||||
it('applies task_type+status filter quickly on 2000 records', () => {
|
||||
const dataset = makeDataset(2000);
|
||||
|
||||
const start = Date.now();
|
||||
const result = applyFilters(dataset, { taskType: 'migration', status: 'failed' });
|
||||
const duration = Date.now() - start;
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(duration).toBeLessThan(100);
|
||||
});
|
||||
});
|
||||
|
||||
// [/DEF:frontend.src.lib.components.reports.__tests__.reports_filter_performance:Module]
|
||||
@@ -0,0 +1,40 @@
|
||||
// [DEF:frontend.src.lib.components.reports.__tests__.reports_page.integration:Module]
|
||||
// @TIER: CRITICAL
|
||||
// @SEMANTICS: tests, reports, integration, mixed-types, rendering
|
||||
// @PURPOSE: Integration-style checks for unified mixed-type reports rendering expectations.
|
||||
// @LAYER: UI (Tests)
|
||||
// @RELATION: TESTS -> frontend/src/routes/reports/+page.svelte
|
||||
// @RELATION: TESTS -> frontend/src/lib/components/reports/ReportsList.svelte
|
||||
// @INVARIANT: Mixed fixture includes all supported report types in one list.
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mixedTaskReports } from './fixtures/reports.fixtures.js';
|
||||
|
||||
function collectVisibleTypeLabels(items) {
|
||||
return items.map((item) => item.task_type);
|
||||
}
|
||||
|
||||
describe('Reports page integration - unified mixed type rendering', () => {
|
||||
it('contains mixed reports from all primary task types in one payload', () => {
|
||||
const labels = collectVisibleTypeLabels(mixedTaskReports);
|
||||
|
||||
expect(labels).toContain('llm_verification');
|
||||
expect(labels).toContain('backup');
|
||||
expect(labels).toContain('migration');
|
||||
expect(labels).toContain('documentation');
|
||||
expect(mixedTaskReports.length).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
|
||||
it('ensures canonical minimum fields are present for each report item', () => {
|
||||
for (const report of mixedTaskReports) {
|
||||
expect(typeof report.report_id).toBe('string');
|
||||
expect(typeof report.task_id).toBe('string');
|
||||
expect(typeof report.task_type).toBe('string');
|
||||
expect(typeof report.status).toBe('string');
|
||||
expect(typeof report.summary).toBe('string');
|
||||
expect(report.updated_at).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// [/DEF:frontend.src.lib.components.reports.__tests__.reports_page.integration:Module]
|
||||
59
frontend/src/lib/components/reports/reportTypeProfiles.js
Normal file
59
frontend/src/lib/components/reports/reportTypeProfiles.js
Normal file
@@ -0,0 +1,59 @@
|
||||
// [DEF:frontend.src.lib.components.reports.reportTypeProfiles:Module]
|
||||
// @TIER: CRITICAL
|
||||
// @SEMANTICS: reports, ui, profiles, fallback, mapping
|
||||
// @PURPOSE: Deterministic mapping from report task_type to visual profile with one fallback.
|
||||
// @LAYER: UI
|
||||
// @RELATION: DEPENDS_ON -> frontend/src/lib/i18n/index.ts
|
||||
// @INVARIANT: Unknown type always resolves to fallback profile.
|
||||
|
||||
import { _ } from '$lib/i18n';
|
||||
|
||||
export const REPORT_TYPE_PROFILES = {
|
||||
llm_verification: {
|
||||
key: 'llm_verification',
|
||||
label: 'LLM',
|
||||
variant: 'bg-violet-100 text-violet-700',
|
||||
icon: 'sparkles',
|
||||
fallback: false
|
||||
},
|
||||
backup: {
|
||||
key: 'backup',
|
||||
label: () => _('nav.backups'),
|
||||
variant: 'bg-emerald-100 text-emerald-700',
|
||||
icon: 'archive',
|
||||
fallback: false
|
||||
},
|
||||
migration: {
|
||||
key: 'migration',
|
||||
label: () => _('nav.migration'),
|
||||
variant: 'bg-amber-100 text-amber-700',
|
||||
icon: 'shuffle',
|
||||
fallback: false
|
||||
},
|
||||
documentation: {
|
||||
key: 'documentation',
|
||||
label: 'Documentation',
|
||||
variant: 'bg-sky-100 text-sky-700',
|
||||
icon: 'file-text',
|
||||
fallback: false
|
||||
},
|
||||
unknown: {
|
||||
key: 'unknown',
|
||||
label: () => _('reports.unknown_type'),
|
||||
variant: 'bg-slate-100 text-slate-700',
|
||||
icon: 'help-circle',
|
||||
fallback: true
|
||||
}
|
||||
};
|
||||
|
||||
// [DEF:getReportTypeProfile:Function]
|
||||
// @PURPOSE: Resolve visual profile by task type with guaranteed fallback.
|
||||
// @PRE: taskType may be known/unknown/empty.
|
||||
// @POST: Returns one profile object.
|
||||
export function getReportTypeProfile(taskType) {
|
||||
const key = typeof taskType === 'string' ? taskType : 'unknown';
|
||||
return REPORT_TYPE_PROFILES[key] || REPORT_TYPE_PROFILES.unknown;
|
||||
}
|
||||
// [/DEF:getReportTypeProfile:Function]
|
||||
|
||||
// [/DEF:frontend.src.lib.components.reports.reportTypeProfiles:Module]
|
||||
Reference in New Issue
Block a user