Skip to content
This repository was archived by the owner on Jul 3, 2026. It is now read-only.
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
7 changes: 6 additions & 1 deletion SIKKERHET.md
Original file line number Diff line number Diff line change
Expand Up @@ -413,7 +413,12 @@ Bruker (nettleser/app)
| Komponentrefaktorering | Implementert | DashboardLayout (349 → 173 linjer) delt opp i DashboardHeader, DashboardSidebar, SidebarNav og ThemeToggle. Bedre vedlikeholdbarhet og testbarhet. |
| Type-sikkerhet | Implementert | 14x `as any`-casts i operator/hendelser fjernet. Sentralisert typed-queries-modul med typesikre tabellnavn erstatter spredte type-overrides. |
| Offline PWA-caching | Implementert | Service worker utvidet med install/activate/fetch-hendelser. Pre-cacher statiske ressurser, network-first for navigasjon med cache-fallback, stale-while-revalidate for assets. Supabase/API-kall ekskluderes fra caching. |
| Tilgjengelighet (a11y) | Implementert | BottomNav med role="tablist"/role="tab"/aria-selected, ToggleSwitch med role="switch"/aria-checked, aria-labels på interaktive elementer, DashboardHeader med aria-label på meny-knapp. |
| Tilgjengelighet (a11y) | Implementert | BottomNav med role="tablist"/role="tab"/aria-selected, ToggleSwitch med role="switch"/aria-checked, aria-labels på interaktive elementer, DashboardHeader med aria-label på meny-knapp. Tastaturnavigasjon i hendelseslisten (piltaster, Enter, Escape). |
| Error Boundary | Implementert | React Error Boundary-komponent fanger komponent-krasj og viser feilmelding med "Prøv igjen"-knapp. Integrert i root layout rundt hele applikasjonen. |
| Debounced søk | Implementert | `useDebounce`-hook (300ms) på søkefeltet i operator/hendelser for å redusere unødvendige omberegninger. |
| Pull-to-refresh | Implementert | Mobil-vennlig pull-to-refresh på forsiden med visuell indikator og animasjon. |
| Tema-overgang | Implementert | Myk CSS-overgang (200ms) ved bytte mellom lyst og mørkt tema via `.theme-transitioning`-klasse. |
| Empty states | Implementert | Informative tomme tilstander med ikon, beskrivelse og handling (nullstill filtre) når ingen hendelser matcher i operator-visningen. |

### 11.2 Anbefalte fremtidige tiltak

