Skip to content
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
10 changes: 5 additions & 5 deletions src/app/(app)/weather-alert-detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,15 +109,15 @@ export default function WeatherAlertDetailScreen() {
<VStack space="xs">
<HStack className="items-center justify-between">
<Text className={`text-sm ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>{t('weatherAlerts.detail.effective')}</Text>
<Text className={`text-sm font-medium ${isDark ? 'text-white' : 'text-gray-900'}`}>{formatDateTime(alert.EffectiveUtc)}</Text>
<Text className={`text-sm font-medium ${isDark ? 'text-white' : 'text-gray-900'}`}>{formatDateTime(alert.EffectiveOnUtc || alert.EffectiveUtc)}</Text>
</HStack>
<HStack className="items-center justify-between">
<Text className={`text-sm ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>{t('weatherAlerts.detail.onset')}</Text>
<Text className={`text-sm font-medium ${isDark ? 'text-white' : 'text-gray-900'}`}>{formatDateTime(alert.OnsetUtc)}</Text>
<Text className={`text-sm font-medium ${isDark ? 'text-white' : 'text-gray-900'}`}>{formatDateTime(alert.OnsetOnUtc || alert.OnsetUtc)}</Text>
</HStack>
<HStack className="items-center justify-between">
<Text className={`text-sm ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>{t('weatherAlerts.detail.expires')}</Text>
<Text className={`text-sm font-medium ${isDark ? 'text-white' : 'text-gray-900'}`}>{formatDateTime(alert.ExpiresUtc)}</Text>
<Text className={`text-sm font-medium ${isDark ? 'text-white' : 'text-gray-900'}`}>{formatDateTime(alert.ExpiresOnUtc || alert.ExpiresUtc)}</Text>
</HStack>
</VStack>
</VStack>
Expand Down Expand Up @@ -168,10 +168,10 @@ export default function WeatherAlertDetailScreen() {
<Text className={`text-sm ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>{t('weatherAlerts.detail.certainty')}</Text>
<Text className={`text-sm font-medium ${isDark ? 'text-white' : 'text-gray-900'}`}>{t(CERTAINTY_KEYS[alert.Certainty] || 'weatherAlerts.certainty.unknown')}</Text>
</HStack>
{alert.SenderName ? (
{alert.Sender ? (
<HStack className="items-center justify-between">
<Text className={`text-sm ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>{t('weatherAlerts.detail.sender')}</Text>
<Text className={`text-sm font-medium ${isDark ? 'text-white' : 'text-gray-900'}`}>{alert.SenderName}</Text>
<Text className={`text-sm font-medium ${isDark ? 'text-white' : 'text-gray-900'}`}>{alert.Sender}</Text>
</HStack>
) : null}
</VStack>
Expand Down
10 changes: 5 additions & 5 deletions src/app/(app)/weather-alerts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,17 +76,17 @@ export default function WeatherAlertsScreen() {

switch (sort) {
case 'expires':
result.sort((a, b) => new Date(a.ExpiresUtc).getTime() - new Date(b.ExpiresUtc).getTime());
result.sort((a, b) => new Date(a.ExpiresOnUtc || a.ExpiresUtc).getTime() - new Date(b.ExpiresOnUtc || b.ExpiresUtc).getTime());
break;
case 'newest':
result.sort((a, b) => new Date(b.EffectiveUtc).getTime() - new Date(a.EffectiveUtc).getTime());
result.sort((a, b) => new Date(b.EffectiveOnUtc || b.EffectiveUtc).getTime() - new Date(a.EffectiveOnUtc || a.EffectiveUtc).getTime());
break;
case 'severity':
default:
result.sort((a, b) => {
// Core enum: Extreme=0 is most severe, so ascending puts the worst alerts first.
if (a.Severity !== b.Severity) return a.Severity - b.Severity;
return new Date(b.EffectiveUtc).getTime() - new Date(a.EffectiveUtc).getTime();
return new Date(b.EffectiveOnUtc || b.EffectiveUtc).getTime() - new Date(a.EffectiveOnUtc || a.EffectiveUtc).getTime();
});
break;
}
Expand Down Expand Up @@ -129,9 +129,9 @@ export default function WeatherAlertsScreen() {
</Text>
<HStack className="items-center justify-between">
<Text className={`text-xs ${isDark ? 'text-gray-500' : 'text-gray-500'}`}>
{translate('weatherAlerts.detail.effective')}: {formatDateTime(item.EffectiveUtc)}
{translate('weatherAlerts.detail.effective')}: {formatDateTime(item.EffectiveOnUtc || item.EffectiveUtc)}
</Text>
<Text className={`text-xs font-medium ${isDark ? 'text-amber-400' : 'text-amber-600'}`}>{formatExpiry(item.ExpiresUtc)}</Text>
<Text className={`text-xs font-medium ${isDark ? 'text-amber-400' : 'text-amber-600'}`}>{formatExpiry(item.ExpiresOnUtc || item.ExpiresUtc)}</Text>
</HStack>
</VStack>
</HStack>
Expand Down
2 changes: 1 addition & 1 deletion src/components/widgets/WeatherAlertsWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ const AlertCard: React.FC<AlertCardProps> = ({ alert, isDark, showHeadline, show
) : null}
{showExpiry ? (
<Text className={`${isDark ? 'text-gray-500' : 'text-gray-500'}`} style={{ fontSize: fontSize - 2 }}>
{formatExpiry(alert.ExpiresUtc)}
{formatExpiry(alert.ExpiresOnUtc || alert.ExpiresUtc)}
</Text>
) : null}
</VStack>
Expand Down
39 changes: 39 additions & 0 deletions src/lib/__tests__/get-time-ago-utc.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { getTimeAgoUtc } from '../utils';

describe('getTimeAgoUtc', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-08-19T20:00:00Z'));
});

afterEach(() => {
jest.useRealTimers();
});

it('reads a Z-stamped UTC string without applying the legacy offset shift', () => {
expect(getTimeAgoUtc('2026-08-19T19:55:00.000Z')).toBe('5 minutes ago');
});

it('reads an offset-stamped string without applying the legacy offset shift', () => {
expect(getTimeAgoUtc('2026-08-19T12:55:00-07:00')).toBe('5 minutes ago');
});

it('still reads legacy zone-less UTC strings via the offset-shifted comparison', () => {
// Zone-less strings parse as device-local; the function compensates by shifting "now"
// by the same offset, so the result is correct in any device timezone.
const zoneless = new Date(Date.now() - 5 * 60 * 1000).toISOString().replace(/\.\d{3}Z$/, '');
expect(getTimeAgoUtc(zoneless)).toBe('5 minutes ago');
});

it('treats Date objects as exact instants', () => {
expect(getTimeAgoUtc(new Date(Date.now() - 5 * 60 * 1000))).toBe('5 minutes ago');
});

it('treats epoch milliseconds as exact instants', () => {
expect(getTimeAgoUtc(Date.now() - 5 * 60 * 1000)).toBe('5 minutes ago');
});

it('returns Unknown for empty input', () => {
expect(getTimeAgoUtc('')).toBe('Unknown');
});
});
16 changes: 14 additions & 2 deletions src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -431,18 +431,29 @@ export function getTimeAgoUtc(time: any): string {
return 'Unknown';
}

// Legacy API values are zone-less UTC strings that new Date() parses as device-local time;
// those need "now" shifted by the same offset below. Strings carrying an explicit Z/±hh:mm
// designator parse correctly and must not be shifted.
let hasZone = false;

switch (typeof time) {
case 'number':
// Epoch milliseconds are an exact instant — never shift.
hasZone = true;
break;
case 'string':
hasZone = /(Z|[+-]\d{2}:?\d{2})$/i.test(time.trim());
time = +new Date(time);
break;
case 'object':
if (time.constructor === Date) {
if (time instanceof Date) {
// A Date is an exact instant — never shift.
hasZone = true;
time = time.getTime();
}
break;
default:
hasZone = true;
time = +new Date();
}

Expand All @@ -466,7 +477,8 @@ export function getTimeAgoUtc(time: any): string {
[5806080000, 'Last century', 'Next century'], // 60*60*24*7*4*12*100*2
[58060800000, 'centuries', 2903040000], // 60*60*24*7*4*12*100*20, 60*60*24*7*4*12*100
];
let seconds = (Number(new Date(currentDate).getTime() + new Date(currentDate).getTimezoneOffset() * 60 * 1000) - time) / 1000,
const nowMs = hasZone ? currentDate.getTime() : currentDate.getTime() + currentDate.getTimezoneOffset() * 60 * 1000;
let seconds = (nowMs - time) / 1000,
token = 'ago',
listChoice = 1;

Expand Down
36 changes: 24 additions & 12 deletions src/models/v4/weatherAlerts/weatherAlertResultData.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,37 @@
// Mirrors Resgrid Core's v4 WeatherAlertResultData (Web/Resgrid.Web.Services/Models/v4/WeatherAlerts).
// Enum-valued fields are ints (see WeatherAlertSeverity/Category/Urgency/Certainty/Status in Core).
export class WeatherAlertResultData {
public WeatherAlertId: string = '';
public DepartmentId: string = '';
public DepartmentId: number = 0;
public WeatherAlertSourceId: string = '';
public ExternalId: string = '';
public Sender: string = '';
public Event: string = '';
public Severity: number = 0;
public AlertCategory: number = 4; // Other
public Severity: number = 4; // Unknown
public Urgency: number = 4; // Unknown
public Certainty: number = 4; // Unknown
public Status: number = 0; // Active
public Headline: string = '';
public Description: string = '';
public Instruction: string = '';
public AreaDescription: string = '';
public Polygon: string = '';
public Geocodes: string = '';
public CenterGeoLocation: string = '';
// Department-local display strings (despite the Utc names) — render verbatim only.
public OnsetUtc: string = '';
public ExpiresUtc: string = '';
public EffectiveUtc: string = '';
public Status: number = 0;
public AlertCategory: number = 0;
public Urgency: number = 0;
public Certainty: number = 0;
public Sender: string = '';
public SenderName: string = '';
public SourceType: number = 0;
public ExternalId: string = '';
public References: string = '';
public Web: string = '';
public SentUtc: string = '';
public FirstSeenUtc: string = '';
public LastUpdatedUtc: string = '';
// Real UTC instants with explicit "Z" — use these for any date math. Empty on older servers.
public EffectiveOnUtc?: string = '';
public OnsetOnUtc?: string = '';
public ExpiresOnUtc?: string = '';
public SentOnUtc?: string = '';
public ReferencesExternalId: string = '';
public NotificationSent: boolean = false;
public SystemMessageId?: number;
}
Loading