diff --git a/android/app/src/main/java/com/neph/features/news/data/AnnouncementsRepository.kt b/android/app/src/main/java/com/neph/features/news/data/AnnouncementsRepository.kt new file mode 100644 index 00000000..0de1fec2 --- /dev/null +++ b/android/app/src/main/java/com/neph/features/news/data/AnnouncementsRepository.kt @@ -0,0 +1,51 @@ +package com.neph.features.news.data + +import com.neph.core.network.JsonHttpClient +import org.json.JSONObject + +data class Announcement( + val id: String, + val adminId: String?, + val title: String, + val content: String, + val createdAt: String? +) + +object AnnouncementsRepository { + private const val DefaultLimit = 100 + private const val MaxLimit = 100 + + suspend fun fetchAnnouncements(limit: Int = DefaultLimit): List { + val normalizedLimit = limit.coerceIn(1, MaxLimit) + val response = JsonHttpClient.request( + path = "/announcements?limit=$normalizedLimit" + ) + + val rawList = response.optJSONArray("announcements") ?: return emptyList() + val items = mutableListOf() + for (i in 0 until rawList.length()) { + val obj = rawList.optJSONObject(i) ?: continue + items.add(parseAnnouncement(obj)) + } + return items + } + + suspend fun fetchAnnouncement(announcementId: String): Announcement { + val response = JsonHttpClient.request( + path = "/announcements/${java.net.URLEncoder.encode(announcementId, Charsets.UTF_8.name())}" + ) + val obj = response.optJSONObject("announcement") + ?: throw IllegalStateException("Announcement response did not include an announcement object.") + return parseAnnouncement(obj) + } + + private fun parseAnnouncement(json: JSONObject): Announcement { + return Announcement( + id = json.optString("id"), + adminId = json.optString("adminId").takeIf { it.isNotBlank() }, + title = json.optString("title"), + content = json.optString("content"), + createdAt = json.optString("createdAt").takeIf { it.isNotBlank() } + ) + } +} diff --git a/android/app/src/main/java/com/neph/features/news/presentation/AnnouncementDetailScreen.kt b/android/app/src/main/java/com/neph/features/news/presentation/AnnouncementDetailScreen.kt new file mode 100644 index 00000000..e8acae9c --- /dev/null +++ b/android/app/src/main/java/com/neph/features/news/presentation/AnnouncementDetailScreen.kt @@ -0,0 +1,149 @@ +package com.neph.features.news.presentation + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import com.neph.features.news.data.Announcement +import com.neph.features.news.data.AnnouncementsRepository +import com.neph.ui.components.buttons.PrimaryButton +import com.neph.ui.components.display.SectionCard +import com.neph.ui.components.display.StatusBadge +import com.neph.ui.components.display.StatusBadgeTone +import com.neph.ui.layout.AppScaffold +import com.neph.ui.theme.LocalNephSpacing +import kotlinx.coroutines.CancellationException + +private sealed interface DetailUiState { + data object Loading : DetailUiState + data class Loaded(val announcement: Announcement) : DetailUiState + data class Error(val message: String) : DetailUiState +} + +@Composable +fun AnnouncementDetailScreen( + announcementId: String, + onNavigateBack: () -> Unit +) { + val spacing = LocalNephSpacing.current + var state by remember { mutableStateOf(DetailUiState.Loading) } + var reloadKey by remember { mutableStateOf(0) } + + LaunchedEffect(announcementId, reloadKey) { + state = DetailUiState.Loading + try { + val announcement = AnnouncementsRepository.fetchAnnouncement(announcementId) + state = DetailUiState.Loaded(announcement) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Throwable) { + state = DetailUiState.Error( + error.message?.takeIf { it.isNotBlank() } + ?: "Could not load this announcement. Please try again." + ) + } + } + + AppScaffold( + title = "Announcement", + onNavigateBack = onNavigateBack + ) { + Column( + verticalArrangement = Arrangement.spacedBy(spacing.lg) + ) { + SectionCard { + when (val current = state) { + is DetailUiState.Loading -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(spacing.lg), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + is DetailUiState.Error -> { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(spacing.md), + verticalArrangement = Arrangement.spacedBy(spacing.md), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Couldn't load announcement", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold + ) + Text( + text = current.message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + PrimaryButton( + text = "Retry", + onClick = { reloadKey += 1 }, + modifier = Modifier.fillMaxWidth(0.6f) + ) + } + } + is DetailUiState.Loaded -> { + AnnouncementDetailContent(announcement = current.announcement) + } + } + } + } + } +} + +@Composable +private fun AnnouncementDetailContent(announcement: Announcement) { + val spacing = LocalNephSpacing.current + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(spacing.sm) + ) { + StatusBadge( + text = "Announcement", + tone = StatusBadgeTone.BRAND + ) + + Text( + text = announcement.title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold + ) + + val publishedAt = formatPublishedAt(announcement.createdAt) + if (publishedAt.isNotBlank()) { + Text( + text = publishedAt, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Text( + text = announcement.content, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } +} diff --git a/android/app/src/main/java/com/neph/features/news/presentation/NewsFormatters.kt b/android/app/src/main/java/com/neph/features/news/presentation/NewsFormatters.kt new file mode 100644 index 00000000..200605a3 --- /dev/null +++ b/android/app/src/main/java/com/neph/features/news/presentation/NewsFormatters.kt @@ -0,0 +1,18 @@ +package com.neph.features.news.presentation + +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale + +private val DisplayDateFormatter: DateTimeFormatter = + DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.US).withZone(ZoneId.systemDefault()) + +internal fun formatPublishedAt(value: String?): String { + if (value.isNullOrBlank()) return "" + return try { + DisplayDateFormatter.format(Instant.parse(value)) + } catch (_: Exception) { + value + } +} diff --git a/android/app/src/main/java/com/neph/features/news/presentation/NewsScreen.kt b/android/app/src/main/java/com/neph/features/news/presentation/NewsScreen.kt index aedd4b37..a5a8cf80 100644 --- a/android/app/src/main/java/com/neph/features/news/presentation/NewsScreen.kt +++ b/android/app/src/main/java/com/neph/features/news/presentation/NewsScreen.kt @@ -1,147 +1,84 @@ package com.neph.features.news.presentation +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.Modifier +import com.neph.features.news.data.Announcement +import com.neph.features.news.data.AnnouncementsRepository import com.neph.navigation.Routes +import com.neph.ui.components.buttons.PrimaryButton import com.neph.ui.components.display.SectionCard import com.neph.ui.components.display.StatusBadge import com.neph.ui.components.display.StatusBadgeTone import com.neph.ui.layout.AppDrawerScaffold import com.neph.ui.theme.LocalNephSpacing import com.neph.ui.theme.NephTheme +import kotlinx.coroutines.CancellationException -private data class NewsItem( - val id: String, - val title: String, - val summary: String, - val publishedAt: String, - val category: String -) +private sealed interface NewsUiState { + data object Loading : NewsUiState + data class Loaded(val items: List) : NewsUiState + data class Error(val message: String) : NewsUiState +} + +private const val SummaryMaxLength = 180 -private val mockNews = listOf( - NewsItem( - id = "n-001", - title = "Neighborhood Preparedness Workshops Start Next Week", - summary = "Local volunteer teams will host practical first-response workshops for households in participating districts.", - publishedAt = "2026-03-20", - category = "Preparedness" - ), - NewsItem( - id = "n-002", - title = "Mobile App Pilot Open for Early Access", - summary = "NEPH mobile pilot is available for selected users to test incident reporting and emergency support requests.", - publishedAt = "2026-03-18", - category = "Announcement" - ), - NewsItem( - id = "n-003", - title = "Community Safety Volunteers Expanded", - summary = "New volunteers have joined the response network, improving local coverage for urgent coordination.", - publishedAt = "2026-03-13", - category = "Community" - ), - NewsItem( - id = "n-004", - title = "Medical Information Checklist Updated", - summary = "The profile health checklist now includes clearer guidance for medications and chronic conditions.", - publishedAt = "2026-03-09", - category = "Preparedness" - ), - NewsItem( - id = "n-005", - title = "District Evacuation Route Signage Refreshed", - summary = "Municipal teams completed replacement of damaged evacuation signs across high-risk neighborhoods.", - publishedAt = "2026-03-06", - category = "Announcement" - ), - NewsItem( - id = "n-006", - title = "Volunteer Radio Drill Scheduled for Saturday", - summary = "Community coordinators will run a communication drill to validate fallback channels during outages.", - publishedAt = "2026-03-04", - category = "Community" - ), - NewsItem( - id = "n-007", - title = "Water Stocking Guidance Updated for Families", - summary = "Preparedness guidelines now include age-based daily water recommendations for households.", - publishedAt = "2026-03-01", - category = "Preparedness" - ), - NewsItem( - id = "n-008", - title = "Search and Rescue Teams Added in Northern Zone", - summary = "Two new rapid-response teams were integrated to reduce arrival times in remote areas.", - publishedAt = "2026-02-26", - category = "Community" - ), - NewsItem( - id = "n-009", - title = "Critical Medication Reminder Campaign Launched", - summary = "A new campaign reminds patients to maintain a 7-day emergency medication reserve.", - publishedAt = "2026-02-24", - category = "Announcement" - ), - NewsItem( - id = "n-010", - title = "Neighborhood Assembly Point Maps Published", - summary = "Updated assembly point maps are now available with improved accessibility details.", - publishedAt = "2026-02-20", - category = "Preparedness" - ), - NewsItem( - id = "n-011", - title = "Local First-Aid Mentor Program Expanded", - summary = "The mentor network now covers 14 additional schools and public training centers.", - publishedAt = "2026-02-16", - category = "Community" - ), - NewsItem( - id = "n-012", - title = "Power Outage Kit Checklist Shared", - summary = "NEPH published a concise checklist for household lighting, charging, and communication readiness.", - publishedAt = "2026-02-12", - category = "Preparedness" - ) -) +private fun summarizeAnnouncementContent(content: String): String { + val normalized = content.replace(Regex("\\s+"), " ").trim() + if (normalized.length <= SummaryMaxLength) { + return normalized + } + val truncated = normalized.substring(0, SummaryMaxLength - 1).trim() + return "$truncated…" +} @Composable fun NewsScreen( onNavigateToRoute: (String) -> Unit, onOpenSettings: (() -> Unit)?, onProfileClick: () -> Unit, + onOpenAnnouncement: (String) -> Unit, profileBadgeText: String, isAuthenticated: Boolean ) { val spacing = LocalNephSpacing.current - val categories = remember { listOf("All", "Preparedness", "Announcement", "Community") } - var selectedCategory by remember { mutableStateOf("All") } - var isFilterExpanded by remember { mutableStateOf(false) } + var state by remember { mutableStateOf(NewsUiState.Loading) } + var reloadKey by remember { mutableStateOf(0) } - val filteredNews = remember(selectedCategory) { - if (selectedCategory == "All") { - mockNews - } else { - mockNews.filter { it.category == selectedCategory } + LaunchedEffect(reloadKey) { + state = NewsUiState.Loading + try { + val announcements = AnnouncementsRepository.fetchAnnouncements() + state = NewsUiState.Loaded(announcements) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Throwable) { + state = NewsUiState.Error( + error.message?.takeIf { it.isNotBlank() } + ?: "Could not load announcements. Please try again." + ) } } @@ -170,85 +107,166 @@ fun NewsScreen( verticalArrangement = Arrangement.spacedBy(spacing.lg) ) { SectionCard(modifier = Modifier.weight(1f)) { + Text( + text = "Updates", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold + ) + + when (val current = state) { + is NewsUiState.Loading -> LoadingState() + is NewsUiState.Error -> ErrorState( + message = current.message, + onRetry = { reloadKey += 1 } + ) + is NewsUiState.Loaded -> { + if (current.items.isEmpty()) { + EmptyState(onRetry = { reloadKey += 1 }) + } else { + AnnouncementList( + items = current.items, + onOpenAnnouncement = onOpenAnnouncement + ) + } + } + } + } + } + } +} + +@Composable +private fun LoadingState() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } +} + +@Composable +private fun ErrorState(message: String, onRetry: () -> Unit) { + val spacing = LocalNephSpacing.current + Column( + modifier = Modifier + .fillMaxSize() + .padding(top = spacing.lg), + verticalArrangement = Arrangement.spacedBy(spacing.md), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Couldn't load announcements", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold + ) + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + PrimaryButton( + text = "Retry", + onClick = onRetry, + modifier = Modifier.fillMaxWidth(0.6f) + ) + } +} + +@Composable +private fun EmptyState(onRetry: () -> Unit) { + val spacing = LocalNephSpacing.current + Column( + modifier = Modifier + .fillMaxSize() + .padding(top = spacing.lg), + verticalArrangement = Arrangement.spacedBy(spacing.md), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "No announcements yet", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold + ) + Text( + text = "Check back later for updates from administrators.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + PrimaryButton( + text = "Refresh", + onClick = onRetry, + modifier = Modifier.fillMaxWidth(0.6f) + ) + } +} + +@Composable +private fun AnnouncementList( + items: List, + onOpenAnnouncement: (String) -> Unit +) { + val spacing = LocalNephSpacing.current + + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .padding(top = spacing.md), + verticalArrangement = Arrangement.spacedBy(spacing.md) + ) { + itemsIndexed(items, key = { _, item -> item.id }) { index, item -> + val summary = remember(item.id, item.content) { + summarizeAnnouncementContent(item.content) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .clickable { onOpenAnnouncement(item.id) }, + verticalArrangement = Arrangement.spacedBy(spacing.xs) + ) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween ) { - Text( - text = "Updates", - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.SemiBold + StatusBadge( + text = "Announcement", + tone = StatusBadgeTone.BRAND ) - Row { - TextButton(onClick = { isFilterExpanded = true }) { - Text(text = selectedCategory) - } - - DropdownMenu( - expanded = isFilterExpanded, - onDismissRequest = { isFilterExpanded = false } - ) { - categories.forEach { category -> - DropdownMenuItem( - text = { Text(category) }, - onClick = { - selectedCategory = category - isFilterExpanded = false - } - ) - } - } - } + Text( + text = formatPublishedAt(item.createdAt), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } - LazyColumn( - modifier = Modifier.fillMaxWidth().weight(1f), - verticalArrangement = Arrangement.spacedBy(spacing.md) - ) { - itemsIndexed(filteredNews, key = { _, item -> item.id }) { index, item -> - Column(verticalArrangement = Arrangement.spacedBy(spacing.xs)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - StatusBadge( - text = item.category, - tone = when (item.category) { - "Preparedness" -> StatusBadgeTone.INFO - "Announcement" -> StatusBadgeTone.BRAND - "Community" -> StatusBadgeTone.SUCCESS - else -> StatusBadgeTone.NEUTRAL - } - ) + Text( + text = item.title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold + ) - Text( - text = item.publishedAt, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } + Text( + text = summary, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) - Text( - text = item.title, - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.SemiBold - ) - - Text( - text = item.summary, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } + Text( + text = "Open announcement", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold + ) + } - if (index < filteredNews.lastIndex) { - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) - } - } - } + if (index < items.lastIndex) { + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) } } } @@ -262,6 +280,7 @@ private fun NewsScreenPreview() { onNavigateToRoute = {}, onOpenSettings = {}, onProfileClick = {}, + onOpenAnnouncement = {}, profileBadgeText = "PP", isAuthenticated = true ) diff --git a/android/app/src/main/java/com/neph/navigation/AppNavGraph.kt b/android/app/src/main/java/com/neph/navigation/AppNavGraph.kt index 9681625d..bb86e33c 100644 --- a/android/app/src/main/java/com/neph/navigation/AppNavGraph.kt +++ b/android/app/src/main/java/com/neph/navigation/AppNavGraph.kt @@ -29,6 +29,7 @@ import com.neph.features.home.presentation.HomeScreen import com.neph.features.myhelprequests.presentation.MyHelpRequestsScreen import com.neph.features.nearbyusers.presentation.NearbyVisibleUsersScreen import com.neph.features.news.presentation.NewsScreen +import com.neph.features.news.presentation.AnnouncementDetailScreen import com.neph.features.notifications.presentation.NotificationsScreen import com.neph.features.privacysecurity.presentation.PrivacySecurityScreen import com.neph.features.profile.data.ProfileRepository @@ -172,11 +173,29 @@ fun AppNavGraph( navigateToLogin() } }, + onOpenAnnouncement = { announcementId -> + navController.navigate(Routes.newsDetail(announcementId)) + }, profileBadgeText = profileBadgeText, isAuthenticated = authenticated ) } + composable( + route = "${Routes.NewsDetail.route}/{${Routes.NewsAnnouncementIdArg}}", + arguments = listOf( + navArgument(Routes.NewsAnnouncementIdArg) { + type = NavType.StringType + } + ) + ) { backStackEntry -> + val announcementId = backStackEntry.arguments?.getString(Routes.NewsAnnouncementIdArg).orEmpty() + AnnouncementDetailScreen( + announcementId = announcementId, + onNavigateBack = { navController.popBackStack() } + ) + } + composable(Routes.MyHelpRequests.route) { val authenticated = isAuthenticated() val profileBadgeText = resolveProfileBadgeText(authenticated) diff --git a/android/app/src/main/java/com/neph/navigation/Routes.kt b/android/app/src/main/java/com/neph/navigation/Routes.kt index e7c9dc4e..b6d41c1b 100644 --- a/android/app/src/main/java/com/neph/navigation/Routes.kt +++ b/android/app/src/main/java/com/neph/navigation/Routes.kt @@ -6,6 +6,7 @@ sealed class Routes( ) { data object Home : Routes("home", "Home") data object News : Routes("news", "News & Announcements") + data object NewsDetail : Routes("news_detail", "Announcement") data object RequestHelp : Routes("request_help") data object MyHelpRequests : Routes("my_help_requests", "My Help Requests") data object AssignedRequest : Routes("assigned_request", "Assigned Request") @@ -34,6 +35,11 @@ sealed class Routes( fun requestHelpWithDraft(localId: String): String = "${RequestHelp.route}?$RequestHelpDraftArg=$localId" + const val NewsAnnouncementIdArg = "announcementId" + + fun newsDetail(announcementId: String): String = + "${NewsDetail.route}/$announcementId" + val authenticatedDrawerItems = listOf( Home, News, diff --git a/web/e2e/news-fallback.spec.js b/web/e2e/news-fallback.spec.js index 611de47f..5451117a 100644 --- a/web/e2e/news-fallback.spec.js +++ b/web/e2e/news-fallback.spec.js @@ -1,11 +1,13 @@ const { test, expect } = require('@playwright/test'); const { resetDatabase } = require('./helpers/db'); +const CACHED_ANNOUNCEMENTS_KEY = 'neph.publicAnnouncements.cache.v1'; + test.beforeEach(async () => { await resetDatabase(); }); -test('shows demo announcements with retry context when news API is unavailable', async ({ page }) => { +test('shows explicit error state when news API is unavailable and no cache exists', async ({ page }) => { await page.route('**/api/announcements**', async (route) => { await route.fulfill({ status: 503, @@ -19,9 +21,49 @@ test('shows demo announcements with retry context when news API is unavailable', await page.goto('/news'); - await expect(page.getByText('Using fallback news')).toBeVisible(); - await expect(page.getByText(/Announcements could not be refreshed/)).toBeVisible(); + await expect(page.getByText("Couldn't load announcements")).toBeVisible(); + await expect(page.getByText(/Could not load announcements/)).toBeVisible(); await expect(page.getByRole('button', { name: 'Retry news' })).toBeVisible(); + await expect(page.getByText('No announcements could be loaded right now.')).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Know your nearest gathering area' })).toHaveCount(0); +}); + +test('shows cached announcements with warning when news API is unavailable', async ({ page }) => { + await page.addInitScript((cacheKey) => { + window.localStorage.setItem( + cacheKey, + JSON.stringify({ + announcements: [ + { + id: 'cached_announcement_1', + adminId: 'admin-cache', + title: 'Cached Announcement Title', + content: + 'Cached announcement body content to verify warning-mode rendering when API is unavailable.', + createdAt: '2026-05-10T09:00:00.000Z', + }, + ], + savedAt: '2026-05-10T09:05:00.000Z', + }) + ); + }, CACHED_ANNOUNCEMENTS_KEY); + + await page.route('**/api/announcements**', async (route) => { + await route.fulfill({ + status: 503, + contentType: 'application/json', + body: JSON.stringify({ + code: 'ANNOUNCEMENTS_UNAVAILABLE', + message: 'Announcements service is unavailable', + }), + }); + }); + + await page.goto('/news'); + + await expect(page.getByText('Using cached announcements')).toBeVisible(); + await expect(page.getByText(/Showing cached announcements instead/)).toBeVisible(); await expect(page.getByText('Last updated:')).toBeVisible(); - await expect(page.getByRole('heading', { name: 'Know your nearest gathering area' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Cached Announcement Title' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Retry news' })).toBeVisible(); }); diff --git a/web/src/app/home/page.tsx b/web/src/app/home/page.tsx index abec92a7..d042a4d9 100644 --- a/web/src/app/home/page.tsx +++ b/web/src/app/home/page.tsx @@ -12,7 +12,6 @@ import { type EmergencyContact, } from "../../lib/emergencyNumbers"; import { - FALLBACK_ANNOUNCEMENTS, announcementToNewsItem, fetchAnnouncements, readCachedAnnouncements, @@ -109,19 +108,22 @@ export default function HomePage() { const cached = readCachedAnnouncements(); const cachedAnnouncements = cached?.announcements.slice(0, 3) || []; const hasCachedAnnouncements = cachedAnnouncements.length > 0; - const fallbackAnnouncements = hasCachedAnnouncements - ? cachedAnnouncements - : FALLBACK_ANNOUNCEMENTS.slice(0, 3); - const sourceLabel = hasCachedAnnouncements - ? "cached announcements" - : "demo announcements"; - setPreviewNews(fallbackAnnouncements.map(announcementToNewsItem)); - setNewsUpdatedAt(hasCachedAnnouncements && cached ? cached.savedAt : FALLBACK_ANNOUNCEMENTS[0]?.createdAt || ""); - setUsingFallbackNews(true); - setNewsError( - `Latest announcements could not be refreshed (${describeNewsPreviewFailure(err)}). Showing ${sourceLabel}.` - ); + if (hasCachedAnnouncements) { + setPreviewNews(cachedAnnouncements.map(announcementToNewsItem)); + setNewsUpdatedAt(cached?.savedAt || ""); + setUsingFallbackNews(true); + setNewsError( + `Latest announcements could not be refreshed (${describeNewsPreviewFailure(err)}). Showing cached announcements.` + ); + } else { + setPreviewNews([]); + setNewsUpdatedAt(""); + setUsingFallbackNews(true); + setNewsError( + `Latest announcements could not be loaded (${describeNewsPreviewFailure(err)}).` + ); + } } finally { setNewsLoading(false); } diff --git a/web/src/app/news/[announcementId]/page.tsx b/web/src/app/news/[announcementId]/page.tsx index 1b29449b..af6e4d57 100644 --- a/web/src/app/news/[announcementId]/page.tsx +++ b/web/src/app/news/[announcementId]/page.tsx @@ -9,7 +9,6 @@ import { SectionHeader } from "@/components/ui/display/SectionHeader"; import { PrimaryButton } from "@/components/ui/buttons/PrimaryButton"; import { ANNOUNCEMENTS_CACHE_KEY, - FALLBACK_ANNOUNCEMENTS, fetchAnnouncement, formatAnnouncementDate, type Announcement, @@ -70,16 +69,13 @@ export default function NewsDetailPage() { const nextAnnouncement = await fetchAnnouncement(announcementId); setAnnouncement(nextAnnouncement); } catch (err) { - const fallback = - findCachedAnnouncement(announcementId) || - FALLBACK_ANNOUNCEMENTS.find((item) => item.id === announcementId) || - null; + const fallback = findCachedAnnouncement(announcementId); if (fallback) { const detail = describeAnnouncementFailure(err); setAnnouncement(fallback); setUsingFallback(true); - setError(`Announcement could not be refreshed (${detail}). Showing cached/demo content.`); + setError(`Announcement could not be refreshed (${detail}). Showing cached content.`); } else { setError(`Announcement could not be refreshed (${describeAnnouncementFailure(err)}).`); setAnnouncement(null); @@ -115,7 +111,7 @@ export default function NewsDetailPage() {

- {usingFallback ? "Using fallback announcement" : "Announcement load failed"} + {usingFallback ? "Using cached announcement" : "Announcement load failed"}

{error}

diff --git a/web/src/app/news/page.tsx b/web/src/app/news/page.tsx index 59eea65e..66832801 100644 --- a/web/src/app/news/page.tsx +++ b/web/src/app/news/page.tsx @@ -7,7 +7,6 @@ import { SectionCard } from "@/components/ui/display/SectionCard"; import { SectionHeader } from "@/components/ui/display/SectionHeader"; import { PrimaryButton } from "@/components/ui/buttons/PrimaryButton"; import { - FALLBACK_ANNOUNCEMENTS, announcementToNewsItem, cacheAnnouncements, fetchAnnouncements, @@ -28,16 +27,26 @@ function buildFailureMessage(err: unknown, sourceLabel: string) { return `Announcements could not be refreshed (${detail}). Showing ${sourceLabel} instead.`; } +function buildLoadFailureMessage(err: unknown) { + const rawDetail = err instanceof Error ? err.message : ""; + const detail = /could not reach the server/i.test(rawDetail) + ? "the live announcements service did not respond" + : rawDetail || "the announcements API did not respond"; + return `Could not load announcements (${detail}).`; +} + export default function NewsPage() { const [items, setItems] = React.useState([]); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(""); + const [loadError, setLoadError] = React.useState(""); const [lastUpdated, setLastUpdated] = React.useState(""); const [sourceLabel, setSourceLabel] = React.useState("live announcements"); const loadAnnouncements = React.useCallback(async () => { setLoading(true); setError(""); + setLoadError(""); try { const announcements = await fetchAnnouncements({ limit: 100 }); @@ -55,10 +64,10 @@ export default function NewsPage() { setSourceLabel("cached announcements"); setError(buildFailureMessage(err, "cached announcements")); } else { - setItems(FALLBACK_ANNOUNCEMENTS.map(announcementToNewsItem)); - setLastUpdated(FALLBACK_ANNOUNCEMENTS[0]?.createdAt || ""); - setSourceLabel("demo announcements"); - setError(buildFailureMessage(err, "demo announcements")); + setItems([]); + setLastUpdated(""); + setSourceLabel(""); + setLoadError(buildLoadFailureMessage(err)); } } finally { setLoading(false); @@ -91,13 +100,25 @@ export default function NewsPage() {
) : ( <> -
+

- {error ? "Using fallback news" : "Announcements are up to date"} + {loadError + ? "Couldn't load announcements" + : error + ? "Using cached announcements" + : "Announcements are up to date"}

- {error || `Showing ${sourceLabel}.`} + {loadError || error || `Showing ${sourceLabel}.`}

{lastUpdated ? (

@@ -112,7 +133,11 @@ export default function NewsPage() { {items.length === 0 ? (

-

No announcements have been published yet.

+

+ {loadError + ? "No announcements could be loaded right now." + : "No announcements have been published yet."} +

) : (
@@ -131,7 +156,7 @@ export default function NewsPage() { className="news-read-more-link" href={`/news/${encodeURIComponent(item.id)}`} > - {item.hasMore ? "Read full announcement" : "Open announcement"} + Open announcement ))} diff --git a/web/src/lib/formatters.ts b/web/src/lib/formatters.ts index d63442c8..43b29783 100644 --- a/web/src/lib/formatters.ts +++ b/web/src/lib/formatters.ts @@ -64,7 +64,7 @@ export function formatTimestampDate(value: string, now = new Date()) { return relativeLabel; } - return date.toLocaleDateString(undefined, { + return date.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric", @@ -79,13 +79,13 @@ export function formatTimestampDateTime(value: string, now = new Date()) { const relativeLabel = getRelativeDayLabel(date, now); if (relativeLabel) { - return `${relativeLabel}, ${date.toLocaleTimeString(undefined, { + return `${relativeLabel}, ${date.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", })}`; } - return date.toLocaleString(undefined, { + return date.toLocaleString("en-US", { dateStyle: "medium", timeStyle: "short", }); diff --git a/web/src/lib/news.ts b/web/src/lib/news.ts index fee3e810..e44075c0 100644 --- a/web/src/lib/news.ts +++ b/web/src/lib/news.ts @@ -34,33 +34,6 @@ export type AnnouncementCache = { savedAt: string; }; -export const FALLBACK_ANNOUNCEMENTS: Announcement[] = [ - { - id: "seed_announcement_gathering_area", - adminId: "seed", - title: "Know your nearest gathering area", - content: - "Check the gathering areas page and keep your location information up to date so emergency guidance can stay relevant.", - createdAt: "2026-04-29T12:10:00.000Z", - }, - { - id: "seed_announcement_volunteer_expansion", - adminId: "seed", - title: "Community safety volunteers are expanding", - content: - "New volunteer coordination improvements are being prepared to help communities respond faster during emergencies.", - createdAt: "2026-04-29T12:05:00.000Z", - }, - { - id: "seed_announcement_preparedness_checklist", - adminId: "seed", - title: "Preparedness checklist updated", - content: - "Review your household emergency bag, contact list, medication details, and nearest gathering area before an emergency occurs.", - createdAt: "2026-04-29T12:00:00.000Z", - }, -]; - function buildAnnouncementsPath(options: { limit?: number } = {}) { const params = new URLSearchParams(); if (typeof options.limit === "number") {