Expand Down
48 changes: 40 additions & 8 deletions src/app/(dashboard)/operator/hendelser/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { useSentralScope } from '@/hooks/useSentralScope'
import Link from 'next/link'
import { useState, useMemo, useCallback, useRef } from 'react'
import { toast } from 'sonner'
import { useDebounce } from '@/hooks/useDebounce'
import { EmptyState } from '@/components/ui/EmptyState'
import { createClient } from '@/lib/supabase/client'
import { logActivity } from '@/lib/logActivity'
import { validateImageFileFull } from '@/lib/file-validation'
Expand Down Expand Up @@ -42,6 +44,7 @@ export default function OperatorHendelserPage() {
// Filters
const [statusFilter, setStatusFilter] = useState<string>('alle')
const [search, setSearch] = useState('')
const debouncedSearch = useDebounce(search, 300)
const [filterKategori, setFilterKategori] = useState('')
const [filterFylke, setFilterFylke] = useState('')
const [filterKommune, setFilterKommune] = useState('')
Expand Down Expand Up @@ -148,7 +151,7 @@ export default function OperatorHendelserPage() {
.filter((h) => {
if (deactivatedIds.includes(h.id)) return false
if (statusFilter !== 'alle' && h.status !== statusFilter) return false
if (search && !h.tittel.toLowerCase().includes(search.toLowerCase()) && !h.sted.toLowerCase().includes(search.toLowerCase())) return false
if (debouncedSearch && !h.tittel.toLowerCase().includes(debouncedSearch.toLowerCase()) && !h.sted.toLowerCase().includes(debouncedSearch.toLowerCase())) return false
if (filterKategori && h.kategori_id !== filterKategori) return false
if (filterFylke && h.fylke_id !== filterFylke) return false
if (filterKommune && h.kommune_id !== filterKommune) return false
Expand Down Expand Up @@ -617,7 +620,41 @@ export default function OperatorHendelserPage() {
</div>

{/* Cards */}
<div className="space-y-3">
{hendelser.length === 0 ? (
<EmptyState
icon={debouncedSearch ? 'search' : 'filter'}
title={debouncedSearch ? 'Ingen treff på søket' : 'Ingen hendelser funnet'}
description={debouncedSearch
? `Ingen hendelser matcher "${debouncedSearch}". Prøv et annet søkeord.`
: activeFilterCount > 0
? 'Ingen hendelser matcher de valgte filtrene.'
: statusFilter !== 'alle'
? `Ingen hendelser med status "${statusFilter}".`
: 'Det finnes ingen hendelser ennå.'}
action={activeFilterCount > 0 || debouncedSearch ? { label: 'Nullstill filtre', onClick: clearFilters } : undefined}
/>
) : (
<div
className="space-y-3"
role="list"
onKeyDown={(e) => {
if (!['ArrowUp', 'ArrowDown', 'Enter', 'Escape'].includes(e.key)) return
e.preventDefault()
const currentIdx = expandedId ? hendelser.findIndex(h => h.id === expandedId) : -1
if (e.key === 'ArrowDown') {
const nextIdx = Math.min(currentIdx + 1, hendelser.length - 1)
setExpandedId(hendelser[nextIdx].id)
} else if (e.key === 'ArrowUp') {
const prevIdx = Math.max(currentIdx - 1, 0)
setExpandedId(hendelser[prevIdx].id)
} else if (e.key === 'Enter' && expandedId) {
openHendelse(expandedId)
} else if (e.key === 'Escape') {
setExpandedId(null)
}
}}
tabIndex={0}

Copilot AI Feb 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding tabIndex={0} to a div makes it keyboard focusable but doesn't provide adequate context for screen reader users. Consider adding an aria-label or aria-labelledby attribute to describe what this interactive list represents, such as 'Hendelsesliste' or 'Liste over hendelser'.

Copilot uses AI. Check for mistakes.
>
{hendelser.map((h) => {
const sentral = sentraler.find((s) => s.brannvesen_ids.includes(h.brannvesen_id))
const bv = brannvesen.find((b) => b.id === h.brannvesen_id)
Expand All @@ -633,7 +670,7 @@ export default function OperatorHendelserPage() {
const statusLabel = h.status === 'pågår' ? 'PÅGÅR' : 'AVSLUTTET'

return (
<div key={h.id} className="bg-theme-card rounded-xl border border-theme overflow-hidden transition-all shadow-sm hover:shadow-md flex">
<div key={h.id} role="listitem" className={`bg-theme-card rounded-xl border overflow-hidden transition-all shadow-sm hover:shadow-md flex ${expandedId === h.id ? 'border-blue-500/50 ring-1 ring-blue-500/30' : 'border-theme'}`}>
{/* Status color stripe with vertical text */}
<div className={`w-7 shrink-0 ${statusStripeColor} flex items-center justify-center`}>
<span className="text-[10px] font-bold text-white tracking-widest [writing-mode:vertical-lr] rotate-180 select-none">
Expand Down Expand Up @@ -960,11 +997,6 @@ export default function OperatorHendelserPage() {
)
})}
</div>

{hendelser.length === 0 && (
<div className="bg-theme-card rounded-xl border border-theme p-8 text-center text-theme-muted text-sm">
{search || activeFilterCount > 0 ? 'Ingen hendelser matcher filteret' : 'Ingen hendelser registrert'}
</div>
)}

<div className="mt-3 text-center">
Expand Down
6 changes: 6 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@ body {
padding-right: env(safe-area-inset-right);
}

/* Smooth theme transition */
.theme-transitioning,
.theme-transitioning * {

Copilot AI Feb 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using the universal selector (*) with transitions can cause performance issues as it applies transitions to all descendant elements. Consider limiting transitions to specific theme-related properties or elements that actually need them, such as specific class names or using CSS variables for theme colors.

Suggested change
.theme-transitioning * {
.theme-transitioning .bg-theme,
.theme-transitioning .bg-theme-card,
.theme-transitioning .bg-theme-card-hover,
.theme-transitioning .bg-theme-card-inner,
.theme-transitioning .bg-theme-sidebar,
.theme-transitioning .bg-theme-input,
.theme-transitioning .border-theme,
.theme-transitioning .border-theme-input,
.theme-transitioning .text-theme,
.theme-transitioning .text-theme-secondary,
.theme-transitioning .text-theme-muted,
.theme-transitioning .text-theme-dim,
.theme-transitioning .bg-theme-overlay,
.theme-transitioning .hover\:bg-theme-card-hover:hover,
.theme-transitioning .hover\:text-theme:hover,
.theme-transitioning .bg-theme\/95,
.theme-transitioning .divide-theme > :not([hidden]) ~ :not([hidden]) {

Copilot uses AI. Check for mistakes.
transition: background-color 200ms ease, color 200ms ease, border-color 200ms ease !important;
}

/* Custom scrollbar */
::-webkit-scrollbar {
width: 6px;
Expand Down
5 changes: 4 additions & 1 deletion src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { AuthProvider } from '@/components/providers/AuthProvider'
import { ThemeProvider } from '@/components/providers/ThemeProvider'
import { ThemeToaster } from '@/components/providers/ThemeToaster'
import { PushInit } from '@/components/providers/PushInit'
import { ErrorBoundaryProvider } from '@/components/providers/ErrorBoundaryProvider'
import './globals.css'

export const metadata: Metadata = {
Expand All @@ -29,7 +30,9 @@ export default function RootLayout({
<html lang="no" className="dark" suppressHydrationWarning>
<body className="min-h-screen bg-theme text-theme">
<ThemeProvider>
<AuthProvider>{children}</AuthProvider>
<ErrorBoundaryProvider>
<AuthProvider>{children}</AuthProvider>
</ErrorBoundaryProvider>
<ThemeToaster />
<PushInit />
</ThemeProvider>
Expand Down
29 changes: 27 additions & 2 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
'use client'

import { useState, useMemo, useEffect } from 'react'
import { useState, useMemo, useEffect, useCallback } from 'react'
import { IncidentCard } from '@/components/public/IncidentCard'
import { usePullToRefresh } from '@/hooks/usePullToRefresh'
import { BottomNav } from '@/components/public/BottomNav'
import { FilterSheet, FilterState, emptyFilters } from '@/components/public/FilterSheet'
import { SettingsView } from '@/components/public/SettingsView'
Expand Down Expand Up @@ -53,6 +54,11 @@ export default function HomePage() {
const { rolle } = useAuth()
const { theme, toggleTheme } = useTheme()

const handleRefresh = useCallback(async () => {
await refetch()
}, [refetch])
const { containerRef, refreshing, pullDistance, progress } = usePullToRefresh({ onRefresh: handleRefresh })

const dashboardHref = rolle === 'admin' || rolle === '110-admin' ? '/operator/hendelser'
: rolle === 'operator' ? '/operator/hendelser'
: rolle === 'presse' ? '/presse/hendelser'
Expand Down Expand Up @@ -162,7 +168,26 @@ export default function HomePage() {
const prefsFilterCount = pushPrefs.sentraler.length + pushPrefs.fylker.length + pushPrefs.kategorier.length + pushPrefs.brannvesen.length

return (
<div className="min-h-screen bg-theme pb-20 lg:pb-0">
<div ref={containerRef} className="min-h-screen bg-theme pb-20 lg:pb-0">
{/* Pull-to-refresh indicator (mobile only) */}
{(pullDistance > 0 || refreshing) && (
<div
className="fixed top-0 left-0 right-0 z-50 flex justify-center pointer-events-none lg:hidden"
style={{ transform: `translateY(${refreshing ? 48 : pullDistance}px)`, transition: refreshing ? 'transform 200ms ease' : 'none' }}
>
<div className={`w-8 h-8 rounded-full bg-theme-card border border-theme shadow-lg flex items-center justify-center ${refreshing ? 'animate-spin' : ''}`}>
<svg
className="w-4 h-4 text-blue-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
style={{ transform: `rotate(${progress * 360}deg)`, transition: refreshing ? 'none' : 'transform 100ms' }}
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</div>
</div>
)}
{/* Push Onboarding Popup */}
{showOnboarding && !onboardingDismissed && (
<PushOnboarding onComplete={() => setOnboardingDismissed(true)} />
Expand Down
7 changes: 7 additions & 0 deletions src/components/providers/ErrorBoundaryProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
'use client'

import { ErrorBoundary } from '@/components/ui/ErrorBoundary'

export function ErrorBoundaryProvider({ children }: { children: React.ReactNode }) {
return <ErrorBoundary>{children}</ErrorBoundary>
}
3 changes: 3 additions & 0 deletions src/components/providers/ThemeProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,10 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
}, [theme, mounted])

const toggleTheme = () => {
// Add transition class for smooth color change, remove after transition
document.documentElement.classList.add('theme-transitioning')
setTheme(prev => prev === 'dark' ? 'light' : 'dark')
setTimeout(() => document.documentElement.classList.remove('theme-transitioning'), 250)

Copilot AI Feb 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The timeout duration (250ms) doesn't match the CSS transition duration (200ms) specified in globals.css line 65. These values should be synchronized to prevent the class from being removed too early or too late. Consider using a constant or ensuring both values are 200ms.

Copilot uses AI. Check for mistakes.
}

return (
Expand Down
46 changes: 46 additions & 0 deletions src/components/ui/EmptyState.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
'use client'

interface EmptyStateProps {
icon?: 'search' | 'filter' | 'list' | 'fire'
title: string
description?: string
action?: {
label: string
onClick: () => void
}
}

const icons = {
search: (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
),
filter: (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
),
list: (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
),
fire: (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M17.657 18.657A8 8 0 016.343 7.343S7 9 9 10c0-2 .5-5 2.986-7C14 5 16.09 5.777 17.656 7.343A7.975 7.975 0 0120 13a7.975 7.975 0 01-2.343 5.657z" />
),
}

export function EmptyState({ icon = 'list', title, description, action }: EmptyStateProps) {
return (
<div className="text-center py-12 px-4">
<svg className="w-12 h-12 text-theme-dim mx-auto mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
{icons[icon]}
</svg>
<h3 className="text-sm font-semibold text-theme-secondary mb-1">{title}</h3>
{description && <p className="text-xs text-theme-muted mb-4 max-w-sm mx-auto">{description}</p>}
{action && (
<button
onClick={action.onClick}
className="px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg text-sm font-medium transition-colors"
>
{action.label}
</button>
)}
</div>
)
}
54 changes: 54 additions & 0 deletions src/components/ui/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
'use client'

import { Component, type ReactNode } from 'react'

interface Props {
children: ReactNode
fallback?: ReactNode
}

interface State {
hasError: boolean
error: Error | null
}

export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { hasError: false, error: null }
}

static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}

componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('[ErrorBoundary]', error, errorInfo)
}

render() {
if (this.state.hasError) {
if (this.props.fallback) return this.props.fallback

return (
<div className="rounded-xl bg-theme-card border border-theme p-6 text-center">
<svg className="w-10 h-10 text-red-400 mx-auto mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<h3 className="text-sm font-semibold text-theme mb-1">Noe gikk galt</h3>
<p className="text-xs text-theme-muted mb-3">
{this.state.error?.message || 'En uventet feil oppstod i denne komponenten.'}
</p>
<button
onClick={() => this.setState({ hasError: false, error: null })}
className="px-3 py-1.5 bg-blue-500 hover:bg-blue-600 text-white rounded-lg text-xs font-medium transition-colors"
>
Prøv igjen
</button>
</div>
)
}

return this.props.children
}
}
12 changes: 12 additions & 0 deletions src/hooks/useDebounce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { useState, useEffect } from 'react'

export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value)

useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay)
return () => clearTimeout(timer)
}, [value, delay])

return debouncedValue
}
60 changes: 60 additions & 0 deletions src/hooks/usePullToRefresh.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { useState, useEffect, useCallback, useRef } from 'react'

interface UsePullToRefreshOptions {
onRefresh: () => Promise<void> | void
threshold?: number
}

export function usePullToRefresh({ onRefresh, threshold = 80 }: UsePullToRefreshOptions) {
const [pulling, setPulling] = useState(false)
const [refreshing, setRefreshing] = useState(false)
const [pullDistance, setPullDistance] = useState(0)
const startY = useRef(0)
const containerRef = useRef<HTMLDivElement>(null)

const handleTouchStart = useCallback((e: TouchEvent) => {
if (window.scrollY === 0) {
startY.current = e.touches[0].clientY
setPulling(true)
}
}, [])

const handleTouchMove = useCallback((e: TouchEvent) => {
if (!pulling || refreshing) return
const distance = e.touches[0].clientY - startY.current
if (distance > 0) {
setPullDistance(Math.min(distance * 0.5, threshold * 1.5))
}
}, [pulling, refreshing, threshold])

const handleTouchEnd = useCallback(async () => {
if (!pulling) return
setPulling(false)
if (pullDistance >= threshold && !refreshing) {
setRefreshing(true)
try {
await onRefresh()
} finally {
setRefreshing(false)
}
}
setPullDistance(0)
}, [pulling, pullDistance, threshold, refreshing, onRefresh])

useEffect(() => {
const el = containerRef.current
if (!el) return
el.addEventListener('touchstart', handleTouchStart, { passive: true })
el.addEventListener('touchmove', handleTouchMove, { passive: true })
el.addEventListener('touchend', handleTouchEnd)
return () => {
el.removeEventListener('touchstart', handleTouchStart)
el.removeEventListener('touchmove', handleTouchMove)
el.removeEventListener('touchend', handleTouchEnd)
}
}, [handleTouchStart, handleTouchMove, handleTouchEnd])

const progress = Math.min(pullDistance / threshold, 1)

return { containerRef, refreshing, pullDistance, progress }
}