Page MenuHomeDevCentral

D3980.id10337.diff
No OneTemporary

D3980.id10337.diff

diff --git a/docs/architecture.md b/docs/architecture.md
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -20,6 +20,7 @@
flowchart TB
subgraph Views["Views (Pages)"]
SP["StatusPage.vue\n(Public status page)"]
+ IH["IncidentHistory.vue\n(All incidents + date filter)"]
AL["AdminLogin.vue\n(Token-paste login)"]
AD["AdminDashboard.vue\n(CRUD: Services, Incidents, Maintenance)"]
end
@@ -54,12 +55,14 @@
SP --> IT
SP --> MC
SP --> SF
+ IH --> IT
SG --> SB
AD --> SB
SP --> US
SP --> UI
SP --> UM
+ IH --> UI
AD --> US
AD --> UI
AD --> UM
diff --git a/frontend/src/components/__tests__/IncidentHistory.test.js b/frontend/src/components/__tests__/IncidentHistory.test.js
new file mode 100644
--- /dev/null
+++ b/frontend/src/components/__tests__/IncidentHistory.test.js
@@ -0,0 +1,108 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { mount } from '@vue/test-utils'
+import { ref } from 'vue'
+import IncidentHistory from '@/views/IncidentHistory.vue'
+
+const mockIncidents = ref([])
+const mockLoading = ref(false)
+const mockFetchIncidents = vi.fn(() => Promise.resolve())
+
+vi.mock('@/composables/useIncidents', () => ({
+ useIncidents: () => ({
+ incidents: mockIncidents,
+ loading: mockLoading,
+ fetchIncidents: mockFetchIncidents,
+ }),
+}))
+
+vi.mock('@/plugins/api', () => ({
+ incidentsApi: {
+ getById: vi.fn((id) => Promise.resolve({
+ id,
+ title: `Incident ${id}`,
+ status: 'resolved',
+ impact: 'minor',
+ created_at: new Date().toISOString(),
+ updates: [],
+ })),
+ },
+}))
+
+function mountIncidentHistory() {
+ return mount(IncidentHistory, {
+ global: {
+ stubs: {
+ 'router-link': { template: '<a><slot /></a>' },
+ IncidentTimeline: {
+ template: '<div class="incident-timeline">{{ incident.title }}</div>',
+ props: ['incident'],
+ },
+ },
+ },
+ })
+}
+
+describe('IncidentHistory', () => {
+ beforeEach(() => {
+ mockIncidents.value = []
+ mockLoading.value = false
+ vi.clearAllMocks()
+ })
+
+ it('renders page title', () => {
+ const wrapper = mountIncidentHistory()
+ expect(wrapper.text()).toContain('Incident History')
+ })
+
+ it('renders back to status link', () => {
+ const wrapper = mountIncidentHistory()
+ expect(wrapper.text()).toContain('Back to status')
+ })
+
+ it('renders all four filter buttons', () => {
+ const wrapper = mountIncidentHistory()
+ const buttons = wrapper.findAll('button')
+
+ expect(buttons).toHaveLength(4)
+ expect(buttons[0].text()).toBe('Last 7 Days')
+ expect(buttons[1].text()).toBe('Last 30 Days')
+ expect(buttons[2].text()).toBe('Last 90 Days')
+ expect(buttons[3].text()).toBe('All')
+ })
+
+ it('highlights active filter button', () => {
+ const wrapper = mountIncidentHistory()
+ const buttons = wrapper.findAll('button')
+
+ expect(buttons[0].classes()).toContain('bg-brand-500')
+ expect(buttons[1].classes()).not.toContain('bg-brand-500')
+ })
+
+ it('changes active filter on click', async () => {
+ const wrapper = mountIncidentHistory()
+ const buttons = wrapper.findAll('button')
+
+ await buttons[1].trigger('click')
+
+ expect(buttons[1].classes()).toContain('bg-brand-500')
+ expect(buttons[0].classes()).not.toContain('bg-brand-500')
+ })
+
+ it('shows empty state when no incidents', () => {
+ const wrapper = mountIncidentHistory()
+ expect(wrapper.text()).toContain('No incidents reported in this timeframe.')
+ })
+
+ it('shows loading spinner when loading', () => {
+ mockLoading.value = true
+ const wrapper = mountIncidentHistory()
+
+ expect(wrapper.find('.animate-spin').exists()).toBe(true)
+ expect(wrapper.text()).not.toContain('No incidents reported')
+ })
+
+ it('calls fetchIncidents on mount', () => {
+ mountIncidentHistory()
+ expect(mockFetchIncidents).toHaveBeenCalled()
+ })
+})
diff --git a/frontend/src/components/__tests__/router.test.js b/frontend/src/components/__tests__/router.test.js
new file mode 100644
--- /dev/null
+++ b/frontend/src/components/__tests__/router.test.js
@@ -0,0 +1,59 @@
+import { describe, it, expect, vi } from 'vitest'
+import { createRouter, createWebHistory } from 'vue-router'
+
+vi.mock('@/plugins/api', () => ({
+ authApi: { verify: vi.fn() },
+}))
+
+const routes = [
+ {
+ path: '/',
+ name: 'status',
+ component: { template: '<div>Status</div>' },
+ },
+ {
+ path: '/incidents',
+ name: 'incident-history',
+ component: { template: '<div>Incident History</div>' },
+ },
+]
+
+function createTestRouter() {
+ return createRouter({
+ history: createWebHistory(),
+ routes,
+ })
+}
+
+describe('Router', () => {
+ it('/incidents resolves to incident-history', async () => {
+ const router = createTestRouter()
+ await router.push('/incidents')
+ await router.isReady()
+
+ expect(router.currentRoute.value.name).toBe('incident-history')
+ expect(router.currentRoute.value.path).toBe('/incidents')
+ })
+
+ it('navigates from / to /incidents', async () => {
+ const router = createTestRouter()
+ await router.push('/')
+ await router.isReady()
+
+ expect(router.currentRoute.value.name).toBe('status')
+
+ await router.push('/incidents')
+
+ expect(router.currentRoute.value.name).toBe('incident-history')
+ })
+
+ it('navigates from /incidents back to /', async () => {
+ const router = createTestRouter()
+ await router.push('/incidents')
+ await router.isReady()
+
+ await router.push('/')
+
+ expect(router.currentRoute.value.name).toBe('status')
+ })
+})
diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js
--- a/frontend/src/router/index.js
+++ b/frontend/src/router/index.js
@@ -10,6 +10,11 @@
name: 'status',
component: StatusPage,
},
+ {
+ path: '/incidents',
+ name: 'incident-history',
+ component: () => import('@/views/IncidentHistory.vue'),
+ },
{
path: '/confirm/:token',
name: 'confirm-subscription',
diff --git a/frontend/src/views/IncidentHistory.vue b/frontend/src/views/IncidentHistory.vue
new file mode 100644
--- /dev/null
+++ b/frontend/src/views/IncidentHistory.vue
@@ -0,0 +1,112 @@
+<script setup>
+import { ref, computed, onMounted } from 'vue'
+import { useIncidents } from '@/composables/useIncidents'
+import { incidentsApi } from '@/plugins/api'
+import IncidentTimeline from '@/components/IncidentTimeline.vue'
+
+const FILTER_OPTIONS = [
+ { label: 'Last 7 Days', value: 7 },
+ { label: 'Last 30 Days', value: 30 },
+ { label: 'Last 90 Days', value: 90 },
+ { label: 'All', value: 'all' },
+]
+
+const { incidents, loading, fetchIncidents } = useIncidents()
+const activeFilter = ref(7)
+const detailedIncidents = ref([])
+const detailsLoading = ref(false)
+
+async function fetchDetailedIncidents() {
+ detailsLoading.value = true
+ try {
+ detailedIncidents.value = await Promise.all(
+ incidents.value.map(
+ (incident) => incidentsApi.getById(incident.id).catch(() => incident)
+ )
+ )
+ } catch (err) {
+ console.error('Failed to fetch incident details:', err)
+ } finally {
+ detailsLoading.value = false
+ }
+}
+
+function isWithinDays(dateString, days) {
+ const cutoffDate = new Date()
+ cutoffDate.setDate(cutoffDate.getDate() - days)
+
+ return new Date(dateString) >= cutoffDate
+}
+
+function filterButtonClass(value) {
+ if (activeFilter.value === value) {
+ return 'bg-brand-500 text-white'
+ }
+
+ return 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
+}
+
+const filteredIncidents = computed(() => {
+ if (activeFilter.value === 'all') {
+ return detailedIncidents.value
+ }
+
+ return detailedIncidents.value.filter((incident) => {
+ return isWithinDays(incident.start_date, activeFilter.value)
+ })
+})
+
+const isLoading = computed(() => loading.value || detailsLoading.value)
+
+onMounted(async () => {
+ await fetchIncidents()
+ await fetchDetailedIncidents()
+})
+</script>
+
+<template>
+ <div class="max-w-3xl mx-auto px-4 sm:px-6 py-8 space-y-6">
+ <div class="flex items-center justify-between mb-2">
+ <h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">
+ Incident History
+ </h1>
+ <router-link
+ to="/"
+ class="text-sm text-brand-500 hover:text-brand-600"
+ >
+ &larr; Back to status
+ </router-link>
+ </div>
+
+ <div class="flex gap-2">
+ <button
+ v-for="filter in FILTER_OPTIONS"
+ :key="filter.value"
+ :class="filterButtonClass(filter.value)"
+ class="px-4 py-2 rounded-md text-sm font-medium transition-colors"
+ @click="activeFilter = filter.value"
+ >
+ {{ filter.label }}
+ </button>
+ </div>
+
+ <!-- Loading -->
+ <div v-if="isLoading" class="flex justify-center py-20">
+ <div class="h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-brand-500"></div>
+ </div>
+
+ <template v-else>
+ <div v-if="filteredIncidents.length === 0" class="card p-6 text-center text-gray-500">
+ No incidents reported in this timeframe.
+ </div>
+
+ <div v-else class="space-y-3">
+ <IncidentTimeline
+ v-for="incident in filteredIncidents"
+ :key="incident.id"
+ :incident="incident"
+ />
+ </div>
+ </template>
+ </div>
+</template>

File Metadata

Mime Type
text/plain
Expires
Fri, Mar 6, 09:05 (20 h, 56 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
3501133
Default Alt Text
D3980.id10337.diff (9 KB)

Event Timeline