mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 09:13:24 +00:00
ADD - build localized CityWarn experience
Add the Pinia domain, citizen feed, map, archive, settings, alert detail, and authorized publishing workflows. Ship complete German and English locale trees with a browser-safe fallback and contract coverage for every CityWarn label.
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useCityWarnStore } from '@/stores/citywarn'
|
||||
import type { CityWarnAlert, CityWarnBootstrap } from '@/types/citywarn'
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
|
||||
vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() }))
|
||||
vi.mock('@/stores/phone', () => ({
|
||||
usePhoneStore: () => ({ device: null, saveDeviceNamespace: vi.fn() }),
|
||||
}))
|
||||
|
||||
const mockNuiCall = vi.mocked(nuiCall)
|
||||
|
||||
function alert(overrides: Partial<CityWarnAlert> = {}): CityWarnAlert {
|
||||
return {
|
||||
area: {
|
||||
centerX: 100,
|
||||
centerY: 200,
|
||||
label: 'Mission Row',
|
||||
radius: 500,
|
||||
type: 'radius',
|
||||
},
|
||||
authorName: 'Alex Morgan',
|
||||
body: 'Avoid the area.',
|
||||
category: 'police',
|
||||
createdAt: Date.now(),
|
||||
expiresAt: Date.now() + 60_000,
|
||||
id: 'alert-1',
|
||||
instructions: 'Use another route.',
|
||||
revision: 1,
|
||||
severity: 'danger',
|
||||
sourceLabel: 'LSPD',
|
||||
startsAt: Date.now(),
|
||||
status: 'active',
|
||||
title: 'Police operation',
|
||||
updatedAt: Date.now(),
|
||||
updates: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const bootstrap: CityWarnBootstrap = {
|
||||
active: [alert()],
|
||||
archive: [],
|
||||
context: {
|
||||
allowedCategories: ['police'],
|
||||
canCityWide: true,
|
||||
canPublish: true,
|
||||
gradeLabel: 'Sergeant',
|
||||
jobLabel: 'LSPD',
|
||||
maximumSeverity: 'extreme',
|
||||
onDuty: true,
|
||||
requiresDuty: true,
|
||||
},
|
||||
onlinePlayers: 42,
|
||||
}
|
||||
|
||||
describe('CityWarn store', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
mockNuiCall.mockReset()
|
||||
})
|
||||
|
||||
it('loads server-authoritative alerts and publishing context', async () => {
|
||||
mockNuiCall.mockResolvedValueOnce({ data: bootstrap, success: true })
|
||||
const citywarn = useCityWarnStore()
|
||||
|
||||
expect(await citywarn.load()).toBe(true)
|
||||
expect(citywarn.active).toEqual(bootstrap.active)
|
||||
expect(citywarn.context?.canPublish).toBe(true)
|
||||
expect(citywarn.onlinePlayers).toBe(42)
|
||||
expect(mockNuiCall).toHaveBeenCalledWith('citywarn:bootstrap')
|
||||
})
|
||||
|
||||
it('filters warnings by category, severity and location preference', () => {
|
||||
const citywarn = useCityWarnStore()
|
||||
const policeRadius = alert()
|
||||
const cityWide = alert({
|
||||
area: {
|
||||
centerX: null,
|
||||
centerY: null,
|
||||
label: 'Los Santos',
|
||||
radius: null,
|
||||
type: 'city',
|
||||
},
|
||||
category: 'public_safety',
|
||||
severity: 'extreme',
|
||||
})
|
||||
|
||||
citywarn.preferences.categories.police = false
|
||||
expect(citywarn.accepts(policeRadius)).toBe(false)
|
||||
citywarn.preferences.categories.police = true
|
||||
citywarn.preferences.minimumSeverity = 'extreme'
|
||||
expect(citywarn.accepts(policeRadius)).toBe(false)
|
||||
citywarn.preferences.locationAlerts = false
|
||||
expect(citywarn.accepts(cityWide)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,195 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type {
|
||||
CityWarnAlert,
|
||||
CityWarnBootstrap,
|
||||
CityWarnCategory,
|
||||
CityWarnEventData,
|
||||
CityWarnPreferences,
|
||||
CityWarnPublishInput,
|
||||
CityWarnSeverity,
|
||||
} from '@/types/citywarn'
|
||||
import { nuiCall, type NuiResponse } from '@/utils/nui'
|
||||
|
||||
export const CITYWARN_CATEGORIES: CityWarnCategory[] = [
|
||||
'public_safety',
|
||||
'police',
|
||||
'fire',
|
||||
'medical',
|
||||
'infrastructure',
|
||||
'evacuation',
|
||||
]
|
||||
|
||||
export const CITYWARN_SEVERITIES: CityWarnSeverity[] = [
|
||||
'information',
|
||||
'warning',
|
||||
'danger',
|
||||
'extreme',
|
||||
]
|
||||
|
||||
const severityRank: Record<CityWarnSeverity, number> = {
|
||||
danger: 2,
|
||||
extreme: 3,
|
||||
information: 0,
|
||||
warning: 1,
|
||||
}
|
||||
|
||||
export const DEFAULT_CITYWARN_PREFERENCES: CityWarnPreferences = {
|
||||
categories: Object.fromEntries(
|
||||
CITYWARN_CATEGORIES.map((category) => [category, true]),
|
||||
) as Record<CityWarnCategory, boolean>,
|
||||
locationAlerts: true,
|
||||
minimumSeverity: 'information',
|
||||
}
|
||||
|
||||
function parsePreferences(value: unknown): CityWarnPreferences {
|
||||
const candidate =
|
||||
value && typeof value === 'object'
|
||||
? (value as Partial<CityWarnPreferences>)
|
||||
: {}
|
||||
const minimumSeverity = CITYWARN_SEVERITIES.includes(
|
||||
candidate.minimumSeverity as CityWarnSeverity,
|
||||
)
|
||||
? (candidate.minimumSeverity as CityWarnSeverity)
|
||||
: DEFAULT_CITYWARN_PREFERENCES.minimumSeverity
|
||||
|
||||
return {
|
||||
categories: Object.fromEntries(
|
||||
CITYWARN_CATEGORIES.map((category) => [
|
||||
category,
|
||||
candidate.categories?.[category] !== false,
|
||||
]),
|
||||
) as Record<CityWarnCategory, boolean>,
|
||||
locationAlerts: candidate.locationAlerts !== false,
|
||||
minimumSeverity,
|
||||
}
|
||||
}
|
||||
|
||||
function acceptsAlert(
|
||||
preferences: CityWarnPreferences,
|
||||
alert: CityWarnAlert,
|
||||
): boolean {
|
||||
if (!preferences.categories[alert.category]) return false
|
||||
if (
|
||||
severityRank[alert.severity] < severityRank[preferences.minimumSeverity]
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return preferences.locationAlerts || alert.area.type === 'city'
|
||||
}
|
||||
|
||||
export const useCityWarnStore = defineStore('citywarn', {
|
||||
state: () => ({
|
||||
active: [] as CityWarnAlert[],
|
||||
archive: [] as CityWarnAlert[],
|
||||
context: null as CityWarnBootstrap['context'] | null,
|
||||
error: '',
|
||||
initialized: false,
|
||||
isLoading: false,
|
||||
onlinePlayers: 0,
|
||||
preferences: parsePreferences(null),
|
||||
}),
|
||||
getters: {
|
||||
visibleActive(state): CityWarnAlert[] {
|
||||
return state.active.filter((alert) =>
|
||||
acceptsAlert(state.preferences, alert),
|
||||
)
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
hydratePreferences(): void {
|
||||
const phone = usePhoneStore()
|
||||
this.preferences = parsePreferences(
|
||||
phone.device?.data.citywarn?.payload ?? null,
|
||||
)
|
||||
},
|
||||
savePreferences(): void {
|
||||
usePhoneStore().saveDeviceNamespace('citywarn', this.preferences)
|
||||
},
|
||||
setCategory(category: CityWarnCategory, enabled: boolean): void {
|
||||
this.preferences.categories[category] = enabled
|
||||
this.savePreferences()
|
||||
},
|
||||
setLocationAlerts(enabled: boolean): void {
|
||||
this.preferences.locationAlerts = enabled
|
||||
this.savePreferences()
|
||||
},
|
||||
setMinimumSeverity(severity: CityWarnSeverity): void {
|
||||
this.preferences.minimumSeverity = severity
|
||||
this.savePreferences()
|
||||
},
|
||||
accepts(alert: CityWarnAlert): boolean {
|
||||
return acceptsAlert(this.preferences, alert)
|
||||
},
|
||||
async load(): Promise<boolean> {
|
||||
this.isLoading = true
|
||||
this.hydratePreferences()
|
||||
const response = await nuiCall<CityWarnBootstrap>('citywarn:bootstrap')
|
||||
this.isLoading = false
|
||||
if (response.success && response.data) {
|
||||
this.active = response.data.active
|
||||
this.archive = response.data.archive
|
||||
this.context = response.data.context
|
||||
this.onlinePlayers = response.data.onlinePlayers
|
||||
this.error = ''
|
||||
this.initialized = true
|
||||
return true
|
||||
}
|
||||
this.error = response.error ?? 'request_failed'
|
||||
return false
|
||||
},
|
||||
async refresh(): Promise<boolean> {
|
||||
return this.load()
|
||||
},
|
||||
applyEvent(data: CityWarnEventData): void {
|
||||
const alert = data.alert
|
||||
if (!alert) return
|
||||
this.active = this.active.filter((item) => item.id !== alert.id)
|
||||
this.archive = this.archive.filter((item) => item.id !== alert.id)
|
||||
if (alert.status === 'active') this.active.unshift(alert)
|
||||
else this.archive.unshift(alert)
|
||||
},
|
||||
async publish(
|
||||
input: CityWarnPublishInput,
|
||||
): Promise<NuiResponse<{ alert: CityWarnAlert; recipients: number }>> {
|
||||
const response = await nuiCall<{
|
||||
alert: CityWarnAlert
|
||||
recipients: number
|
||||
}>('citywarn:publish', input)
|
||||
if (response.success && response.data) {
|
||||
this.applyEvent({ alert: response.data.alert })
|
||||
this.error = ''
|
||||
} else this.error = response.error ?? 'request_failed'
|
||||
return response
|
||||
},
|
||||
async addUpdate(
|
||||
alert: CityWarnAlert,
|
||||
message: string,
|
||||
): Promise<NuiResponse<{ alert: CityWarnAlert }>> {
|
||||
const response = await nuiCall<{ alert: CityWarnAlert }>(
|
||||
'citywarn:update',
|
||||
{ id: alert.id, message, revision: alert.revision },
|
||||
)
|
||||
if (response.success && response.data) {
|
||||
this.applyEvent({ alert: response.data.alert })
|
||||
this.error = ''
|
||||
} else this.error = response.error ?? 'request_failed'
|
||||
return response
|
||||
},
|
||||
async resolve(
|
||||
alert: CityWarnAlert,
|
||||
message: string,
|
||||
): Promise<NuiResponse<{ alert: CityWarnAlert }>> {
|
||||
const response = await nuiCall<{ alert: CityWarnAlert }>(
|
||||
'citywarn:resolve',
|
||||
{ id: alert.id, message, revision: alert.revision },
|
||||
)
|
||||
if (response.success && response.data) {
|
||||
this.applyEvent({ alert: response.data.alert })
|
||||
this.error = ''
|
||||
} else this.error = response.error ?? 'request_failed'
|
||||
return response
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -82,6 +82,133 @@ describe('phone locale fallback', () => {
|
||||
expect(phone.t('Apps.crypto.profile.shareKey')).toBe('Share')
|
||||
})
|
||||
|
||||
it('keeps CityWarn copy translated with a partial server locale', () => {
|
||||
const phone = usePhoneStore()
|
||||
phone.open({ locales: { Apps: { citywarn: { name: 'CityWarn' } } } })
|
||||
|
||||
const cityWarnKeys = [
|
||||
'name',
|
||||
'navigation',
|
||||
'loading',
|
||||
'issuedBy',
|
||||
'updated',
|
||||
'expires',
|
||||
'affectedArea',
|
||||
'currentLocation',
|
||||
'details',
|
||||
'instructions',
|
||||
'timeline',
|
||||
'publishedBy',
|
||||
'radius',
|
||||
'emptyArchive',
|
||||
'emptyArchiveBody',
|
||||
'emptyFiltered',
|
||||
'emptyFilteredBody',
|
||||
'mapTitle',
|
||||
'mapBody',
|
||||
...['active', 'map', 'archive', 'settings'].map((key) => `tabs.${key}`),
|
||||
...['safe', 'safeBody', 'active', 'activeBody'].map(
|
||||
(key) => `hero.${key}`,
|
||||
),
|
||||
...['active', 'resolved', 'expired'].map((key) => `status.${key}`),
|
||||
...['information', 'warning', 'danger', 'extreme'].map(
|
||||
(key) => `severity.${key}`,
|
||||
),
|
||||
...[
|
||||
'public_safety',
|
||||
'police',
|
||||
'fire',
|
||||
'medical',
|
||||
'infrastructure',
|
||||
'evacuation',
|
||||
].map((key) => `categories.${key}`),
|
||||
...['title', 'body', 'offDuty', 'unavailable'].map(
|
||||
(key) => `publisher.${key}`,
|
||||
),
|
||||
...[
|
||||
'new',
|
||||
'step',
|
||||
'categoryTitle',
|
||||
'categoryBody',
|
||||
'severityTitle',
|
||||
'areaTitle',
|
||||
'areaBody',
|
||||
'areaTypes.radius',
|
||||
'areaTypes.district',
|
||||
'areaTypes.city',
|
||||
'areaLabel',
|
||||
'areaPlaceholder',
|
||||
'radius',
|
||||
'useLocation',
|
||||
'locationSet',
|
||||
'contentTitle',
|
||||
'title',
|
||||
'titlePlaceholder',
|
||||
'body',
|
||||
'bodyPlaceholder',
|
||||
'instructions',
|
||||
'instructionsPlaceholder',
|
||||
'duration',
|
||||
'durationMinutes',
|
||||
'previewTitle',
|
||||
'recipients',
|
||||
'legal',
|
||||
'back',
|
||||
'next',
|
||||
'publish',
|
||||
'publishing',
|
||||
'success',
|
||||
].map((key) => `compose.${key}`),
|
||||
...[
|
||||
'update',
|
||||
'resolve',
|
||||
'updateTitle',
|
||||
'resolveTitle',
|
||||
'updatePlaceholder',
|
||||
'resolvePlaceholder',
|
||||
'send',
|
||||
'confirm',
|
||||
'success',
|
||||
'resolved',
|
||||
].map((key) => `manage.${key}`),
|
||||
...[
|
||||
'locationTitle',
|
||||
'locationBody',
|
||||
'levelTitle',
|
||||
'levelBody',
|
||||
'categoryTitle',
|
||||
'categoryBody',
|
||||
'notificationHint',
|
||||
].map((key) => `settings.${key}`),
|
||||
...['published', 'update', 'resolved'].map(
|
||||
(key) => `notifications.${key}`,
|
||||
),
|
||||
...[
|
||||
'feature_disabled',
|
||||
'not_authorized',
|
||||
'invalid_warning',
|
||||
'invalid_update',
|
||||
'active_limit',
|
||||
'revision_conflict',
|
||||
'rate_limited',
|
||||
'request_failed',
|
||||
'not_found',
|
||||
'device_not_open',
|
||||
'device_not_owned',
|
||||
'device_locked',
|
||||
'default',
|
||||
].map((key) => `errors.${key}`),
|
||||
]
|
||||
|
||||
for (const key of cityWarnKeys) {
|
||||
const path = `Apps.citywarn.${key}`
|
||||
expect(phone.t(path), path).not.toBe(path)
|
||||
}
|
||||
expect(phone.t('Apps.citywarn.compose.recipients', { count: '42' })).toBe(
|
||||
'About 42 people are currently reachable.',
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the English Lua payload before the bundled emergency fallback', () => {
|
||||
const phone = usePhoneStore()
|
||||
phone.open({
|
||||
|
||||
@@ -658,8 +658,147 @@ const cryptoFallbackLocales = {
|
||||
},
|
||||
}
|
||||
|
||||
const citywarnFallbackLocales = {
|
||||
name: 'CityWarn',
|
||||
navigation: 'CityWarn navigation',
|
||||
loading: 'Loading warnings...',
|
||||
tabs: {
|
||||
active: 'Current',
|
||||
map: 'Map',
|
||||
archive: 'History',
|
||||
settings: 'Settings',
|
||||
},
|
||||
hero: {
|
||||
safe: 'No immediate danger',
|
||||
safeBody: 'There are no active warnings for your location.',
|
||||
active: '{count} active warnings',
|
||||
activeBody: 'Follow the guidance from the responsible authorities.',
|
||||
},
|
||||
issuedBy: 'Issued by',
|
||||
updated: 'Updated',
|
||||
expires: 'Valid until',
|
||||
affectedArea: 'Affected area',
|
||||
currentLocation: 'Current location',
|
||||
details: 'Warning details',
|
||||
instructions: 'What you should do now',
|
||||
timeline: 'Updates',
|
||||
publishedBy: 'Published by {name}',
|
||||
radius: '{count} m radius',
|
||||
status: {
|
||||
active: 'Active',
|
||||
resolved: 'Resolved',
|
||||
expired: 'Expired',
|
||||
},
|
||||
severity: {
|
||||
information: 'Information',
|
||||
warning: 'Warning',
|
||||
danger: 'Danger',
|
||||
extreme: 'Extreme danger',
|
||||
},
|
||||
categories: {
|
||||
public_safety: 'Public safety',
|
||||
police: 'Police incident',
|
||||
fire: 'Fire & rescue',
|
||||
medical: 'Health',
|
||||
infrastructure: 'Infrastructure',
|
||||
evacuation: 'Evacuation',
|
||||
},
|
||||
emptyArchive: 'No history yet',
|
||||
emptyArchiveBody: 'Resolved and expired warnings will appear here.',
|
||||
emptyFiltered: 'No matching warnings',
|
||||
emptyFilteredBody: 'Your personal filters hide the current alerts.',
|
||||
mapTitle: 'Warning areas',
|
||||
mapBody: 'Highlighted areas show the approximate scope of active alerts.',
|
||||
publisher: {
|
||||
title: 'Authority tools',
|
||||
body: 'You are on duty as {job} ({grade}) and may publish warnings.',
|
||||
offDuty: 'You must be on duty to publish warnings.',
|
||||
unavailable: 'Authority tools are not available for your current job.',
|
||||
},
|
||||
compose: {
|
||||
new: 'New warning',
|
||||
step: 'Step {step} of 4',
|
||||
categoryTitle: 'Type of danger',
|
||||
categoryBody: 'Choose the category that best matches the incident.',
|
||||
severityTitle: 'Danger level',
|
||||
areaTitle: 'Affected area',
|
||||
areaBody: 'Define where this alert applies.',
|
||||
areaTypes: {
|
||||
radius: 'Radius',
|
||||
district: 'District',
|
||||
city: 'Whole city',
|
||||
},
|
||||
areaLabel: 'Area name',
|
||||
areaPlaceholder: 'e.g. Mission Row',
|
||||
radius: 'Radius in metres',
|
||||
useLocation: 'Use current location',
|
||||
locationSet: 'Location captured',
|
||||
contentTitle: 'Warning content',
|
||||
title: 'Short title',
|
||||
titlePlaceholder: 'What happened?',
|
||||
body: 'Description',
|
||||
bodyPlaceholder: 'Describe the situation clearly and factually...',
|
||||
instructions: 'Recommended action',
|
||||
instructionsPlaceholder: 'What should residents do now?',
|
||||
duration: 'Validity',
|
||||
durationMinutes: '{count} minutes',
|
||||
previewTitle: 'Review & send',
|
||||
recipients: 'About {count} people are currently reachable.',
|
||||
legal: 'Your name, job and every change are recorded permanently.',
|
||||
back: 'Back',
|
||||
next: 'Continue',
|
||||
publish: 'Publish warning',
|
||||
publishing: 'Publishing...',
|
||||
success: 'The warning was published and distributed.',
|
||||
},
|
||||
manage: {
|
||||
update: 'Send update',
|
||||
resolve: 'Resolve warning',
|
||||
updateTitle: 'Update situation',
|
||||
resolveTitle: 'Resolve warning',
|
||||
updatePlaceholder: 'What changed?',
|
||||
resolvePlaceholder: 'Why has the warning ended?',
|
||||
send: 'Publish update',
|
||||
confirm: 'Resolve warning',
|
||||
success: 'The warning was updated.',
|
||||
resolved: 'The warning was resolved.',
|
||||
},
|
||||
settings: {
|
||||
locationTitle: 'Location-based warnings',
|
||||
locationBody:
|
||||
'Shows alerts for radiuses and districts. City-wide alerts always remain visible.',
|
||||
levelTitle: 'Minimum level',
|
||||
levelBody: 'Alerts below this level are hidden from the current view.',
|
||||
categoryTitle: 'Warning types',
|
||||
categoryBody: 'Choose which types of alerts you want to see.',
|
||||
notificationHint:
|
||||
'Critical alerts may bypass focus modes. Manage sounds in the phone settings.',
|
||||
},
|
||||
notifications: {
|
||||
published: 'New population warning',
|
||||
update: 'A warning was updated',
|
||||
resolved: 'A warning was resolved',
|
||||
},
|
||||
errors: {
|
||||
feature_disabled: 'CityWarn is disabled on this server.',
|
||||
not_authorized: 'You are not allowed to perform this action.',
|
||||
invalid_warning: 'Check all warning details.',
|
||||
invalid_update: 'Enter a valid update.',
|
||||
active_limit: 'Too many warnings are already active.',
|
||||
revision_conflict: 'The warning changed in the meantime. Reload it.',
|
||||
rate_limited: 'Please wait before trying again.',
|
||||
request_failed: 'CityWarn could not complete the request.',
|
||||
not_found: 'This warning is no longer available.',
|
||||
device_not_open: 'Open and unlock this phone before trying again.',
|
||||
device_not_owned: 'This phone is no longer available.',
|
||||
device_locked: 'Unlock this phone before trying again.',
|
||||
default: 'CityWarn is temporarily unavailable.',
|
||||
},
|
||||
}
|
||||
|
||||
const defaultLocales: LocaleTree = {
|
||||
Apps: {
|
||||
citywarn: citywarnFallbackLocales,
|
||||
crypto: cryptoFallbackLocales,
|
||||
easyShare: {
|
||||
name: 'EasyShare',
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
export type CityWarnCategory =
|
||||
| 'public_safety'
|
||||
| 'police'
|
||||
| 'fire'
|
||||
| 'medical'
|
||||
| 'infrastructure'
|
||||
| 'evacuation'
|
||||
|
||||
export type CityWarnSeverity = 'information' | 'warning' | 'danger' | 'extreme'
|
||||
|
||||
export type CityWarnStatus = 'active' | 'resolved' | 'expired'
|
||||
export type CityWarnAreaType = 'radius' | 'district' | 'city'
|
||||
export type CityWarnUpdateKind = 'published' | 'update' | 'resolved'
|
||||
|
||||
export type CityWarnArea = {
|
||||
centerX: number | null
|
||||
centerY: number | null
|
||||
label: string
|
||||
radius: number | null
|
||||
type: CityWarnAreaType
|
||||
}
|
||||
|
||||
export type CityWarnUpdate = {
|
||||
actorName: string
|
||||
createdAt: number
|
||||
id: string
|
||||
kind: CityWarnUpdateKind
|
||||
message: string
|
||||
}
|
||||
|
||||
export type CityWarnAlert = {
|
||||
area: CityWarnArea
|
||||
authorName: string
|
||||
body: string
|
||||
category: CityWarnCategory
|
||||
createdAt: number
|
||||
expiresAt: number
|
||||
id: string
|
||||
instructions: string
|
||||
revision: number
|
||||
severity: CityWarnSeverity
|
||||
sourceLabel: string
|
||||
startsAt: number
|
||||
status: CityWarnStatus
|
||||
title: string
|
||||
updatedAt: number
|
||||
updates: CityWarnUpdate[]
|
||||
}
|
||||
|
||||
export type CityWarnPublisherContext = {
|
||||
allowedCategories: CityWarnCategory[]
|
||||
canCityWide: boolean
|
||||
canPublish: boolean
|
||||
gradeLabel: string | null
|
||||
jobLabel: string | null
|
||||
maximumSeverity: CityWarnSeverity | null
|
||||
onDuty: boolean
|
||||
requiresDuty: boolean
|
||||
}
|
||||
|
||||
export type CityWarnBootstrap = {
|
||||
active: CityWarnAlert[]
|
||||
archive: CityWarnAlert[]
|
||||
context: CityWarnPublisherContext
|
||||
onlinePlayers: number
|
||||
}
|
||||
|
||||
export type CityWarnPreferences = {
|
||||
categories: Record<CityWarnCategory, boolean>
|
||||
locationAlerts: boolean
|
||||
minimumSeverity: CityWarnSeverity
|
||||
}
|
||||
|
||||
export type CityWarnPublishInput = {
|
||||
area: CityWarnArea
|
||||
body: string
|
||||
category: CityWarnCategory
|
||||
durationMinutes: number
|
||||
instructions: string
|
||||
severity: CityWarnSeverity
|
||||
title: string
|
||||
}
|
||||
|
||||
export type CityWarnEventData = {
|
||||
alert?: CityWarnAlert
|
||||
alertId?: string
|
||||
kind?: CityWarnUpdateKind
|
||||
severity?: CityWarnSeverity
|
||||
sourceLabel?: string
|
||||
text?: string
|
||||
title?: string
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(
|
||||
new URL('./CityWarnApp.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const server = readFileSync(
|
||||
new URL('../../../../sky_phone/source/server/citywarn.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const store = readFileSync(
|
||||
new URL('../../stores/citywarn.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const config = readFileSync(
|
||||
new URL('../../../../sky_phone/config/config.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('CityWarn product contract', () => {
|
||||
it('uses central Sky navigation, scrolling, settings and sheets', () => {
|
||||
expect(source).toContain('SkyPillNavigation')
|
||||
expect(source).toContain('SkyScrollArea')
|
||||
expect(source).toContain('SkySettingsGroup')
|
||||
expect(source).toContain('SkySettingsRow')
|
||||
expect(source).toContain('SkySheet')
|
||||
expect(source).not.toContain("from 'konsta/vue'")
|
||||
})
|
||||
|
||||
it('ships citizen feed, map, archive, settings and authority workflows', () => {
|
||||
expect(source).toContain(
|
||||
"type CityWarnTab = 'active' | 'map' | 'archive' | 'settings'",
|
||||
)
|
||||
expect(store).toContain("'citywarn:publish'")
|
||||
expect(store).toContain("'citywarn:update'")
|
||||
expect(store).toContain("'citywarn:resolve'")
|
||||
expect(source).toContain('map:getPlayerCoords')
|
||||
})
|
||||
|
||||
it('reserves the pill navigation, keeps sheets safe and avoids blur flicker', () => {
|
||||
expect(source).toMatch(
|
||||
/\.citywarn-scroll\.sky-scroll-area--tabbar\s*\{[^}]*padding-bottom:\s*calc\(var\(--sky-safe-area-bottom\) \+ 84px\)/s,
|
||||
)
|
||||
expect(source).toMatch(/\.citywarn-map\s*\{[^}]*height:\s*430px;/s)
|
||||
expect(source).toMatch(
|
||||
/\.citywarn-compose-sheet :deep\(\.sky-sheet__panel\)\s*\{[^}]*height:\s*88%;[^}]*overflow:\s*hidden;/s,
|
||||
)
|
||||
expect(source).toContain('-webkit-backdrop-filter: none;')
|
||||
expect(source).toContain('backdrop-filter: none;')
|
||||
expect(source).toContain('filter: none !important;')
|
||||
expect(source).toContain('will-change: auto !important;')
|
||||
expect(source).not.toContain("t('manage.success')")
|
||||
expect(source).not.toContain('<X :size="20" />')
|
||||
expect(source).not.toContain('<span>{{ alert.title }}</span>')
|
||||
})
|
||||
|
||||
it('keeps publishing authorization and validation on the server', () => {
|
||||
expect(server).toContain('Bridge.Framework.GetJob(source)')
|
||||
expect(server).toContain('SkyPhone.RequireSession(source)')
|
||||
expect(server).toContain('config.RequireDuty and not on_duty')
|
||||
expect(server).toContain('severity_rank[data.severity]')
|
||||
expect(server).toContain('sky_phone_citywarn_updates')
|
||||
expect(config).toContain('Config.CityWarn = {')
|
||||
expect(config).toContain('Publishers = {')
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1660,6 +1660,25 @@ Locales["de"] = {
|
||||
},
|
||||
errors = { profile_required = "Erstell zuerst dein Profil auf den lokalen Seiten.", invalid_profile = "Prüfe deinen Benutzernamen und deine Bio.", invalid_profile_image = "Wähle ein gültiges Foto von diesem Handy.", profile_handle_taken = "Dieser Benutzername ist bereits vergeben.", invalid_post = "Gib einen Titel und ein bisschen mehr Details.", invalid_images = "Wähle gültige Fotos von diesem Handy.", invalid_request = "Diese Aktion ist nicht gültig.", post_not_found = "Dieser Beitrag ist nicht mehr verfügbar.", citymarkt_not_found = "Dieses CityMarkt-Verzeichnis ist nicht verfügbar.", citymarkt_daily_limit = "Du hast heute schon eine CityMarkt-Liste geteilt.", citymarkt_already_shared = "Diese Auflistung wurde bereits geteilt.", not_authenticated = "Melde dich zuerst bei Sky Cloud an.", rate_limited = "Zu viele Anfragen. Versuch es gleich erneut.", request_failed = "Der Posten konnte nicht gerettet werden.", default = "Lokale Seiten sind vorübergehend nicht verfügbar." },
|
||||
},
|
||||
citywarn = {
|
||||
name = "CityWarn", navigation = "CityWarn Navigation", loading = "Warnungen werden geladen...",
|
||||
tabs = { active = "Aktuell", map = "Karte", archive = "Verlauf", settings = "Einstellungen" },
|
||||
hero = { safe = "Keine akute Gefahr", safeBody = "Für deinen Standort liegen keine aktiven Warnungen vor.", active = "{count} aktive Warnungen", activeBody = "Beachte die Hinweise der zuständigen Stellen." },
|
||||
issuedBy = "Herausgegeben von", updated = "Aktualisiert", expires = "Gültig bis", affectedArea = "Betroffenes Gebiet", currentLocation = "Aktueller Standort",
|
||||
details = "Warnungsdetails", instructions = "Was du jetzt tun solltest", timeline = "Aktualisierungen", publishedBy = "Veröffentlicht von {name}",
|
||||
radius = "{count} m Umkreis", status = { active = "Aktiv", resolved = "Aufgehoben", expired = "Abgelaufen" },
|
||||
severity = { information = "Information", warning = "Warnung", danger = "Gefahr", extreme = "Extreme Gefahr" },
|
||||
categories = { public_safety = "Öffentliche Sicherheit", police = "Polizeilage", fire = "Brand & Rettung", medical = "Gesundheit", infrastructure = "Infrastruktur", evacuation = "Evakuierung" },
|
||||
emptyArchive = "Noch kein Verlauf", emptyArchiveBody = "Aufgehobene und abgelaufene Warnungen erscheinen hier.",
|
||||
emptyFiltered = "Keine passenden Warnungen", emptyFilteredBody = "Deine persönlichen Filter blenden die aktuellen Meldungen aus.",
|
||||
mapTitle = "Warngebiete", mapBody = "Die markierten Flächen zeigen den ungefähren Geltungsbereich aktiver Meldungen.",
|
||||
publisher = { title = "Behördenbereich", body = "Du bist als {job} ({grade}) im Dienst und darfst Warnungen veröffentlichen.", offDuty = "Du musst im Dienst sein, um Warnungen zu veröffentlichen.", unavailable = "Für deinen aktuellen Job ist der Behördenbereich nicht freigeschaltet." },
|
||||
compose = { new = "Neue Warnung", step = "Schritt {step} von 4", categoryTitle = "Art der Gefahr", categoryBody = "Wähle die Stelle, die am besten zur Lage passt.", severityTitle = "Gefahrenstufe", areaTitle = "Betroffenes Gebiet", areaBody = "Lege fest, für welchen Bereich die Meldung gilt.", areaTypes = { radius = "Umkreis", district = "Stadtteil", city = "Ganze Stadt" }, areaLabel = "Gebietsname", areaPlaceholder = "z. B. Mission Row", radius = "Radius in Metern", useLocation = "Aktuellen Standort verwenden", locationSet = "Standort übernommen", contentTitle = "Inhalt der Warnung", title = "Kurzer Titel", titlePlaceholder = "Was ist passiert?", body = "Beschreibung", bodyPlaceholder = "Beschreibe die Lage klar und sachlich...", instructions = "Handlungsempfehlung", instructionsPlaceholder = "Was sollen Einwohner jetzt tun?", duration = "Gültigkeit", durationMinutes = "{count} Minuten", previewTitle = "Prüfen & senden", recipients = "Etwa {count} Personen sind aktuell erreichbar.", legal = "Dein Name, Job und jede Änderung werden dauerhaft protokolliert.", back = "Zurück", next = "Weiter", publish = "Warnung veröffentlichen", publishing = "Wird veröffentlicht...", success = "Die Warnung wurde veröffentlicht und verteilt." },
|
||||
manage = { update = "Update senden", resolve = "Warnung aufheben", updateTitle = "Lage aktualisieren", resolveTitle = "Warnung aufheben", updatePlaceholder = "Was hat sich geändert?", resolvePlaceholder = "Warum ist die Warnung beendet?", send = "Update veröffentlichen", confirm = "Warnung aufheben", success = "Die Warnung wurde aktualisiert.", resolved = "Die Warnung wurde aufgehoben." },
|
||||
settings = { locationTitle = "Standortbezogene Warnungen", locationBody = "Zeigt Meldungen für Umkreise und Stadtteile. Stadtweite Warnungen bleiben immer sichtbar.", levelTitle = "Mindeststufe", levelBody = "Meldungen unter dieser Stufe werden in der aktuellen Ansicht ausgeblendet.", categoryTitle = "Warnarten", categoryBody = "Wähle, welche Arten von Meldungen du sehen möchtest.", notificationHint = "Kritische Warnungen können Fokus-Modi übergehen. Töne verwaltest du in den Telefon-Einstellungen." },
|
||||
notifications = { published = "Neue Bevölkerungswarnung", update = "Eine Warnung wurde aktualisiert", resolved = "Eine Warnung wurde aufgehoben" },
|
||||
errors = { feature_disabled = "CityWarn ist auf diesem Server deaktiviert.", not_authorized = "Du darfst diese Aktion nicht ausführen.", invalid_warning = "Prüfe alle Angaben der Warnung.", invalid_update = "Gib ein gültiges Update ein.", active_limit = "Es sind bereits zu viele Warnungen aktiv.", revision_conflict = "Die Warnung wurde zwischenzeitlich geändert. Lade sie neu.", rate_limited = "Bitte warte kurz, bevor du es erneut versuchst.", request_failed = "CityWarn konnte die Anfrage nicht abschließen.", not_found = "Diese Warnung ist nicht mehr verfügbar.", device_not_open = "Öffne und entsperre dieses Telefon, bevor du es erneut versuchst.", device_not_owned = "Dieses Telefon ist nicht mehr verfügbar.", device_locked = "Entsperre dieses Telefon, bevor du es erneut versuchst.", default = "CityWarn ist vorübergehend nicht verfügbar." },
|
||||
},
|
||||
map = {
|
||||
name = "Karte", controls = "Kartensteuerung", currentLocation = "Aktueller Standort",
|
||||
locationUnavailable = "Dein aktueller Standort ist nicht verfügbar.",
|
||||
|
||||
@@ -1660,6 +1660,25 @@ Locales["en"] = {
|
||||
},
|
||||
errors = { profile_required = "Create your Local Pages profile first.", invalid_profile = "Check your username and bio.", invalid_profile_image = "Choose a valid photo from this phone.", profile_handle_taken = "This username is already taken.", invalid_post = "Add a title and a little more detail.", invalid_images = "Choose valid photos from this phone.", invalid_request = "This action is not valid.", post_not_found = "This post is no longer available.", citymarkt_not_found = "This CityMarkt listing is unavailable.", citymarkt_daily_limit = "You already shared a CityMarkt listing today.", citymarkt_already_shared = "This listing was already shared.", not_authenticated = "Sign in to Sky Cloud first.", rate_limited = "Too many requests. Try again shortly.", request_failed = "The post could not be saved.", default = "Local Pages is temporarily unavailable." },
|
||||
},
|
||||
citywarn = {
|
||||
name = "CityWarn", navigation = "CityWarn navigation", loading = "Loading warnings...",
|
||||
tabs = { active = "Current", map = "Map", archive = "History", settings = "Settings" },
|
||||
hero = { safe = "No immediate danger", safeBody = "There are no active warnings for your location.", active = "{count} active warnings", activeBody = "Follow the guidance from the responsible authorities." },
|
||||
issuedBy = "Issued by", updated = "Updated", expires = "Valid until", affectedArea = "Affected area", currentLocation = "Current location",
|
||||
details = "Warning details", instructions = "What you should do now", timeline = "Updates", publishedBy = "Published by {name}",
|
||||
radius = "{count} m radius", status = { active = "Active", resolved = "Resolved", expired = "Expired" },
|
||||
severity = { information = "Information", warning = "Warning", danger = "Danger", extreme = "Extreme danger" },
|
||||
categories = { public_safety = "Public safety", police = "Police incident", fire = "Fire & rescue", medical = "Health", infrastructure = "Infrastructure", evacuation = "Evacuation" },
|
||||
emptyArchive = "No history yet", emptyArchiveBody = "Resolved and expired warnings will appear here.",
|
||||
emptyFiltered = "No matching warnings", emptyFilteredBody = "Your personal filters hide the current alerts.",
|
||||
mapTitle = "Warning areas", mapBody = "Highlighted areas show the approximate scope of active alerts.",
|
||||
publisher = { title = "Authority tools", body = "You are on duty as {job} ({grade}) and may publish warnings.", offDuty = "You must be on duty to publish warnings.", unavailable = "Authority tools are not available for your current job." },
|
||||
compose = { new = "New warning", step = "Step {step} of 4", categoryTitle = "Type of danger", categoryBody = "Choose the category that best matches the incident.", severityTitle = "Danger level", areaTitle = "Affected area", areaBody = "Define where this alert applies.", areaTypes = { radius = "Radius", district = "District", city = "Whole city" }, areaLabel = "Area name", areaPlaceholder = "e.g. Mission Row", radius = "Radius in metres", useLocation = "Use current location", locationSet = "Location captured", contentTitle = "Warning content", title = "Short title", titlePlaceholder = "What happened?", body = "Description", bodyPlaceholder = "Describe the situation clearly and factually...", instructions = "Recommended action", instructionsPlaceholder = "What should residents do now?", duration = "Validity", durationMinutes = "{count} minutes", previewTitle = "Review & send", recipients = "About {count} people are currently reachable.", legal = "Your name, job and every change are recorded permanently.", back = "Back", next = "Continue", publish = "Publish warning", publishing = "Publishing...", success = "The warning was published and distributed." },
|
||||
manage = { update = "Send update", resolve = "Resolve warning", updateTitle = "Update situation", resolveTitle = "Resolve warning", updatePlaceholder = "What changed?", resolvePlaceholder = "Why has the warning ended?", send = "Publish update", confirm = "Resolve warning", success = "The warning was updated.", resolved = "The warning was resolved." },
|
||||
settings = { locationTitle = "Location-based warnings", locationBody = "Shows alerts for radiuses and districts. City-wide alerts always remain visible.", levelTitle = "Minimum level", levelBody = "Alerts below this level are hidden from the current view.", categoryTitle = "Warning types", categoryBody = "Choose which types of alerts you want to see.", notificationHint = "Critical alerts may bypass focus modes. Manage sounds in the phone settings." },
|
||||
notifications = { published = "New population warning", update = "A warning was updated", resolved = "A warning was resolved" },
|
||||
errors = { feature_disabled = "CityWarn is disabled on this server.", not_authorized = "You are not allowed to perform this action.", invalid_warning = "Check all warning details.", invalid_update = "Enter a valid update.", active_limit = "Too many warnings are already active.", revision_conflict = "The warning changed in the meantime. Reload it.", rate_limited = "Please wait before trying again.", request_failed = "CityWarn could not complete the request.", not_found = "This warning is no longer available.", device_not_open = "Open and unlock this phone before trying again.", device_not_owned = "This phone is no longer available.", device_locked = "Unlock this phone before trying again.", default = "CityWarn is temporarily unavailable." },
|
||||
},
|
||||
map = {
|
||||
name = "Map", controls = "Map controls", currentLocation = "Current Location",
|
||||
locationUnavailable = "Your current location is unavailable.",
|
||||
|
||||
Reference in New Issue
Block a user