Refactor screens and components for improved readability and consistency

- Simplified state management and data fetching in IncidentEpisodeDetailScreen.
- Enhanced code clarity by using arrow functions consistently and removing unnecessary destructuring.
- Improved type annotations across various screens for better TypeScript support.
- Streamlined the rendering of components in IncidentEpisodesScreen and IncidentsScreen.
- Updated NotificationPreferencesScreen to use consistent function signatures and improved readability.
- Refactored ProjectSelectionScreen and SettingsScreen for better structure and clarity.
- Enhanced LoginScreen and ServerUrlScreen with clearer type definitions and improved error handling.
- Updated storage utilities to ensure consistent type usage and improved code clarity.
- Refactored theme context and spacing utilities for better type safety and readability.
- Improved color and date utility functions for better maintainability.
This commit is contained in:
Nawaz Dhandala
2026-02-10 22:29:37 +00:00
parent 2bc72dbdb6
commit 59b3fc0334
57 changed files with 1122 additions and 900 deletions

View File

@@ -547,4 +547,3 @@ export const InboundEmailDomain: string | undefined =
export const InboundEmailWebhookSecret: string | undefined =
process.env["INBOUND_EMAIL_WEBHOOK_SECRET"] || undefined;

View File

@@ -73,9 +73,9 @@ export default class UserMiddleware {
}
// 2. Fallback: Check Authorization: Bearer <token> header (mobile app flow)
const authHeader: string | undefined = req.headers[
"authorization"
] as string | undefined;
const authHeader: string | undefined = req.headers["authorization"] as
| string
| undefined;
if (authHeader && authHeader.startsWith("Bearer ")) {
return authHeader.substring(7);
}

View File

@@ -1118,7 +1118,8 @@ export class Service extends DatabaseService<Model> {
},
],
message: pushMessage,
deviceType: notificationRuleItem.userPush.deviceType! as PushDeviceType,
deviceType: notificationRuleItem.userPush
.deviceType! as PushDeviceType,
},
{
projectId: options.projectId,
@@ -1194,7 +1195,8 @@ export class Service extends DatabaseService<Model> {
},
],
message: pushMessage,
deviceType: notificationRuleItem.userPush.deviceType! as PushDeviceType,
deviceType: notificationRuleItem.userPush
.deviceType! as PushDeviceType,
},
{
projectId: options.projectId,
@@ -1271,7 +1273,8 @@ export class Service extends DatabaseService<Model> {
},
],
message: pushMessage,
deviceType: notificationRuleItem.userPush.deviceType! as PushDeviceType,
deviceType: notificationRuleItem.userPush
.deviceType! as PushDeviceType,
},
{
projectId: options.projectId,

View File

@@ -1,8 +1,10 @@
import { registerRootComponent } from 'expo';
import { registerRootComponent } from "expo";
import App from './App';
import App from "./App";
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
// It also ensures that whether you load the app in Expo Go or in a native build,
// the environment is set up appropriately
/*
* registerRootComponent calls AppRegistry.registerComponent('main', () => App);
* It also ensures that whether you load the app in Expo Go or in a native build,
* the environment is set up appropriately
*/
registerRootComponent(App);

View File

@@ -28,7 +28,12 @@ function AppContent(): React.JSX.Element {
const { theme } = useTheme();
return (
<View style={[styles.container, { backgroundColor: theme.colors.backgroundPrimary }]}>
<View
style={[
styles.container,
{ backgroundColor: theme.colors.backgroundPrimary },
]}
>
<StatusBar style={theme.isDark ? "light" : "dark"} />
<RootNavigator />
<OfflineBanner />

View File

@@ -1,3 +1,4 @@
import type { AxiosResponse } from "axios";
import apiClient from "./client";
import type {
ListResponse,
@@ -17,7 +18,7 @@ export async function fetchAlerts(
query.currentAlertState = { isResolvedState: false };
}
const response = await apiClient.post(
const response: AxiosResponse = await apiClient.post(
`/api/alert/get-list?skip=${skip}&limit=${limit}`,
{
query,
@@ -45,7 +46,7 @@ export async function fetchAlertById(
projectId: string,
alertId: string,
): Promise<AlertItem> {
const response = await apiClient.post(
const response: AxiosResponse = await apiClient.post(
"/api/alert/get-list?skip=0&limit=1",
{
query: { _id: alertId },
@@ -72,7 +73,7 @@ export async function fetchAlertById(
export async function fetchAlertStates(
projectId: string,
): Promise<AlertState[]> {
const response = await apiClient.post(
const response: AxiosResponse = await apiClient.post(
"/api/alert-state/get-list?skip=0&limit=20",
{
query: {},
@@ -98,7 +99,7 @@ export async function fetchAlertStateTimeline(
projectId: string,
alertId: string,
): Promise<StateTimelineItem[]> {
const response = await apiClient.post(
const response: AxiosResponse = await apiClient.post(
"/api/alert-state-timeline/get-list?skip=0&limit=50",
{
query: { alertId },

View File

@@ -1,7 +1,11 @@
import axios from "axios";
import axios, { AxiosResponse } from "axios";
import apiClient from "./client";
import { getServerUrl } from "../storage/serverUrl";
import { storeTokens, clearTokens } from "../storage/keychain";
import {
storeTokens,
clearTokens,
type StoredTokens,
} from "../storage/keychain";
export interface LoginResponse {
accessToken: string;
@@ -18,7 +22,7 @@ export interface LoginResponse {
export async function validateServerUrl(url: string): Promise<boolean> {
try {
const response = await axios.get(`${url}/api/status`, {
const response: AxiosResponse = await axios.get(`${url}/api/status`, {
timeout: 10000,
});
return response.status === 200;
@@ -31,9 +35,9 @@ export async function login(
email: string,
password: string,
): Promise<LoginResponse> {
const serverUrl = await getServerUrl();
const serverUrl: string = await getServerUrl();
const response = await apiClient.post(
const response: AxiosResponse = await apiClient.post(
`${serverUrl}/identity/login`,
{
data: {
@@ -53,7 +57,8 @@ export async function login(
},
);
const responseData = response.data;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const responseData: any = response.data;
// Check if 2FA is required
if (
@@ -91,9 +96,9 @@ export async function login(
export async function logout(): Promise<void> {
try {
const serverUrl = await getServerUrl();
const serverUrl: string = await getServerUrl();
const { getTokens } = await import("../storage/keychain");
const tokens = await getTokens();
const tokens: StoredTokens | null = await getTokens();
if (tokens?.refreshToken) {
await apiClient.post(

View File

@@ -1,13 +1,19 @@
import axios, { AxiosInstance, InternalAxiosRequestConfig, AxiosError } from "axios";
import axios, {
AxiosInstance,
AxiosResponse,
InternalAxiosRequestConfig,
AxiosError,
} from "axios";
import { getServerUrl } from "../storage/serverUrl";
import {
getCachedAccessToken,
getTokens,
storeTokens,
clearTokens,
type StoredTokens,
} from "../storage/keychain";
let isRefreshing = false;
let isRefreshing: boolean = false;
let refreshSubscribers: Array<(token: string) => void> = [];
let onAuthFailure: (() => void) | null = null;
@@ -16,7 +22,7 @@ function subscribeTokenRefresh(callback: (token: string) => void): void {
}
function onTokenRefreshed(newToken: string): void {
refreshSubscribers.forEach((callback) => {
refreshSubscribers.forEach((callback: (token: string) => void) => {
callback(newToken);
});
refreshSubscribers = [];
@@ -40,7 +46,7 @@ apiClient.interceptors.request.use(
config.baseURL = await getServerUrl();
}
const token = getCachedAccessToken();
const token: string | null = getCachedAccessToken();
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
@@ -51,11 +57,13 @@ apiClient.interceptors.request.use(
// Response interceptor: handle 401 with token refresh queue
apiClient.interceptors.response.use(
(response) => {
(response: AxiosResponse) => {
return response;
},
async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & {
const originalRequest: InternalAxiosRequestConfig & {
_retry?: boolean;
} = error.config as InternalAxiosRequestConfig & {
_retry?: boolean;
};
@@ -64,7 +72,7 @@ apiClient.interceptors.response.use(
}
if (isRefreshing) {
return new Promise((resolve) => {
return new Promise((resolve: (value: AxiosResponse) => void) => {
subscribeTokenRefresh((newToken: string) => {
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${newToken}`;
@@ -78,15 +86,18 @@ apiClient.interceptors.response.use(
isRefreshing = true;
try {
const tokens = await getTokens();
const tokens: StoredTokens | null = await getTokens();
if (!tokens?.refreshToken) {
throw new Error("No refresh token available");
}
const serverUrl = await getServerUrl();
const response = await axios.post(`${serverUrl}/identity/refresh-token`, {
refreshToken: tokens.refreshToken,
});
const serverUrl: string = await getServerUrl();
const response: AxiosResponse = await axios.post(
`${serverUrl}/identity/refresh-token`,
{
refreshToken: tokens.refreshToken,
},
);
const { accessToken, refreshToken, refreshTokenExpiresAt } =
response.data;

View File

@@ -1,3 +1,4 @@
import type { AxiosResponse } from "axios";
import apiClient from "./client";
import type {
ListResponse,
@@ -17,7 +18,7 @@ export async function fetchIncidents(
query.currentIncidentState = { isResolvedState: false };
}
const response = await apiClient.post(
const response: AxiosResponse = await apiClient.post(
`/api/incident/get-list?skip=${skip}&limit=${limit}`,
{
query,
@@ -46,7 +47,7 @@ export async function fetchIncidentById(
projectId: string,
incidentId: string,
): Promise<IncidentItem> {
const response = await apiClient.post(
const response: AxiosResponse = await apiClient.post(
"/api/incident/get-list?skip=0&limit=1",
{
query: { _id: incidentId },
@@ -74,7 +75,7 @@ export async function fetchIncidentById(
export async function fetchIncidentStates(
projectId: string,
): Promise<IncidentState[]> {
const response = await apiClient.post(
const response: AxiosResponse = await apiClient.post(
"/api/incident-state/get-list?skip=0&limit=20",
{
query: {},
@@ -100,7 +101,7 @@ export async function fetchIncidentStateTimeline(
projectId: string,
incidentId: string,
): Promise<StateTimelineItem[]> {
const response = await apiClient.post(
const response: AxiosResponse = await apiClient.post(
"/api/incident-state-timeline/get-list?skip=0&limit=50",
{
query: { incidentId },

View File

@@ -7,7 +7,11 @@ export async function registerPushDevice(params: {
projectId: string;
}): Promise<void> {
const deviceType =
Platform.OS === "ios" ? "iOS" : Platform.OS === "android" ? "Android" : "Web";
Platform.OS === "ios"
? "iOS"
: Platform.OS === "android"
? "Android"
: "Web";
try {
await apiClient.post("/api/user-push/register", {
@@ -25,9 +29,7 @@ export async function registerPushDevice(params: {
}
}
export async function unregisterPushDevice(
deviceToken: string,
): Promise<void> {
export async function unregisterPushDevice(deviceToken: string): Promise<void> {
try {
await apiClient.post("/api/user-push/unregister", {
deviceToken: deviceToken,

View File

@@ -16,15 +16,15 @@ export default function AlertCard({
}: AlertCardProps): React.JSX.Element {
const { theme } = useTheme();
const stateColor = alert.currentAlertState?.color
const stateColor: string = alert.currentAlertState?.color
? rgbToHex(alert.currentAlertState.color)
: theme.colors.textTertiary;
const severityColor = alert.alertSeverity?.color
const severityColor: string = alert.alertSeverity?.color
? rgbToHex(alert.alertSeverity.color)
: theme.colors.textTertiary;
const timeString = formatRelativeTime(alert.createdAt);
const timeString: string = formatRelativeTime(alert.createdAt);
return (
<TouchableOpacity
@@ -41,12 +41,7 @@ export default function AlertCard({
accessibilityLabel={`Alert ${alert.alertNumberWithPrefix || alert.alertNumber}, ${alert.title}. State: ${alert.currentAlertState?.name ?? "unknown"}. Severity: ${alert.alertSeverity?.name ?? "unknown"}.`}
>
<View style={styles.topRow}>
<Text
style={[
styles.number,
{ color: theme.colors.textTertiary },
]}
>
<Text style={[styles.number, { color: theme.colors.textTertiary }]}>
{alert.alertNumberWithPrefix || `#${alert.alertNumber}`}
</Text>
<Text style={[styles.time, { color: theme.colors.textTertiary }]}>
@@ -73,7 +68,9 @@ export default function AlertCard({
]}
>
<View style={[styles.dot, { backgroundColor: stateColor }]} />
<Text style={[styles.badgeText, { color: theme.colors.textPrimary }]}>
<Text
style={[styles.badgeText, { color: theme.colors.textPrimary }]}
>
{alert.currentAlertState.name}
</Text>
</View>
@@ -81,10 +78,7 @@ export default function AlertCard({
{alert.alertSeverity ? (
<View
style={[
styles.badge,
{ backgroundColor: severityColor + "26" },
]}
style={[styles.badge, { backgroundColor: severityColor + "26" }]}
>
<Text style={[styles.badgeText, { color: severityColor }]}>
{alert.alertSeverity.name}
@@ -105,7 +99,7 @@ export default function AlertCard({
);
}
const styles = StyleSheet.create({
const styles: ReturnType<typeof StyleSheet.create> = StyleSheet.create({
card: {
padding: 16,
borderRadius: 12,

View File

@@ -17,17 +17,14 @@ function EmptyIcon({
icon: EmptyIcon;
color: string;
}): React.JSX.Element {
// Simple geometric SVG-style icons using View primitives
// Monochrome, clean, professional — not cartoon/playful
/*
* Simple geometric SVG-style icons using View primitives
* Monochrome, clean, professional — not cartoon/playful
*/
if (icon === "incidents") {
return (
<View style={styles.iconContainer}>
<View
style={[
styles.iconShield,
{ borderColor: color },
]}
>
<View style={[styles.iconShield, { borderColor: color }]}>
<View style={[styles.iconCheckmark, { backgroundColor: color }]} />
</View>
</View>
@@ -78,7 +75,11 @@ export default function EmptyState({
<Text
style={[
theme.typography.titleSmall,
{ color: theme.colors.textPrimary, textAlign: "center", marginTop: 20 },
{
color: theme.colors.textPrimary,
textAlign: "center",
marginTop: 20,
},
]}
>
{title}
@@ -102,7 +103,7 @@ export default function EmptyState({
);
}
const styles = StyleSheet.create({
const styles: ReturnType<typeof StyleSheet.create> = StyleSheet.create({
container: {
flex: 1,
alignItems: "center",

View File

@@ -63,9 +63,7 @@ export default function EpisodeCard(
activeOpacity={0.7}
>
<View style={styles.topRow}>
<Text
style={[styles.number, { color: theme.colors.textTertiary }]}
>
<Text style={[styles.number, { color: theme.colors.textTertiary }]}>
{episode.episodeNumberWithPrefix || `#${episode.episodeNumber}`}
</Text>
<Text style={[styles.time, { color: theme.colors.textTertiary }]}>
@@ -102,10 +100,7 @@ export default function EpisodeCard(
{severity ? (
<View
style={[
styles.badge,
{ backgroundColor: severityColor + "26" },
]}
style={[styles.badge, { backgroundColor: severityColor + "26" }]}
>
<Text style={[styles.badgeText, { color: severityColor }]}>
{severity.name}

View File

@@ -3,7 +3,7 @@ import { View, Text, StyleSheet, TouchableOpacity } from "react-native";
import { useTheme } from "../theme";
import { rgbToHex } from "../utils/color";
import { formatRelativeTime } from "../utils/date";
import type { IncidentItem } from "../api/types";
import type { IncidentItem, NamedEntity } from "../api/types";
interface IncidentCardProps {
incident: IncidentItem;
@@ -16,16 +16,16 @@ export default function IncidentCard({
}: IncidentCardProps): React.JSX.Element {
const { theme } = useTheme();
const stateColor = incident.currentIncidentState?.color
const stateColor: string = incident.currentIncidentState?.color
? rgbToHex(incident.currentIncidentState.color)
: theme.colors.textTertiary;
const severityColor = incident.incidentSeverity?.color
const severityColor: string = incident.incidentSeverity?.color
? rgbToHex(incident.incidentSeverity.color)
: theme.colors.textTertiary;
const monitorCount = incident.monitors?.length ?? 0;
const timeString = formatRelativeTime(
const monitorCount: number = incident.monitors?.length ?? 0;
const timeString: string = formatRelativeTime(
incident.declaredAt || incident.createdAt,
);
@@ -44,12 +44,7 @@ export default function IncidentCard({
accessibilityLabel={`Incident ${incident.incidentNumberWithPrefix || incident.incidentNumber}, ${incident.title}. State: ${incident.currentIncidentState?.name ?? "unknown"}. Severity: ${incident.incidentSeverity?.name ?? "unknown"}.`}
>
<View style={styles.topRow}>
<Text
style={[
styles.number,
{ color: theme.colors.textTertiary },
]}
>
<Text style={[styles.number, { color: theme.colors.textTertiary }]}>
{incident.incidentNumberWithPrefix || `#${incident.incidentNumber}`}
</Text>
<Text style={[styles.time, { color: theme.colors.textTertiary }]}>
@@ -76,7 +71,9 @@ export default function IncidentCard({
]}
>
<View style={[styles.dot, { backgroundColor: stateColor }]} />
<Text style={[styles.badgeText, { color: theme.colors.textPrimary }]}>
<Text
style={[styles.badgeText, { color: theme.colors.textPrimary }]}
>
{incident.currentIncidentState.name}
</Text>
</View>
@@ -84,10 +81,7 @@ export default function IncidentCard({
{incident.incidentSeverity ? (
<View
style={[
styles.badge,
{ backgroundColor: severityColor + "26" },
]}
style={[styles.badge, { backgroundColor: severityColor + "26" }]}
>
<Text style={[styles.badgeText, { color: severityColor }]}>
{incident.incidentSeverity.name}
@@ -101,14 +95,18 @@ export default function IncidentCard({
style={[styles.monitors, { color: theme.colors.textSecondary }]}
numberOfLines={1}
>
{incident.monitors.map((m) => m.name).join(", ")}
{incident.monitors
.map((m: NamedEntity) => {
return m.name;
})
.join(", ")}
</Text>
) : null}
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
const styles: ReturnType<typeof StyleSheet.create> = StyleSheet.create({
card: {
padding: 16,
borderRadius: 12,

View File

@@ -13,7 +13,7 @@ export default function ProjectBadge({
}: ProjectBadgeProps): React.JSX.Element {
const { theme } = useTheme();
const dotColor = color || theme.colors.actionPrimary;
const dotColor: string = color || theme.colors.actionPrimary;
return (
<View style={styles.container}>
@@ -31,7 +31,7 @@ export default function ProjectBadge({
);
}
const styles = StyleSheet.create({
const styles: ReturnType<typeof StyleSheet.create> = StyleSheet.create({
container: {
flexDirection: "row",
alignItems: "center",

View File

@@ -2,12 +2,7 @@ import React from "react";
import { View, Text, StyleSheet } from "react-native";
import { useTheme } from "../theme";
export type SeverityLevel =
| "critical"
| "major"
| "minor"
| "warning"
| "info";
export type SeverityLevel = "critical" | "major" | "minor" | "warning" | "info";
interface SeverityBadgeProps {
severity: SeverityLevel;
@@ -43,8 +38,8 @@ export default function SeverityBadge({
},
};
const colors = colorMap[severity];
const displayLabel = label || severity;
const colors: { text: string; bg: string } = colorMap[severity];
const displayLabel: string = label || severity;
return (
<View style={[styles.badge, { backgroundColor: colors.bg }]}>
@@ -55,7 +50,7 @@ export default function SeverityBadge({
);
}
const styles = StyleSheet.create({
const styles: ReturnType<typeof StyleSheet.create> = StyleSheet.create({
badge: {
paddingHorizontal: 8,
paddingVertical: 4,

View File

@@ -18,11 +18,11 @@ export default function SkeletonCard({
variant = "card",
}: SkeletonCardProps): React.JSX.Element {
const { theme } = useTheme();
const opacity = useRef(new Animated.Value(0.3)).current;
const reduceMotion = useRef(false);
const opacity: Animated.Value = useRef(new Animated.Value(0.3)).current;
const reduceMotion: React.MutableRefObject<boolean> = useRef(false);
useEffect(() => {
AccessibilityInfo.isReduceMotionEnabled().then((enabled) => {
AccessibilityInfo.isReduceMotionEnabled().then((enabled: boolean) => {
reduceMotion.current = enabled;
});
}, []);
@@ -33,7 +33,7 @@ export default function SkeletonCard({
return;
}
const animation = Animated.loop(
const animation: Animated.CompositeAnimation = Animated.loop(
Animated.sequence([
Animated.timing(opacity, {
toValue: 0.7,
@@ -98,10 +98,7 @@ export default function SkeletonCard({
if (variant === "detail") {
return (
<Animated.View
style={[
styles.detailContainer,
{ opacity },
]}
style={[styles.detailContainer, { opacity }]}
accessibilityLabel="Loading content"
accessibilityRole="progressbar"
>
@@ -137,22 +134,24 @@ export default function SkeletonCard({
},
]}
>
{Array.from({ length: 3 }).map((_, index) => (
<View key={index} style={styles.detailRow}>
<View
style={[
styles.detailLabel,
{ backgroundColor: theme.colors.backgroundTertiary },
]}
/>
<View
style={[
styles.detailValue,
{ backgroundColor: theme.colors.backgroundTertiary },
]}
/>
</View>
))}
{Array.from({ length: 3 }).map((_: unknown, index: number) => {
return (
<View key={index} style={styles.detailRow}>
<View
style={[
styles.detailLabel,
{ backgroundColor: theme.colors.backgroundTertiary },
]}
/>
<View
style={[
styles.detailValue,
{ backgroundColor: theme.colors.backgroundTertiary },
]}
/>
</View>
);
})}
</View>
</Animated.View>
);
@@ -209,23 +208,25 @@ export default function SkeletonCard({
/>
</View>
{/* Body lines */}
{Array.from({ length: Math.max(lines - 1, 1) }).map((_, index) => (
<View
key={index}
style={[
styles.line,
{
backgroundColor: theme.colors.backgroundTertiary,
width: lineWidths[index % lineWidths.length],
},
]}
/>
))}
{Array.from({ length: Math.max(lines - 1, 1) }).map((_: unknown, index: number) => {
return (
<View
key={index}
style={[
styles.line,
{
backgroundColor: theme.colors.backgroundTertiary,
width: lineWidths[index % lineWidths.length],
},
]}
/>
);
})}
</Animated.View>
);
}
const styles = StyleSheet.create({
const styles: ReturnType<typeof StyleSheet.create> = StyleSheet.create({
card: {
padding: 16,
borderRadius: 12,

View File

@@ -28,8 +28,8 @@ export default function StateBadge({
muted: theme.colors.stateMuted,
};
const color = colorMap[state];
const displayLabel = label || state;
const color: string = colorMap[state];
const displayLabel: string = label || state;
return (
<View
@@ -41,19 +41,14 @@ export default function StateBadge({
]}
>
<View style={[styles.dot, { backgroundColor: color }]} />
<Text
style={[
styles.text,
{ color: theme.colors.textPrimary },
]}
>
<Text style={[styles.text, { color: theme.colors.textPrimary }]}>
{displayLabel.charAt(0).toUpperCase() + displayLabel.slice(1)}
</Text>
</View>
);
}
const styles = StyleSheet.create({
const styles: ReturnType<typeof StyleSheet.create> = StyleSheet.create({
badge: {
flexDirection: "row",
alignItems: "center",

View File

@@ -5,6 +5,9 @@ import {
StyleSheet,
Animated,
PanResponder,
type GestureResponderEvent,
type PanResponderGestureState,
type PanResponderInstance,
} from "react-native";
import { useTheme } from "../theme";
import { useHaptics } from "../hooks/useHaptics";
@@ -21,7 +24,7 @@ interface SwipeableCardProps {
rightAction?: SwipeAction;
}
const SWIPE_THRESHOLD = 80;
const SWIPE_THRESHOLD: number = 80;
export default function SwipeableCard({
children,
@@ -30,8 +33,8 @@ export default function SwipeableCard({
}: SwipeableCardProps): React.JSX.Element {
const { theme } = useTheme();
const { mediumImpact } = useHaptics();
const translateX = useRef(new Animated.Value(0)).current;
const hasTriggeredHaptic = useRef(false);
const translateX: Animated.Value = useRef(new Animated.Value(0)).current;
const hasTriggeredHaptic: React.MutableRefObject<boolean> = useRef(false);
const panResponder = useRef(
PanResponder.create({
@@ -90,20 +93,14 @@ export default function SwipeableCard({
<View style={styles.actionsContainer}>
{leftAction ? (
<View
style={[
styles.actionLeft,
{ backgroundColor: leftAction.color },
]}
style={[styles.actionLeft, { backgroundColor: leftAction.color }]}
>
<Text style={styles.actionText}>{leftAction.label}</Text>
</View>
) : null}
{rightAction ? (
<View
style={[
styles.actionRight,
{ backgroundColor: rightAction.color },
]}
style={[styles.actionRight, { backgroundColor: rightAction.color }]}
>
<Text style={styles.actionText}>{rightAction.label}</Text>
</View>

View File

@@ -8,23 +8,29 @@ import {
export function useAlertDetail(projectId: string, alertId: string) {
return useQuery({
queryKey: ["alert", projectId, alertId],
queryFn: () => fetchAlertById(projectId, alertId),
enabled: !!projectId && !!alertId,
queryFn: () => {
return fetchAlertById(projectId, alertId);
},
enabled: Boolean(projectId) && Boolean(alertId),
});
}
export function useAlertStates(projectId: string) {
return useQuery({
queryKey: ["alert-states", projectId],
queryFn: () => fetchAlertStates(projectId),
enabled: !!projectId,
queryFn: () => {
return fetchAlertStates(projectId);
},
enabled: Boolean(projectId),
});
}
export function useAlertStateTimeline(projectId: string, alertId: string) {
return useQuery({
queryKey: ["alert-state-timeline", projectId, alertId],
queryFn: () => fetchAlertStateTimeline(projectId, alertId),
enabled: !!projectId && !!alertId,
queryFn: () => {
return fetchAlertStateTimeline(projectId, alertId);
},
enabled: Boolean(projectId) && Boolean(alertId),
});
}

View File

@@ -6,22 +6,23 @@ import {
fetchAlertEpisodeNotes,
} from "../api/alertEpisodes";
export function useAlertEpisodeDetail(
projectId: string,
episodeId: string,
) {
export function useAlertEpisodeDetail(projectId: string, episodeId: string) {
return useQuery({
queryKey: ["alert-episode", projectId, episodeId],
queryFn: () => fetchAlertEpisodeById(projectId, episodeId),
enabled: !!projectId && !!episodeId,
queryFn: () => {
return fetchAlertEpisodeById(projectId, episodeId);
},
enabled: Boolean(projectId) && Boolean(episodeId),
});
}
export function useAlertEpisodeStates(projectId: string) {
return useQuery({
queryKey: ["alert-states", projectId],
queryFn: () => fetchAlertEpisodeStates(projectId),
enabled: !!projectId,
queryFn: () => {
return fetchAlertEpisodeStates(projectId);
},
enabled: Boolean(projectId),
});
}
@@ -31,18 +32,19 @@ export function useAlertEpisodeStateTimeline(
) {
return useQuery({
queryKey: ["alert-episode-state-timeline", projectId, episodeId],
queryFn: () => fetchAlertEpisodeStateTimeline(projectId, episodeId),
enabled: !!projectId && !!episodeId,
queryFn: () => {
return fetchAlertEpisodeStateTimeline(projectId, episodeId);
},
enabled: Boolean(projectId) && Boolean(episodeId),
});
}
export function useAlertEpisodeNotes(
projectId: string,
episodeId: string,
) {
export function useAlertEpisodeNotes(projectId: string, episodeId: string) {
return useQuery({
queryKey: ["alert-episode-notes", projectId, episodeId],
queryFn: () => fetchAlertEpisodeNotes(projectId, episodeId),
enabled: !!projectId && !!episodeId,
queryFn: () => {
return fetchAlertEpisodeNotes(projectId, episodeId);
},
enabled: Boolean(projectId) && Boolean(episodeId),
});
}

View File

@@ -8,8 +8,10 @@ export function useAlertEpisodes(
) {
return useQuery({
queryKey: ["alert-episodes", projectId, skip, limit],
queryFn: () => fetchAlertEpisodes(projectId, { skip, limit }),
enabled: !!projectId,
queryFn: () => {
return fetchAlertEpisodes(projectId, { skip, limit });
},
enabled: Boolean(projectId),
});
}
@@ -24,6 +26,6 @@ export function useUnresolvedAlertEpisodeCount(projectId: string) {
});
return response.count;
},
enabled: !!projectId,
enabled: Boolean(projectId),
});
}

View File

@@ -4,7 +4,9 @@ import { fetchAlertNotes } from "../api/alertNotes";
export function useAlertNotes(projectId: string, alertId: string) {
return useQuery({
queryKey: ["alert-notes", projectId, alertId],
queryFn: () => fetchAlertNotes(projectId, alertId),
enabled: !!projectId && !!alertId,
queryFn: () => {
return fetchAlertNotes(projectId, alertId);
},
enabled: Boolean(projectId) && Boolean(alertId),
});
}

View File

@@ -8,8 +8,10 @@ export function useAlerts(
) {
return useQuery({
queryKey: ["alerts", projectId, skip, limit],
queryFn: () => fetchAlerts(projectId, { skip, limit }),
enabled: !!projectId,
queryFn: () => {
return fetchAlerts(projectId, { skip, limit });
},
enabled: Boolean(projectId),
});
}
@@ -24,6 +26,6 @@ export function useUnresolvedAlertCount(projectId: string) {
});
return response.count;
},
enabled: !!projectId,
enabled: Boolean(projectId),
});
}

View File

@@ -6,7 +6,7 @@ import React, {
useCallback,
ReactNode,
} from "react";
import { getTokens, clearTokens } from "../storage/keychain";
import { getTokens } from "../storage/keychain";
import { hasServerUrl } from "../storage/serverUrl";
import {
login as apiLogin,
@@ -33,7 +33,9 @@ interface AuthProviderProps {
children: ReactNode;
}
export function AuthProvider({ children }: AuthProviderProps): React.JSX.Element {
export function AuthProvider({
children,
}: AuthProviderProps): React.JSX.Element {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [needsServerUrl, setNeedsServerUrl] = useState(false);

View File

@@ -8,16 +8,20 @@ import {
export function useIncidentDetail(projectId: string, incidentId: string) {
return useQuery({
queryKey: ["incident", projectId, incidentId],
queryFn: () => fetchIncidentById(projectId, incidentId),
enabled: !!projectId && !!incidentId,
queryFn: () => {
return fetchIncidentById(projectId, incidentId);
},
enabled: Boolean(projectId) && Boolean(incidentId),
});
}
export function useIncidentStates(projectId: string) {
return useQuery({
queryKey: ["incident-states", projectId],
queryFn: () => fetchIncidentStates(projectId),
enabled: !!projectId,
queryFn: () => {
return fetchIncidentStates(projectId);
},
enabled: Boolean(projectId),
});
}
@@ -27,7 +31,9 @@ export function useIncidentStateTimeline(
) {
return useQuery({
queryKey: ["incident-state-timeline", projectId, incidentId],
queryFn: () => fetchIncidentStateTimeline(projectId, incidentId),
enabled: !!projectId && !!incidentId,
queryFn: () => {
return fetchIncidentStateTimeline(projectId, incidentId);
},
enabled: Boolean(projectId) && Boolean(incidentId),
});
}

View File

@@ -6,22 +6,23 @@ import {
fetchIncidentEpisodeNotes,
} from "../api/incidentEpisodes";
export function useIncidentEpisodeDetail(
projectId: string,
episodeId: string,
) {
export function useIncidentEpisodeDetail(projectId: string, episodeId: string) {
return useQuery({
queryKey: ["incident-episode", projectId, episodeId],
queryFn: () => fetchIncidentEpisodeById(projectId, episodeId),
enabled: !!projectId && !!episodeId,
queryFn: () => {
return fetchIncidentEpisodeById(projectId, episodeId);
},
enabled: Boolean(projectId) && Boolean(episodeId),
});
}
export function useIncidentEpisodeStates(projectId: string) {
return useQuery({
queryKey: ["incident-states", projectId],
queryFn: () => fetchIncidentEpisodeStates(projectId),
enabled: !!projectId,
queryFn: () => {
return fetchIncidentEpisodeStates(projectId);
},
enabled: Boolean(projectId),
});
}
@@ -31,18 +32,19 @@ export function useIncidentEpisodeStateTimeline(
) {
return useQuery({
queryKey: ["incident-episode-state-timeline", projectId, episodeId],
queryFn: () => fetchIncidentEpisodeStateTimeline(projectId, episodeId),
enabled: !!projectId && !!episodeId,
queryFn: () => {
return fetchIncidentEpisodeStateTimeline(projectId, episodeId);
},
enabled: Boolean(projectId) && Boolean(episodeId),
});
}
export function useIncidentEpisodeNotes(
projectId: string,
episodeId: string,
) {
export function useIncidentEpisodeNotes(projectId: string, episodeId: string) {
return useQuery({
queryKey: ["incident-episode-notes", projectId, episodeId],
queryFn: () => fetchIncidentEpisodeNotes(projectId, episodeId),
enabled: !!projectId && !!episodeId,
queryFn: () => {
return fetchIncidentEpisodeNotes(projectId, episodeId);
},
enabled: Boolean(projectId) && Boolean(episodeId),
});
}

View File

@@ -8,8 +8,10 @@ export function useIncidentEpisodes(
) {
return useQuery({
queryKey: ["incident-episodes", projectId, skip, limit],
queryFn: () => fetchIncidentEpisodes(projectId, { skip, limit }),
enabled: !!projectId,
queryFn: () => {
return fetchIncidentEpisodes(projectId, { skip, limit });
},
enabled: Boolean(projectId),
});
}
@@ -24,6 +26,6 @@ export function useUnresolvedIncidentEpisodeCount(projectId: string) {
});
return response.count;
},
enabled: !!projectId,
enabled: Boolean(projectId),
});
}

View File

@@ -4,7 +4,9 @@ import { fetchIncidentNotes } from "../api/incidentNotes";
export function useIncidentNotes(projectId: string, incidentId: string) {
return useQuery({
queryKey: ["incident-notes", projectId, incidentId],
queryFn: () => fetchIncidentNotes(projectId, incidentId),
enabled: !!projectId && !!incidentId,
queryFn: () => {
return fetchIncidentNotes(projectId, incidentId);
},
enabled: Boolean(projectId) && Boolean(incidentId),
});
}

View File

@@ -8,8 +8,10 @@ export function useIncidents(
) {
return useQuery({
queryKey: ["incidents", projectId, skip, limit],
queryFn: () => fetchIncidents(projectId, { skip, limit }),
enabled: !!projectId,
queryFn: () => {
return fetchIncidents(projectId, { skip, limit });
},
enabled: Boolean(projectId),
});
}
@@ -24,6 +26,6 @@ export function useUnresolvedIncidentCount(projectId: string) {
});
return response.count;
},
enabled: !!projectId,
enabled: Boolean(projectId),
});
}

View File

@@ -21,7 +21,9 @@ interface ProjectContextValue {
clearProject: () => Promise<void>;
}
const ProjectContext = createContext<ProjectContextValue | undefined>(undefined);
const ProjectContext = createContext<ProjectContextValue | undefined>(
undefined,
);
interface ProjectProviderProps {
children: ReactNode;
@@ -45,9 +47,9 @@ export function ProjectProvider({
// Try to restore previously selected project
const savedId = await AsyncStorage.getItem(PROJECT_STORAGE_KEY);
if (savedId) {
const saved = response.data.find(
(p: ProjectItem) => p._id === savedId,
);
const saved = response.data.find((p: ProjectItem) => {
return p._id === savedId;
});
if (saved) {
setSelectedProject(saved);
}

View File

@@ -17,9 +17,7 @@ import { useProject } from "./useProject";
const PUSH_TOKEN_KEY = "oneuptime_expo_push_token";
export function usePushNotifications(
navigationRef: unknown,
): void {
export function usePushNotifications(navigationRef: unknown): void {
const { isAuthenticated } = useAuth();
const { projectList } = useProject();
const responseListenerRef = useRef<Subscription | null>(null);
@@ -79,10 +77,11 @@ export function usePushNotifications(
// Set up notification listeners
useEffect(() => {
receivedListenerRef.current =
Notifications.addNotificationReceivedListener((_notification) => {
receivedListenerRef.current = Notifications.addNotificationReceivedListener(
(_notification) => {
// Foreground notification received — handler in setup.ts shows it
});
},
);
responseListenerRef.current =
Notifications.addNotificationResponseReceivedListener(

View File

@@ -9,7 +9,7 @@ import AlertEpisodesStackNavigator from "./AlertEpisodesStackNavigator";
import SettingsStackNavigator from "./SettingsStackNavigator";
import { useTheme } from "../theme";
const Tab = createBottomTabNavigator<MainTabParamList>();
const Tab: ReturnType<typeof createBottomTabNavigator<MainTabParamList>> = createBottomTabNavigator<MainTabParamList>();
export default function MainTabNavigator(): React.JSX.Element {
const { theme } = useTheme();

View File

@@ -119,7 +119,9 @@ export default function RootNavigator(): React.JSX.Element {
if (biometric.isEnabled && !biometricPassed) {
return (
<BiometricLockScreen
onSuccess={() => setBiometricPassed(true)}
onSuccess={() => {
return setBiometricPassed(true);
}}
biometricType={biometric.biometricType}
/>
);

View File

@@ -5,13 +5,15 @@ import { Platform } from "react-native";
// Show notifications when app is in foreground
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: true,
shouldShowBanner: true,
shouldShowList: true,
}),
handleNotification: async () => {
return {
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: true,
shouldShowBanner: true,
shouldShowList: true,
};
},
});
export async function setupNotificationChannels(): Promise<void> {
@@ -24,8 +26,7 @@ export async function setupNotificationChannels(): Promise<void> {
importance: Notifications.AndroidImportance.MAX,
sound: "default",
vibrationPattern: [0, 500, 250, 500],
lockscreenVisibility:
Notifications.AndroidNotificationVisibility.PUBLIC,
lockscreenVisibility: Notifications.AndroidNotificationVisibility.PUBLIC,
});
await Notifications.setNotificationChannelAsync("oncall_high", {
@@ -80,8 +81,7 @@ export async function requestPermissionsAndGetToken(): Promise<string | null> {
return null;
}
const { status: existingStatus } =
await Notifications.getPermissionsAsync();
const { status: existingStatus } = await Notifications.getPermissionsAsync();
let finalStatus = existingStatus;
if (existingStatus !== "granted") {

View File

@@ -23,21 +23,24 @@ import { createAlertNote } from "../api/alertNotes";
import { rgbToHex } from "../utils/color";
import { formatDateTime } from "../utils/date";
import type { AlertsStackParamList } from "../navigation/types";
import { useQueryClient } from "@tanstack/react-query";
import { QueryClient, useQueryClient } from "@tanstack/react-query";
import type {
AlertState,
StateTimelineItem,
NoteItem,
} from "../api/types";
import AddNoteModal from "../components/AddNoteModal";
import SkeletonCard from "../components/SkeletonCard";
import { useHaptics } from "../hooks/useHaptics";
type Props = NativeStackScreenProps<AlertsStackParamList, "AlertDetail">;
export default function AlertDetailScreen({
route,
}: Props): React.JSX.Element {
export default function AlertDetailScreen({ route }: Props): React.JSX.Element {
const { alertId } = route.params;
const { theme } = useTheme();
const { selectedProject } = useProject();
const projectId = selectedProject?._id ?? "";
const queryClient = useQueryClient();
const projectId: string = selectedProject?._id ?? "";
const queryClient: QueryClient = useQueryClient();
const {
data: alert,
@@ -45,32 +48,34 @@ export default function AlertDetailScreen({
refetch: refetchAlert,
} = useAlertDetail(projectId, alertId);
const { data: states } = useAlertStates(projectId);
const {
data: timeline,
refetch: refetchTimeline,
} = useAlertStateTimeline(projectId, alertId);
const {
data: notes,
refetch: refetchNotes,
} = useAlertNotes(projectId, alertId);
const { data: timeline, refetch: refetchTimeline } = useAlertStateTimeline(
projectId,
alertId,
);
const { data: notes, refetch: refetchNotes } = useAlertNotes(
projectId,
alertId,
);
const { successFeedback, errorFeedback } = useHaptics();
const [changingState, setChangingState] = useState(false);
const [noteModalVisible, setNoteModalVisible] = useState(false);
const [submittingNote, setSubmittingNote] = useState(false);
const onRefresh = useCallback(async () => {
const onRefresh: () => Promise<void> = useCallback(async () => {
await Promise.all([refetchAlert(), refetchTimeline(), refetchNotes()]);
}, [refetchAlert, refetchTimeline, refetchNotes]);
const handleStateChange = useCallback(
const handleStateChange: (stateId: string, stateName: string) => Promise<void> = useCallback(
async (stateId: string, stateName: string) => {
if (!alert) {
return;
}
const queryKey = ["alert", projectId, alertId];
const previousData = queryClient.getQueryData(queryKey);
const newState = states?.find((s) => s._id === stateId);
const queryKey: string[] = ["alert", projectId, alertId];
const previousData: unknown = queryClient.getQueryData(queryKey);
const newState: AlertState | undefined = states?.find((s: AlertState) => {
return s._id === stateId;
});
if (newState) {
queryClient.setQueryData(queryKey, {
...alert,
@@ -95,10 +100,18 @@ export default function AlertDetailScreen({
setChangingState(false);
}
},
[projectId, alertId, alert, states, refetchAlert, refetchTimeline, queryClient],
[
projectId,
alertId,
alert,
states,
refetchAlert,
refetchTimeline,
queryClient,
],
);
const handleAddNote = useCallback(
const handleAddNote: (noteText: string) => Promise<void> = useCallback(
async (noteText: string) => {
setSubmittingNote(true);
try {
@@ -117,9 +130,7 @@ export default function AlertDetailScreen({
if (isLoading) {
return (
<View
style={[
{ flex: 1, backgroundColor: theme.colors.backgroundPrimary },
]}
style={[{ flex: 1, backgroundColor: theme.colors.backgroundPrimary }]}
>
<SkeletonCard variant="detail" />
</View>
@@ -146,21 +157,25 @@ export default function AlertDetailScreen({
);
}
const stateColor = alert.currentAlertState?.color
const stateColor: string = alert.currentAlertState?.color
? rgbToHex(alert.currentAlertState.color)
: theme.colors.textTertiary;
const severityColor = alert.alertSeverity?.color
const severityColor: string = alert.alertSeverity?.color
? rgbToHex(alert.alertSeverity.color)
: theme.colors.textTertiary;
// Find acknowledge and resolve states from fetched state definitions
const acknowledgeState = states?.find((s) => s.isAcknowledgedState);
const resolveState = states?.find((s) => s.isResolvedState);
const acknowledgeState: AlertState | undefined = states?.find((s: AlertState) => {
return s.isAcknowledgedState;
});
const resolveState: AlertState | undefined = states?.find((s: AlertState) => {
return s.isResolvedState;
});
const currentStateId = alert.currentAlertState?._id;
const isResolved = resolveState?._id === currentStateId;
const isAcknowledged = acknowledgeState?._id === currentStateId;
const currentStateId: string | undefined = alert.currentAlertState?._id;
const isResolved: boolean = resolveState?._id === currentStateId;
const isAcknowledged: boolean = acknowledgeState?._id === currentStateId;
return (
<ScrollView
@@ -194,7 +209,9 @@ export default function AlertDetailScreen({
]}
>
<View style={[styles.dot, { backgroundColor: stateColor }]} />
<Text style={[styles.badgeText, { color: theme.colors.textPrimary }]}>
<Text
style={[styles.badgeText, { color: theme.colors.textPrimary }]}
>
{alert.currentAlertState.name}
</Text>
</View>
@@ -215,10 +232,7 @@ export default function AlertDetailScreen({
{alert.description ? (
<View style={styles.section}>
<Text
style={[
styles.sectionTitle,
{ color: theme.colors.textSecondary },
]}
style={[styles.sectionTitle, { color: theme.colors.textSecondary }]}
>
Description
</Text>
@@ -266,12 +280,18 @@ export default function AlertDetailScreen({
{alert.monitor ? (
<View style={styles.detailRow}>
<Text
style={[styles.detailLabel, { color: theme.colors.textTertiary }]}
style={[
styles.detailLabel,
{ color: theme.colors.textTertiary },
]}
>
Monitor
</Text>
<Text
style={[styles.detailValue, { color: theme.colors.textPrimary }]}
style={[
styles.detailValue,
{ color: theme.colors.textPrimary },
]}
>
{alert.monitor.name}
</Text>
@@ -284,10 +304,7 @@ export default function AlertDetailScreen({
{!isResolved ? (
<View style={styles.section}>
<Text
style={[
styles.sectionTitle,
{ color: theme.colors.textSecondary },
]}
style={[styles.sectionTitle, { color: theme.colors.textSecondary }]}
>
Actions
</Text>
@@ -298,18 +315,21 @@ export default function AlertDetailScreen({
styles.actionButton,
{ backgroundColor: theme.colors.stateAcknowledged },
]}
onPress={() =>
handleStateChange(
onPress={() => {
return handleStateChange(
acknowledgeState._id,
acknowledgeState.name,
)
}
);
}}
disabled={changingState}
accessibilityRole="button"
accessibilityLabel="Acknowledge alert"
>
{changingState ? (
<ActivityIndicator size="small" color={theme.colors.textInverse} />
<ActivityIndicator
size="small"
color={theme.colors.textInverse}
/>
) : (
<Text
style={[
@@ -329,15 +349,18 @@ export default function AlertDetailScreen({
styles.actionButton,
{ backgroundColor: theme.colors.stateResolved },
]}
onPress={() =>
handleStateChange(resolveState._id, resolveState.name)
}
onPress={() => {
return handleStateChange(resolveState._id, resolveState.name);
}}
disabled={changingState}
accessibilityRole="button"
accessibilityLabel="Resolve alert"
>
{changingState ? (
<ActivityIndicator size="small" color={theme.colors.textInverse} />
<ActivityIndicator
size="small"
color={theme.colors.textInverse}
/>
) : (
<Text
style={[
@@ -358,15 +381,12 @@ export default function AlertDetailScreen({
{timeline && timeline.length > 0 ? (
<View style={styles.section}>
<Text
style={[
styles.sectionTitle,
{ color: theme.colors.textSecondary },
]}
style={[styles.sectionTitle, { color: theme.colors.textSecondary }]}
>
State Timeline
</Text>
{timeline.map((entry) => {
const entryColor = entry.alertState?.color
{timeline.map((entry: StateTimelineItem) => {
const entryColor: string = entry.alertState?.color
? rgbToHex(entry.alertState.color)
: theme.colors.textTertiary;
return (
@@ -423,7 +443,9 @@ export default function AlertDetailScreen({
styles.addNoteButton,
{ backgroundColor: theme.colors.actionPrimary },
]}
onPress={() => setNoteModalVisible(true)}
onPress={() => {
return setNoteModalVisible(true);
}}
>
<Text
style={[
@@ -437,47 +459,49 @@ export default function AlertDetailScreen({
</View>
{notes && notes.length > 0
? notes.map((note) => (
<View
key={note._id}
style={[
styles.noteCard,
{
backgroundColor: theme.colors.backgroundSecondary,
borderColor: theme.colors.borderSubtle,
},
]}
>
<Text
? notes.map((note: NoteItem) => {
return (
<View
key={note._id}
style={[
theme.typography.bodyMedium,
{ color: theme.colors.textPrimary },
styles.noteCard,
{
backgroundColor: theme.colors.backgroundSecondary,
borderColor: theme.colors.borderSubtle,
},
]}
>
{note.note}
</Text>
<View style={styles.noteMeta}>
{note.createdByUser ? (
<Text
style={[
theme.typography.bodyMedium,
{ color: theme.colors.textPrimary },
]}
>
{note.note}
</Text>
<View style={styles.noteMeta}>
{note.createdByUser ? (
<Text
style={[
theme.typography.bodySmall,
{ color: theme.colors.textTertiary },
]}
>
{note.createdByUser.name}
</Text>
) : null}
<Text
style={[
theme.typography.bodySmall,
{ color: theme.colors.textTertiary },
]}
>
{note.createdByUser.name}
{formatDateTime(note.createdAt)}
</Text>
) : null}
<Text
style={[
theme.typography.bodySmall,
{ color: theme.colors.textTertiary },
]}
>
{formatDateTime(note.createdAt)}
</Text>
</View>
</View>
</View>
))
);
})
: null}
{notes && notes.length === 0 ? (
@@ -494,7 +518,9 @@ export default function AlertDetailScreen({
<AddNoteModal
visible={noteModalVisible}
onClose={() => setNoteModalVisible(false)}
onClose={() => {
return setNoteModalVisible(false);
}}
onSubmit={handleAddNote}
isSubmitting={submittingNote}
/>
@@ -502,7 +528,7 @@ export default function AlertDetailScreen({
);
}
const styles = StyleSheet.create({
const styles: ReturnType<typeof StyleSheet.create> = StyleSheet.create({
centered: {
flex: 1,
alignItems: "center",

View File

@@ -25,7 +25,12 @@ import {
import { rgbToHex } from "../utils/color";
import { formatDateTime } from "../utils/date";
import type { AlertEpisodesStackParamList } from "../navigation/types";
import { useQueryClient } from "@tanstack/react-query";
import { QueryClient, useQueryClient } from "@tanstack/react-query";
import type {
AlertState,
StateTimelineItem,
NoteItem,
} from "../api/types";
import AddNoteModal from "../components/AddNoteModal";
import SkeletonCard from "../components/SkeletonCard";
import { useHaptics } from "../hooks/useHaptics";
@@ -41,8 +46,8 @@ export default function AlertEpisodeDetailScreen({
const { episodeId } = route.params;
const { theme } = useTheme();
const { selectedProject } = useProject();
const projectId = selectedProject?._id ?? "";
const queryClient = useQueryClient();
const projectId: string = selectedProject?._id ?? "";
const queryClient: QueryClient = useQueryClient();
const {
data: episode,
@@ -50,32 +55,32 @@ export default function AlertEpisodeDetailScreen({
refetch: refetchEpisode,
} = useAlertEpisodeDetail(projectId, episodeId);
const { data: states } = useAlertEpisodeStates(projectId);
const {
data: timeline,
refetch: refetchTimeline,
} = useAlertEpisodeStateTimeline(projectId, episodeId);
const {
data: notes,
refetch: refetchNotes,
} = useAlertEpisodeNotes(projectId, episodeId);
const { data: timeline, refetch: refetchTimeline } =
useAlertEpisodeStateTimeline(projectId, episodeId);
const { data: notes, refetch: refetchNotes } = useAlertEpisodeNotes(
projectId,
episodeId,
);
const { successFeedback, errorFeedback } = useHaptics();
const [changingState, setChangingState] = useState(false);
const [noteModalVisible, setNoteModalVisible] = useState(false);
const [submittingNote, setSubmittingNote] = useState(false);
const onRefresh = useCallback(async () => {
const onRefresh: () => Promise<void> = useCallback(async () => {
await Promise.all([refetchEpisode(), refetchTimeline(), refetchNotes()]);
}, [refetchEpisode, refetchTimeline, refetchNotes]);
const handleStateChange = useCallback(
const handleStateChange: (stateId: string, stateName: string) => Promise<void> = useCallback(
async (stateId: string, stateName: string) => {
if (!episode) {
return;
}
const queryKey = ["alert-episode", projectId, episodeId];
const previousData = queryClient.getQueryData(queryKey);
const newState = states?.find((s) => s._id === stateId);
const queryKey: string[] = ["alert-episode", projectId, episodeId];
const previousData: unknown = queryClient.getQueryData(queryKey);
const newState: AlertState | undefined = states?.find((s: AlertState) => {
return s._id === stateId;
});
if (newState) {
queryClient.setQueryData(queryKey, {
...episode,
@@ -113,7 +118,7 @@ export default function AlertEpisodeDetailScreen({
],
);
const handleAddNote = useCallback(
const handleAddNote: (noteText: string) => Promise<void> = useCallback(
async (noteText: string) => {
setSubmittingNote(true);
try {
@@ -132,9 +137,7 @@ export default function AlertEpisodeDetailScreen({
if (isLoading) {
return (
<View
style={[
{ flex: 1, backgroundColor: theme.colors.backgroundPrimary },
]}
style={[{ flex: 1, backgroundColor: theme.colors.backgroundPrimary }]}
>
<SkeletonCard variant="detail" />
</View>
@@ -161,20 +164,24 @@ export default function AlertEpisodeDetailScreen({
);
}
const stateColor = episode.currentAlertState?.color
const stateColor: string = episode.currentAlertState?.color
? rgbToHex(episode.currentAlertState.color)
: theme.colors.textTertiary;
const severityColor = episode.alertSeverity?.color
const severityColor: string = episode.alertSeverity?.color
? rgbToHex(episode.alertSeverity.color)
: theme.colors.textTertiary;
const acknowledgeState = states?.find((s) => s.isAcknowledgedState);
const resolveState = states?.find((s) => s.isResolvedState);
const acknowledgeState: AlertState | undefined = states?.find((s: AlertState) => {
return s.isAcknowledgedState;
});
const resolveState: AlertState | undefined = states?.find((s: AlertState) => {
return s.isResolvedState;
});
const currentStateId = episode.currentAlertState?._id;
const isResolved = resolveState?._id === currentStateId;
const isAcknowledged = acknowledgeState?._id === currentStateId;
const currentStateId: string | undefined = episode.currentAlertState?._id;
const isResolved: boolean = resolveState?._id === currentStateId;
const isAcknowledged: boolean = acknowledgeState?._id === currentStateId;
return (
<ScrollView
@@ -231,10 +238,7 @@ export default function AlertEpisodeDetailScreen({
{episode.description ? (
<View style={styles.section}>
<Text
style={[
styles.sectionTitle,
{ color: theme.colors.textSecondary },
]}
style={[styles.sectionTitle, { color: theme.colors.textSecondary }]}
>
Description
</Text>
@@ -268,18 +272,12 @@ export default function AlertEpisodeDetailScreen({
>
<View style={styles.detailRow}>
<Text
style={[
styles.detailLabel,
{ color: theme.colors.textTertiary },
]}
style={[styles.detailLabel, { color: theme.colors.textTertiary }]}
>
Created
</Text>
<Text
style={[
styles.detailValue,
{ color: theme.colors.textPrimary },
]}
style={[styles.detailValue, { color: theme.colors.textPrimary }]}
>
{formatDateTime(episode.createdAt)}
</Text>
@@ -287,18 +285,12 @@ export default function AlertEpisodeDetailScreen({
<View style={styles.detailRow}>
<Text
style={[
styles.detailLabel,
{ color: theme.colors.textTertiary },
]}
style={[styles.detailLabel, { color: theme.colors.textTertiary }]}
>
Alerts
</Text>
<Text
style={[
styles.detailValue,
{ color: theme.colors.textPrimary },
]}
style={[styles.detailValue, { color: theme.colors.textPrimary }]}
>
{episode.alertCount ?? 0}
</Text>
@@ -310,10 +302,7 @@ export default function AlertEpisodeDetailScreen({
{!isResolved ? (
<View style={styles.section}>
<Text
style={[
styles.sectionTitle,
{ color: theme.colors.textSecondary },
]}
style={[styles.sectionTitle, { color: theme.colors.textSecondary }]}
>
Actions
</Text>
@@ -324,12 +313,12 @@ export default function AlertEpisodeDetailScreen({
styles.actionButton,
{ backgroundColor: theme.colors.stateAcknowledged },
]}
onPress={() =>
handleStateChange(
onPress={() => {
return handleStateChange(
acknowledgeState._id,
acknowledgeState.name,
)
}
);
}}
disabled={changingState}
>
{changingState ? (
@@ -356,9 +345,9 @@ export default function AlertEpisodeDetailScreen({
styles.actionButton,
{ backgroundColor: theme.colors.stateResolved },
]}
onPress={() =>
handleStateChange(resolveState._id, resolveState.name)
}
onPress={() => {
return handleStateChange(resolveState._id, resolveState.name);
}}
disabled={changingState}
>
{changingState ? (
@@ -386,15 +375,12 @@ export default function AlertEpisodeDetailScreen({
{timeline && timeline.length > 0 ? (
<View style={styles.section}>
<Text
style={[
styles.sectionTitle,
{ color: theme.colors.textSecondary },
]}
style={[styles.sectionTitle, { color: theme.colors.textSecondary }]}
>
State Timeline
</Text>
{timeline.map((entry) => {
const entryColor = entry.alertState?.color
{timeline.map((entry: StateTimelineItem) => {
const entryColor: string = entry.alertState?.color
? rgbToHex(entry.alertState.color)
: theme.colors.textTertiary;
return (
@@ -409,10 +395,7 @@ export default function AlertEpisodeDetailScreen({
]}
>
<View
style={[
styles.timelineDot,
{ backgroundColor: entryColor },
]}
style={[styles.timelineDot, { backgroundColor: entryColor }]}
/>
<View style={styles.timelineInfo}>
<Text
@@ -457,7 +440,9 @@ export default function AlertEpisodeDetailScreen({
styles.addNoteButton,
{ backgroundColor: theme.colors.actionPrimary },
]}
onPress={() => setNoteModalVisible(true)}
onPress={() => {
return setNoteModalVisible(true);
}}
>
<Text
style={[
@@ -471,47 +456,49 @@ export default function AlertEpisodeDetailScreen({
</View>
{notes && notes.length > 0
? notes.map((note) => (
<View
key={note._id}
style={[
styles.noteCard,
{
backgroundColor: theme.colors.backgroundSecondary,
borderColor: theme.colors.borderSubtle,
},
]}
>
<Text
? notes.map((note: NoteItem) => {
return (
<View
key={note._id}
style={[
theme.typography.bodyMedium,
{ color: theme.colors.textPrimary },
styles.noteCard,
{
backgroundColor: theme.colors.backgroundSecondary,
borderColor: theme.colors.borderSubtle,
},
]}
>
{note.note}
</Text>
<View style={styles.noteMeta}>
{note.createdByUser ? (
<Text
style={[
theme.typography.bodyMedium,
{ color: theme.colors.textPrimary },
]}
>
{note.note}
</Text>
<View style={styles.noteMeta}>
{note.createdByUser ? (
<Text
style={[
theme.typography.bodySmall,
{ color: theme.colors.textTertiary },
]}
>
{note.createdByUser.name}
</Text>
) : null}
<Text
style={[
theme.typography.bodySmall,
{ color: theme.colors.textTertiary },
]}
>
{note.createdByUser.name}
{formatDateTime(note.createdAt)}
</Text>
) : null}
<Text
style={[
theme.typography.bodySmall,
{ color: theme.colors.textTertiary },
]}
>
{formatDateTime(note.createdAt)}
</Text>
</View>
</View>
</View>
))
);
})
: null}
{notes && notes.length === 0 ? (
@@ -528,7 +515,9 @@ export default function AlertEpisodeDetailScreen({
<AddNoteModal
visible={noteModalVisible}
onClose={() => setNoteModalVisible(false)}
onClose={() => {
return setNoteModalVisible(false);
}}
onSubmit={handleAddNote}
isSubmitting={submittingNote}
/>
@@ -536,7 +525,7 @@ export default function AlertEpisodeDetailScreen({
);
}
const styles = StyleSheet.create({
const styles: ReturnType<typeof StyleSheet.create> = StyleSheet.create({
centered: {
flex: 1,
alignItems: "center",

View File

@@ -19,7 +19,7 @@ import EmptyState from "../components/EmptyState";
import type { AlertEpisodesStackParamList } from "../navigation/types";
import type { AlertEpisodeItem } from "../api/types";
const PAGE_SIZE = 20;
const PAGE_SIZE: number = 20;
type NavProp = NativeStackNavigationProp<
AlertEpisodesStackParamList,
@@ -29,12 +29,12 @@ type NavProp = NativeStackNavigationProp<
export default function AlertEpisodesScreen(): React.JSX.Element {
const { theme } = useTheme();
const { selectedProject } = useProject();
const projectId = selectedProject?._id ?? "";
const navigation = useNavigation<NavProp>();
const projectId: string = selectedProject?._id ?? "";
const navigation: NavProp = useNavigation<NavProp>();
const { lightImpact } = useHaptics();
const [page, setPage] = useState(0);
const skip = page * PAGE_SIZE;
const skip: number = page * PAGE_SIZE;
const { data, isLoading, isError, refetch } = useAlertEpisodes(
projectId,
@@ -42,23 +42,25 @@ export default function AlertEpisodesScreen(): React.JSX.Element {
PAGE_SIZE,
);
const episodes = data?.data ?? [];
const totalCount = data?.count ?? 0;
const hasMore = skip + PAGE_SIZE < totalCount;
const episodes: AlertEpisodeItem[] = data?.data ?? [];
const totalCount: number = data?.count ?? 0;
const hasMore: boolean = skip + PAGE_SIZE < totalCount;
const onRefresh = useCallback(async () => {
const onRefresh: () => Promise<void> = useCallback(async () => {
lightImpact();
setPage(0);
await refetch();
}, [refetch, lightImpact]);
const loadMore = useCallback(() => {
const loadMore: () => void = useCallback(() => {
if (hasMore && !isLoading) {
setPage((prev) => prev + 1);
setPage((prev: number) => {
return prev + 1;
});
}
}, [hasMore, isLoading]);
const handlePress = useCallback(
const handlePress: (episode: AlertEpisodeItem) => void = useCallback(
(episode: AlertEpisodeItem) => {
navigation.navigate("AlertEpisodeDetail", {
episodeId: episode._id,
@@ -105,7 +107,9 @@ export default function AlertEpisodesScreen(): React.JSX.Element {
styles.retryButton,
{ backgroundColor: theme.colors.actionPrimary },
]}
onPress={() => refetch()}
onPress={() => {
return refetch();
}}
>
<Text
style={[
@@ -129,17 +133,23 @@ export default function AlertEpisodesScreen(): React.JSX.Element {
>
<FlatList
data={episodes}
keyExtractor={(item) => item._id}
keyExtractor={(item: AlertEpisodeItem) => {
return item._id;
}}
contentContainerStyle={
episodes.length === 0 ? styles.emptyContainer : styles.list
}
renderItem={({ item }) => (
<EpisodeCard
episode={item}
type="alert"
onPress={() => handlePress(item)}
/>
)}
renderItem={({ item }: { item: AlertEpisodeItem }) => {
return (
<EpisodeCard
episode={item}
type="alert"
onPress={() => {
return handlePress(item);
}}
/>
);
}}
ListEmptyComponent={
<EmptyState
title="No alert episodes"
@@ -157,7 +167,7 @@ export default function AlertEpisodesScreen(): React.JSX.Element {
);
}
const styles = StyleSheet.create({
const styles: ReturnType<typeof StyleSheet.create> = StyleSheet.create({
container: {
flex: 1,
},

View File

@@ -20,21 +20,21 @@ import SwipeableCard from "../components/SwipeableCard";
import SkeletonCard from "../components/SkeletonCard";
import EmptyState from "../components/EmptyState";
import type { AlertsStackParamList } from "../navigation/types";
import type { AlertItem } from "../api/types";
import { useQueryClient } from "@tanstack/react-query";
import type { AlertItem, AlertState } from "../api/types";
import { QueryClient, useQueryClient } from "@tanstack/react-query";
const PAGE_SIZE = 20;
const PAGE_SIZE: number = 20;
type NavProp = NativeStackNavigationProp<AlertsStackParamList, "AlertsList">;
export default function AlertsScreen(): React.JSX.Element {
const { theme } = useTheme();
const { selectedProject } = useProject();
const projectId = selectedProject?._id ?? "";
const navigation = useNavigation<NavProp>();
const projectId: string = selectedProject?._id ?? "";
const navigation: NavProp = useNavigation<NavProp>();
const [page, setPage] = useState(0);
const skip = page * PAGE_SIZE;
const skip: number = page * PAGE_SIZE;
const { data, isLoading, isError, refetch } = useAlerts(
projectId,
@@ -43,34 +43,38 @@ export default function AlertsScreen(): React.JSX.Element {
);
const { data: states } = useAlertStates(projectId);
const { successFeedback, errorFeedback, lightImpact } = useHaptics();
const queryClient = useQueryClient();
const queryClient: QueryClient = useQueryClient();
const acknowledgeState = states?.find((s) => s.isAcknowledgedState);
const acknowledgeState: AlertState | undefined = states?.find((s: AlertState) => {
return s.isAcknowledgedState;
});
const alerts = data?.data ?? [];
const totalCount = data?.count ?? 0;
const hasMore = skip + PAGE_SIZE < totalCount;
const alerts: AlertItem[] = data?.data ?? [];
const totalCount: number = data?.count ?? 0;
const hasMore: boolean = skip + PAGE_SIZE < totalCount;
const onRefresh = useCallback(async () => {
const onRefresh: () => Promise<void> = useCallback(async () => {
lightImpact();
setPage(0);
await refetch();
}, [refetch, lightImpact]);
const loadMore = useCallback(() => {
const loadMore: () => void = useCallback(() => {
if (hasMore && !isLoading) {
setPage((prev) => prev + 1);
setPage((prev: number) => {
return prev + 1;
});
}
}, [hasMore, isLoading]);
const handlePress = useCallback(
const handlePress: (alert: AlertItem) => void = useCallback(
(alert: AlertItem) => {
navigation.navigate("AlertDetail", { alertId: alert._id });
},
[navigation],
);
const handleAcknowledge = useCallback(
const handleAcknowledge: (alert: AlertItem) => Promise<void> = useCallback(
async (alert: AlertItem) => {
if (!acknowledgeState) {
return;
@@ -84,7 +88,14 @@ export default function AlertsScreen(): React.JSX.Element {
await errorFeedback();
}
},
[projectId, acknowledgeState, successFeedback, errorFeedback, refetch, queryClient],
[
projectId,
acknowledgeState,
successFeedback,
errorFeedback,
refetch,
queryClient,
],
);
if (isLoading && alerts.length === 0) {
@@ -125,7 +136,9 @@ export default function AlertsScreen(): React.JSX.Element {
styles.retryButton,
{ backgroundColor: theme.colors.actionPrimary },
]}
onPress={() => refetch()}
onPress={() => {
return refetch();
}}
>
<Text
style={[
@@ -149,26 +162,37 @@ export default function AlertsScreen(): React.JSX.Element {
>
<FlatList
data={alerts}
keyExtractor={(item) => item._id}
keyExtractor={(item: AlertItem) => {
return item._id;
}}
contentContainerStyle={
alerts.length === 0 ? styles.emptyContainer : styles.list
}
renderItem={({ item }) => (
<SwipeableCard
rightAction={
acknowledgeState &&
item.currentAlertState?._id !== acknowledgeState._id
? {
label: "Acknowledge",
color: "#2EA043",
onAction: () => handleAcknowledge(item),
}
: undefined
}
>
<AlertCard alert={item} onPress={() => handlePress(item)} />
</SwipeableCard>
)}
renderItem={({ item }: { item: AlertItem }) => {
return (
<SwipeableCard
rightAction={
acknowledgeState &&
item.currentAlertState?._id !== acknowledgeState._id
? {
label: "Acknowledge",
color: "#2EA043",
onAction: () => {
return handleAcknowledge(item);
},
}
: undefined
}
>
<AlertCard
alert={item}
onPress={() => {
return handlePress(item);
}}
/>
</SwipeableCard>
);
}}
ListEmptyComponent={
<EmptyState
title="No active alerts"
@@ -186,7 +210,7 @@ export default function AlertsScreen(): React.JSX.Element {
);
}
const styles = StyleSheet.create({
const styles: ReturnType<typeof StyleSheet.create> = StyleSheet.create({
container: {
flex: 1,
},

View File

@@ -45,7 +45,10 @@ export default function BiometricLockScreen({
]}
>
<View
style={[styles.lockBody, { backgroundColor: theme.colors.textTertiary }]}
style={[
styles.lockBody,
{ backgroundColor: theme.colors.textTertiary },
]}
/>
<View
style={[
@@ -58,7 +61,11 @@ export default function BiometricLockScreen({
<Text
style={[
theme.typography.titleMedium,
{ color: theme.colors.textPrimary, marginTop: 24, textAlign: "center" },
{
color: theme.colors.textPrimary,
marginTop: 24,
textAlign: "center",
},
]}
>
OneUptime is Locked

View File

@@ -58,7 +58,7 @@ function StatCard({
accessibilityRole="button"
>
<Text style={[styles.cardCount, { color }]}>
{isLoading ? "--" : (count ?? 0)}
{isLoading ? "--" : count ?? 0}
</Text>
<Text style={[styles.cardLabel, { color: theme.colors.textSecondary }]}>
{label}
@@ -88,12 +88,7 @@ function QuickLink({ label, onPress }: QuickLinkProps): React.JSX.Element {
activeOpacity={0.7}
accessibilityRole="button"
>
<Text
style={[
styles.linkLabel,
{ color: theme.colors.textPrimary },
]}
>
<Text style={[styles.linkLabel, { color: theme.colors.textPrimary }]}>
{label}
</Text>
<Text style={[styles.chevron, { color: theme.colors.textTertiary }]}>
@@ -183,14 +178,18 @@ export default function HomeScreen(): React.JSX.Element {
label="Active Incidents"
color={theme.colors.severityCritical}
isLoading={loadingIncidents}
onPress={() => navigation.navigate("Incidents")}
onPress={() => {
return navigation.navigate("Incidents");
}}
/>
<StatCard
count={alertCount}
label="Active Alerts"
color={theme.colors.severityMajor}
isLoading={loadingAlerts}
onPress={() => navigation.navigate("Alerts")}
onPress={() => {
return navigation.navigate("Alerts");
}}
/>
</View>
@@ -200,42 +199,51 @@ export default function HomeScreen(): React.JSX.Element {
label="Inc Episodes"
color={theme.colors.severityCritical}
isLoading={loadingIncidentEpisodes}
onPress={() => navigation.navigate("IncidentEpisodes")}
onPress={() => {
return navigation.navigate("IncidentEpisodes");
}}
/>
<StatCard
count={alertEpisodeCount}
label="Alert Episodes"
color={theme.colors.severityMajor}
isLoading={loadingAlertEpisodes}
onPress={() => navigation.navigate("AlertEpisodes")}
onPress={() => {
return navigation.navigate("AlertEpisodes");
}}
/>
</View>
{/* Quick Links */}
<View style={styles.quickLinksSection}>
<Text
style={[
styles.sectionTitle,
{ color: theme.colors.textSecondary },
]}
style={[styles.sectionTitle, { color: theme.colors.textSecondary }]}
>
Quick Links
</Text>
<QuickLink
label="View All Incidents"
onPress={() => navigation.navigate("Incidents")}
onPress={() => {
return navigation.navigate("Incidents");
}}
/>
<QuickLink
label="View All Alerts"
onPress={() => navigation.navigate("Alerts")}
onPress={() => {
return navigation.navigate("Alerts");
}}
/>
<QuickLink
label="Incident Episodes"
onPress={() => navigation.navigate("IncidentEpisodes")}
onPress={() => {
return navigation.navigate("IncidentEpisodes");
}}
/>
<QuickLink
label="Alert Episodes"
onPress={() => navigation.navigate("AlertEpisodes")}
onPress={() => {
return navigation.navigate("AlertEpisodes");
}}
/>
</View>
</ScrollView>

View File

@@ -10,7 +10,7 @@ import {
StyleSheet,
} from "react-native";
import type { NativeStackScreenProps } from "@react-navigation/native-stack";
import { useTheme } from "../theme";
import { useTheme, type Theme } from "../theme";
import { useProject } from "../hooks/useProject";
import {
useIncidentDetail,
@@ -23,10 +23,17 @@ import { createIncidentNote } from "../api/incidentNotes";
import { rgbToHex } from "../utils/color";
import { formatDateTime } from "../utils/date";
import type { IncidentsStackParamList } from "../navigation/types";
import { useQueryClient } from "@tanstack/react-query";
import { QueryClient, useQueryClient } from "@tanstack/react-query";
import AddNoteModal from "../components/AddNoteModal";
import SkeletonCard from "../components/SkeletonCard";
import { useHaptics } from "../hooks/useHaptics";
import type {
IncidentItem,
IncidentState,
StateTimelineItem,
NoteItem,
NamedEntity,
} from "../api/types";
type Props = NativeStackScreenProps<IncidentsStackParamList, "IncidentDetail">;
@@ -34,10 +41,10 @@ export default function IncidentDetailScreen({
route,
}: Props): React.JSX.Element {
const { incidentId } = route.params;
const { theme } = useTheme();
const { theme }: { theme: Theme } = useTheme();
const { selectedProject } = useProject();
const projectId = selectedProject?._id ?? "";
const queryClient = useQueryClient();
const projectId: string = selectedProject?._id ?? "";
const queryClient: QueryClient = useQueryClient();
const {
data: incident,
@@ -45,32 +52,40 @@ export default function IncidentDetailScreen({
refetch: refetchIncident,
} = useIncidentDetail(projectId, incidentId);
const { data: states } = useIncidentStates(projectId);
const {
data: timeline,
refetch: refetchTimeline,
} = useIncidentStateTimeline(projectId, incidentId);
const {
data: notes,
refetch: refetchNotes,
} = useIncidentNotes(projectId, incidentId);
const { data: timeline, refetch: refetchTimeline } = useIncidentStateTimeline(
projectId,
incidentId,
);
const { data: notes, refetch: refetchNotes } = useIncidentNotes(
projectId,
incidentId,
);
const { successFeedback, errorFeedback } = useHaptics();
const [changingState, setChangingState] = useState(false);
const [noteModalVisible, setNoteModalVisible] = useState(false);
const [submittingNote, setSubmittingNote] = useState(false);
const onRefresh = useCallback(async () => {
const onRefresh: () => Promise<void> = useCallback(async () => {
await Promise.all([refetchIncident(), refetchTimeline(), refetchNotes()]);
}, [refetchIncident, refetchTimeline, refetchNotes]);
const handleStateChange = useCallback(
const handleStateChange: (
stateId: string,
stateName: string,
) => Promise<void> = useCallback(
async (stateId: string, stateName: string) => {
if (!incident) {
return;
}
const queryKey = ["incident", projectId, incidentId];
const previousData = queryClient.getQueryData(queryKey);
const newState = states?.find((s) => s._id === stateId);
const queryKey: string[] = ["incident", projectId, incidentId];
const previousData: IncidentItem | undefined =
queryClient.getQueryData(queryKey);
const newState: IncidentState | undefined = states?.find(
(s: IncidentState) => {
return s._id === stateId;
},
);
if (newState) {
queryClient.setQueryData(queryKey, {
...incident,
@@ -95,10 +110,18 @@ export default function IncidentDetailScreen({
setChangingState(false);
}
},
[projectId, incidentId, incident, states, refetchIncident, refetchTimeline, queryClient],
[
projectId,
incidentId,
incident,
states,
refetchIncident,
refetchTimeline,
queryClient,
],
);
const handleAddNote = useCallback(
const handleAddNote: (noteText: string) => Promise<void> = useCallback(
async (noteText: string) => {
setSubmittingNote(true);
try {
@@ -117,9 +140,7 @@ export default function IncidentDetailScreen({
if (isLoading) {
return (
<View
style={[
{ flex: 1, backgroundColor: theme.colors.backgroundPrimary },
]}
style={[{ flex: 1, backgroundColor: theme.colors.backgroundPrimary }]}
>
<SkeletonCard variant="detail" />
</View>
@@ -146,21 +167,29 @@ export default function IncidentDetailScreen({
);
}
const stateColor = incident.currentIncidentState?.color
const stateColor: string = incident.currentIncidentState?.color
? rgbToHex(incident.currentIncidentState.color)
: theme.colors.textTertiary;
const severityColor = incident.incidentSeverity?.color
const severityColor: string = incident.incidentSeverity?.color
? rgbToHex(incident.incidentSeverity.color)
: theme.colors.textTertiary;
// Find acknowledge and resolve states from fetched state definitions
const acknowledgeState = states?.find((s) => s.isAcknowledgedState);
const resolveState = states?.find((s) => s.isResolvedState);
const acknowledgeState: IncidentState | undefined = states?.find(
(s: IncidentState) => {
return s.isAcknowledgedState;
},
);
const resolveState: IncidentState | undefined = states?.find(
(s: IncidentState) => {
return s.isResolvedState;
},
);
const currentStateId = incident.currentIncidentState?._id;
const isResolved = resolveState?._id === currentStateId;
const isAcknowledged = acknowledgeState?._id === currentStateId;
const currentStateId: string | undefined = incident.currentIncidentState?._id;
const isResolved: boolean = resolveState?._id === currentStateId;
const isAcknowledged: boolean = acknowledgeState?._id === currentStateId;
return (
<ScrollView
@@ -171,12 +200,7 @@ export default function IncidentDetailScreen({
}
>
{/* Header */}
<Text
style={[
styles.number,
{ color: theme.colors.textTertiary },
]}
>
<Text style={[styles.number, { color: theme.colors.textTertiary }]}>
{incident.incidentNumberWithPrefix || `#${incident.incidentNumber}`}
</Text>
@@ -199,7 +223,9 @@ export default function IncidentDetailScreen({
]}
>
<View style={[styles.dot, { backgroundColor: stateColor }]} />
<Text style={[styles.badgeText, { color: theme.colors.textPrimary }]}>
<Text
style={[styles.badgeText, { color: theme.colors.textPrimary }]}
>
{incident.currentIncidentState.name}
</Text>
</View>
@@ -220,10 +246,7 @@ export default function IncidentDetailScreen({
{incident.description ? (
<View style={styles.section}>
<Text
style={[
styles.sectionTitle,
{ color: theme.colors.textSecondary },
]}
style={[styles.sectionTitle, { color: theme.colors.textSecondary }]}
>
Description
</Text>
@@ -258,7 +281,10 @@ export default function IncidentDetailScreen({
{incident.declaredAt ? (
<View style={styles.detailRow}>
<Text
style={[styles.detailLabel, { color: theme.colors.textTertiary }]}
style={[
styles.detailLabel,
{ color: theme.colors.textTertiary },
]}
>
Declared
</Text>
@@ -289,7 +315,10 @@ export default function IncidentDetailScreen({
{incident.monitors?.length > 0 ? (
<View style={styles.detailRow}>
<Text
style={[styles.detailLabel, { color: theme.colors.textTertiary }]}
style={[
styles.detailLabel,
{ color: theme.colors.textTertiary },
]}
>
Monitors
</Text>
@@ -299,7 +328,11 @@ export default function IncidentDetailScreen({
{ color: theme.colors.textPrimary, flex: 1 },
]}
>
{incident.monitors.map((m) => m.name).join(", ")}
{incident.monitors
.map((m: NamedEntity) => {
return m.name;
})
.join(", ")}
</Text>
</View>
) : null}
@@ -310,10 +343,7 @@ export default function IncidentDetailScreen({
{!isResolved ? (
<View style={styles.section}>
<Text
style={[
styles.sectionTitle,
{ color: theme.colors.textSecondary },
]}
style={[styles.sectionTitle, { color: theme.colors.textSecondary }]}
>
Actions
</Text>
@@ -324,18 +354,21 @@ export default function IncidentDetailScreen({
styles.actionButton,
{ backgroundColor: theme.colors.stateAcknowledged },
]}
onPress={() =>
handleStateChange(
onPress={() => {
return handleStateChange(
acknowledgeState._id,
acknowledgeState.name,
)
}
);
}}
disabled={changingState}
accessibilityRole="button"
accessibilityLabel="Acknowledge incident"
>
{changingState ? (
<ActivityIndicator size="small" color={theme.colors.textInverse} />
<ActivityIndicator
size="small"
color={theme.colors.textInverse}
/>
) : (
<Text
style={[
@@ -355,15 +388,18 @@ export default function IncidentDetailScreen({
styles.actionButton,
{ backgroundColor: theme.colors.stateResolved },
]}
onPress={() =>
handleStateChange(resolveState._id, resolveState.name)
}
onPress={() => {
return handleStateChange(resolveState._id, resolveState.name);
}}
disabled={changingState}
accessibilityRole="button"
accessibilityLabel="Resolve incident"
>
{changingState ? (
<ActivityIndicator size="small" color={theme.colors.textInverse} />
<ActivityIndicator
size="small"
color={theme.colors.textInverse}
/>
) : (
<Text
style={[
@@ -384,15 +420,12 @@ export default function IncidentDetailScreen({
{timeline && timeline.length > 0 ? (
<View style={styles.section}>
<Text
style={[
styles.sectionTitle,
{ color: theme.colors.textSecondary },
]}
style={[styles.sectionTitle, { color: theme.colors.textSecondary }]}
>
State Timeline
</Text>
{timeline.map((entry) => {
const entryColor = entry.incidentState?.color
{timeline.map((entry: StateTimelineItem) => {
const entryColor: string = entry.incidentState?.color
? rgbToHex(entry.incidentState.color)
: theme.colors.textTertiary;
return (
@@ -449,7 +482,9 @@ export default function IncidentDetailScreen({
styles.addNoteButton,
{ backgroundColor: theme.colors.actionPrimary },
]}
onPress={() => setNoteModalVisible(true)}
onPress={() => {
return setNoteModalVisible(true);
}}
>
<Text
style={[
@@ -463,47 +498,49 @@ export default function IncidentDetailScreen({
</View>
{notes && notes.length > 0
? notes.map((note) => (
<View
key={note._id}
style={[
styles.noteCard,
{
backgroundColor: theme.colors.backgroundSecondary,
borderColor: theme.colors.borderSubtle,
},
]}
>
<Text
? notes.map((note: NoteItem) => {
return (
<View
key={note._id}
style={[
theme.typography.bodyMedium,
{ color: theme.colors.textPrimary },
styles.noteCard,
{
backgroundColor: theme.colors.backgroundSecondary,
borderColor: theme.colors.borderSubtle,
},
]}
>
{note.note}
</Text>
<View style={styles.noteMeta}>
{note.createdByUser ? (
<Text
style={[
theme.typography.bodyMedium,
{ color: theme.colors.textPrimary },
]}
>
{note.note}
</Text>
<View style={styles.noteMeta}>
{note.createdByUser ? (
<Text
style={[
theme.typography.bodySmall,
{ color: theme.colors.textTertiary },
]}
>
{note.createdByUser.name}
</Text>
) : null}
<Text
style={[
theme.typography.bodySmall,
{ color: theme.colors.textTertiary },
]}
>
{note.createdByUser.name}
{formatDateTime(note.createdAt)}
</Text>
) : null}
<Text
style={[
theme.typography.bodySmall,
{ color: theme.colors.textTertiary },
]}
>
{formatDateTime(note.createdAt)}
</Text>
</View>
</View>
</View>
))
);
})
: null}
{notes && notes.length === 0 ? (
@@ -520,7 +557,9 @@ export default function IncidentDetailScreen({
<AddNoteModal
visible={noteModalVisible}
onClose={() => setNoteModalVisible(false)}
onClose={() => {
return setNoteModalVisible(false);
}}
onSubmit={handleAddNote}
isSubmitting={submittingNote}
/>
@@ -528,7 +567,7 @@ export default function IncidentDetailScreen({
);
}
const styles = StyleSheet.create({
const styles: ReturnType<typeof StyleSheet.create> = StyleSheet.create({
centered: {
flex: 1,
alignItems: "center",

View File

@@ -50,14 +50,12 @@ export default function IncidentEpisodeDetailScreen({
refetch: refetchEpisode,
} = useIncidentEpisodeDetail(projectId, episodeId);
const { data: states } = useIncidentEpisodeStates(projectId);
const {
data: timeline,
refetch: refetchTimeline,
} = useIncidentEpisodeStateTimeline(projectId, episodeId);
const {
data: notes,
refetch: refetchNotes,
} = useIncidentEpisodeNotes(projectId, episodeId);
const { data: timeline, refetch: refetchTimeline } =
useIncidentEpisodeStateTimeline(projectId, episodeId);
const { data: notes, refetch: refetchNotes } = useIncidentEpisodeNotes(
projectId,
episodeId,
);
const { successFeedback, errorFeedback } = useHaptics();
const [changingState, setChangingState] = useState(false);
@@ -75,7 +73,9 @@ export default function IncidentEpisodeDetailScreen({
}
const queryKey = ["incident-episode", projectId, episodeId];
const previousData = queryClient.getQueryData(queryKey);
const newState = states?.find((s) => s._id === stateId);
const newState = states?.find((s) => {
return s._id === stateId;
});
if (newState) {
queryClient.setQueryData(queryKey, {
...episode,
@@ -132,9 +132,7 @@ export default function IncidentEpisodeDetailScreen({
if (isLoading) {
return (
<View
style={[
{ flex: 1, backgroundColor: theme.colors.backgroundPrimary },
]}
style={[{ flex: 1, backgroundColor: theme.colors.backgroundPrimary }]}
>
<SkeletonCard variant="detail" />
</View>
@@ -169,8 +167,12 @@ export default function IncidentEpisodeDetailScreen({
? rgbToHex(episode.incidentSeverity.color)
: theme.colors.textTertiary;
const acknowledgeState = states?.find((s) => s.isAcknowledgedState);
const resolveState = states?.find((s) => s.isResolvedState);
const acknowledgeState = states?.find((s) => {
return s.isAcknowledgedState;
});
const resolveState = states?.find((s) => {
return s.isResolvedState;
});
const currentStateId = episode.currentIncidentState?._id;
const isResolved = resolveState?._id === currentStateId;
@@ -231,10 +233,7 @@ export default function IncidentEpisodeDetailScreen({
{episode.description ? (
<View style={styles.section}>
<Text
style={[
styles.sectionTitle,
{ color: theme.colors.textSecondary },
]}
style={[styles.sectionTitle, { color: theme.colors.textSecondary }]}
>
Description
</Text>
@@ -289,18 +288,12 @@ export default function IncidentEpisodeDetailScreen({
<View style={styles.detailRow}>
<Text
style={[
styles.detailLabel,
{ color: theme.colors.textTertiary },
]}
style={[styles.detailLabel, { color: theme.colors.textTertiary }]}
>
Created
</Text>
<Text
style={[
styles.detailValue,
{ color: theme.colors.textPrimary },
]}
style={[styles.detailValue, { color: theme.colors.textPrimary }]}
>
{formatDateTime(episode.createdAt)}
</Text>
@@ -308,18 +301,12 @@ export default function IncidentEpisodeDetailScreen({
<View style={styles.detailRow}>
<Text
style={[
styles.detailLabel,
{ color: theme.colors.textTertiary },
]}
style={[styles.detailLabel, { color: theme.colors.textTertiary }]}
>
Incidents
</Text>
<Text
style={[
styles.detailValue,
{ color: theme.colors.textPrimary },
]}
style={[styles.detailValue, { color: theme.colors.textPrimary }]}
>
{episode.incidentCount ?? 0}
</Text>
@@ -331,10 +318,7 @@ export default function IncidentEpisodeDetailScreen({
{!isResolved ? (
<View style={styles.section}>
<Text
style={[
styles.sectionTitle,
{ color: theme.colors.textSecondary },
]}
style={[styles.sectionTitle, { color: theme.colors.textSecondary }]}
>
Actions
</Text>
@@ -345,12 +329,12 @@ export default function IncidentEpisodeDetailScreen({
styles.actionButton,
{ backgroundColor: theme.colors.stateAcknowledged },
]}
onPress={() =>
handleStateChange(
onPress={() => {
return handleStateChange(
acknowledgeState._id,
acknowledgeState.name,
)
}
);
}}
disabled={changingState}
>
{changingState ? (
@@ -377,9 +361,9 @@ export default function IncidentEpisodeDetailScreen({
styles.actionButton,
{ backgroundColor: theme.colors.stateResolved },
]}
onPress={() =>
handleStateChange(resolveState._id, resolveState.name)
}
onPress={() => {
return handleStateChange(resolveState._id, resolveState.name);
}}
disabled={changingState}
>
{changingState ? (
@@ -407,10 +391,7 @@ export default function IncidentEpisodeDetailScreen({
{timeline && timeline.length > 0 ? (
<View style={styles.section}>
<Text
style={[
styles.sectionTitle,
{ color: theme.colors.textSecondary },
]}
style={[styles.sectionTitle, { color: theme.colors.textSecondary }]}
>
State Timeline
</Text>
@@ -430,10 +411,7 @@ export default function IncidentEpisodeDetailScreen({
]}
>
<View
style={[
styles.timelineDot,
{ backgroundColor: entryColor },
]}
style={[styles.timelineDot, { backgroundColor: entryColor }]}
/>
<View style={styles.timelineInfo}>
<Text
@@ -478,7 +456,9 @@ export default function IncidentEpisodeDetailScreen({
styles.addNoteButton,
{ backgroundColor: theme.colors.actionPrimary },
]}
onPress={() => setNoteModalVisible(true)}
onPress={() => {
return setNoteModalVisible(true);
}}
>
<Text
style={[
@@ -492,47 +472,49 @@ export default function IncidentEpisodeDetailScreen({
</View>
{notes && notes.length > 0
? notes.map((note) => (
<View
key={note._id}
style={[
styles.noteCard,
{
backgroundColor: theme.colors.backgroundSecondary,
borderColor: theme.colors.borderSubtle,
},
]}
>
<Text
? notes.map((note) => {
return (
<View
key={note._id}
style={[
theme.typography.bodyMedium,
{ color: theme.colors.textPrimary },
styles.noteCard,
{
backgroundColor: theme.colors.backgroundSecondary,
borderColor: theme.colors.borderSubtle,
},
]}
>
{note.note}
</Text>
<View style={styles.noteMeta}>
{note.createdByUser ? (
<Text
style={[
theme.typography.bodyMedium,
{ color: theme.colors.textPrimary },
]}
>
{note.note}
</Text>
<View style={styles.noteMeta}>
{note.createdByUser ? (
<Text
style={[
theme.typography.bodySmall,
{ color: theme.colors.textTertiary },
]}
>
{note.createdByUser.name}
</Text>
) : null}
<Text
style={[
theme.typography.bodySmall,
{ color: theme.colors.textTertiary },
]}
>
{note.createdByUser.name}
{formatDateTime(note.createdAt)}
</Text>
) : null}
<Text
style={[
theme.typography.bodySmall,
{ color: theme.colors.textTertiary },
]}
>
{formatDateTime(note.createdAt)}
</Text>
</View>
</View>
</View>
))
);
})
: null}
{notes && notes.length === 0 ? (
@@ -549,7 +531,9 @@ export default function IncidentEpisodeDetailScreen({
<AddNoteModal
visible={noteModalVisible}
onClose={() => setNoteModalVisible(false)}
onClose={() => {
return setNoteModalVisible(false);
}}
onSubmit={handleAddNote}
isSubmitting={submittingNote}
/>

View File

@@ -54,7 +54,9 @@ export default function IncidentEpisodesScreen(): React.JSX.Element {
const loadMore = useCallback(() => {
if (hasMore && !isLoading) {
setPage((prev) => prev + 1);
setPage((prev) => {
return prev + 1;
});
}
}, [hasMore, isLoading]);
@@ -105,7 +107,9 @@ export default function IncidentEpisodesScreen(): React.JSX.Element {
styles.retryButton,
{ backgroundColor: theme.colors.actionPrimary },
]}
onPress={() => refetch()}
onPress={() => {
return refetch();
}}
>
<Text
style={[
@@ -129,17 +133,23 @@ export default function IncidentEpisodesScreen(): React.JSX.Element {
>
<FlatList
data={episodes}
keyExtractor={(item) => item._id}
keyExtractor={(item) => {
return item._id;
}}
contentContainerStyle={
episodes.length === 0 ? styles.emptyContainer : styles.list
}
renderItem={({ item }) => (
<EpisodeCard
episode={item}
type="incident"
onPress={() => handlePress(item)}
/>
)}
renderItem={({ item }) => {
return (
<EpisodeCard
episode={item}
type="incident"
onPress={() => {
return handlePress(item);
}}
/>
);
}}
ListEmptyComponent={
<EmptyState
title="No incident episodes"

View File

@@ -9,7 +9,7 @@ import {
} from "react-native";
import { useNavigation } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { useTheme } from "../theme";
import { useTheme, type Theme } from "../theme";
import { useProject } from "../hooks/useProject";
import { useIncidents } from "../hooks/useIncidents";
import { useIncidentStates } from "../hooks/useIncidentDetail";
@@ -20,21 +20,27 @@ import SwipeableCard from "../components/SwipeableCard";
import SkeletonCard from "../components/SkeletonCard";
import EmptyState from "../components/EmptyState";
import type { IncidentsStackParamList } from "../navigation/types";
import type { IncidentItem } from "../api/types";
import { useQueryClient } from "@tanstack/react-query";
import type { IncidentItem, IncidentState } from "../api/types";
import { QueryClient, useQueryClient } from "@tanstack/react-query";
const PAGE_SIZE = 20;
const PAGE_SIZE: number = 20;
type NavProp = NativeStackNavigationProp<IncidentsStackParamList, "IncidentsList">;
type NavProp = NativeStackNavigationProp<
IncidentsStackParamList,
"IncidentsList"
>;
export default function IncidentsScreen(): React.JSX.Element {
const { theme } = useTheme();
const { theme }: { theme: Theme } = useTheme();
const { selectedProject } = useProject();
const projectId = selectedProject?._id ?? "";
const navigation = useNavigation<NavProp>();
const projectId: string = selectedProject?._id ?? "";
const navigation: NativeStackNavigationProp<
IncidentsStackParamList,
"IncidentsList"
> = useNavigation<NavProp>();
const [page, setPage] = useState(0);
const skip = page * PAGE_SIZE;
const skip: number = page * PAGE_SIZE;
const { data, isLoading, isError, refetch } = useIncidents(
projectId,
@@ -43,49 +49,67 @@ export default function IncidentsScreen(): React.JSX.Element {
);
const { data: states } = useIncidentStates(projectId);
const { successFeedback, errorFeedback, lightImpact } = useHaptics();
const queryClient = useQueryClient();
const queryClient: QueryClient = useQueryClient();
const acknowledgeState = states?.find((s) => s.isAcknowledgedState);
const acknowledgeState: IncidentState | undefined = states?.find(
(s: IncidentState) => {
return s.isAcknowledgedState;
},
);
const incidents = data?.data ?? [];
const totalCount = data?.count ?? 0;
const hasMore = skip + PAGE_SIZE < totalCount;
const incidents: IncidentItem[] = data?.data ?? [];
const totalCount: number = data?.count ?? 0;
const hasMore: boolean = skip + PAGE_SIZE < totalCount;
const onRefresh = useCallback(async () => {
const onRefresh: () => Promise<void> = useCallback(async () => {
lightImpact();
setPage(0);
await refetch();
}, [refetch, lightImpact]);
const loadMore = useCallback(() => {
const loadMore: () => void = useCallback(() => {
if (hasMore && !isLoading) {
setPage((prev) => prev + 1);
setPage((prev: number) => {
return prev + 1;
});
}
}, [hasMore, isLoading]);
const handlePress = useCallback(
const handlePress: (incident: IncidentItem) => void = useCallback(
(incident: IncidentItem) => {
navigation.navigate("IncidentDetail", { incidentId: incident._id });
},
[navigation],
);
const handleAcknowledge = useCallback(
async (incident: IncidentItem) => {
if (!acknowledgeState) {
return;
}
try {
await changeIncidentState(projectId, incident._id, acknowledgeState._id);
await successFeedback();
await refetch();
await queryClient.invalidateQueries({ queryKey: ["incidents"] });
} catch {
await errorFeedback();
}
},
[projectId, acknowledgeState, successFeedback, errorFeedback, refetch, queryClient],
);
const handleAcknowledge: (incident: IncidentItem) => Promise<void> =
useCallback(
async (incident: IncidentItem) => {
if (!acknowledgeState) {
return;
}
try {
await changeIncidentState(
projectId,
incident._id,
acknowledgeState._id,
);
await successFeedback();
await refetch();
await queryClient.invalidateQueries({ queryKey: ["incidents"] });
} catch {
await errorFeedback();
}
},
[
projectId,
acknowledgeState,
successFeedback,
errorFeedback,
refetch,
queryClient,
],
);
if (isLoading && incidents.length === 0) {
return (
@@ -125,7 +149,9 @@ export default function IncidentsScreen(): React.JSX.Element {
styles.retryButton,
{ backgroundColor: theme.colors.actionPrimary },
]}
onPress={() => refetch()}
onPress={() => {
return refetch();
}}
>
<Text
style={[
@@ -149,26 +175,37 @@ export default function IncidentsScreen(): React.JSX.Element {
>
<FlatList
data={incidents}
keyExtractor={(item) => item._id}
keyExtractor={(item: IncidentItem) => {
return item._id;
}}
contentContainerStyle={
incidents.length === 0 ? styles.emptyContainer : styles.list
}
renderItem={({ item }) => (
<SwipeableCard
rightAction={
acknowledgeState &&
item.currentIncidentState?._id !== acknowledgeState._id
? {
label: "Acknowledge",
color: "#2EA043",
onAction: () => handleAcknowledge(item),
}
: undefined
}
>
<IncidentCard incident={item} onPress={() => handlePress(item)} />
</SwipeableCard>
)}
renderItem={({ item }: { item: IncidentItem }) => {
return (
<SwipeableCard
rightAction={
acknowledgeState &&
item.currentIncidentState?._id !== acknowledgeState._id
? {
label: "Acknowledge",
color: "#2EA043",
onAction: () => {
return handleAcknowledge(item);
},
}
: undefined
}
>
<IncidentCard
incident={item}
onPress={() => {
return handlePress(item);
}}
/>
</SwipeableCard>
);
}}
ListEmptyComponent={
<EmptyState
title="No active incidents"
@@ -186,7 +223,7 @@ export default function IncidentsScreen(): React.JSX.Element {
);
}
const styles = StyleSheet.create({
const styles: ReturnType<typeof StyleSheet.create> = StyleSheet.create({
container: {
flex: 1,
},

View File

@@ -1,11 +1,5 @@
import React, { useState, useEffect, useCallback } from "react";
import {
View,
Text,
ScrollView,
Switch,
StyleSheet,
} from "react-native";
import { View, Text, ScrollView, Switch, StyleSheet, ViewStyle, TextStyle } from "react-native";
import { useTheme } from "../theme";
import { useHaptics } from "../hooks/useHaptics";
import {
@@ -43,9 +37,7 @@ function PrefRow({
accessibilityLabel={`${label}. ${description}`}
>
<View style={styles.rowText}>
<Text
style={[styles.rowLabel, { color: theme.colors.textPrimary }]}
>
<Text style={[styles.rowLabel, { color: theme.colors.textPrimary }]}>
{label}
</Text>
<Text
@@ -80,16 +72,16 @@ export default function NotificationPreferencesScreen(): React.JSX.Element {
const [loaded, setLoaded] = useState(false);
useEffect(() => {
getNotificationPreferences().then((p) => {
getNotificationPreferences().then((p: NotificationPreferences) => {
setPrefs(p);
setLoaded(true);
});
}, []);
const updatePref = useCallback(
const updatePref: (key: keyof NotificationPreferences, value: boolean) => void = useCallback(
(key: keyof NotificationPreferences, value: boolean) => {
selectionFeedback();
const updated = { ...prefs, [key]: value };
const updated: NotificationPreferences = { ...prefs, [key]: value };
setPrefs(updated);
setNotificationPreferences(updated);
},
@@ -130,25 +122,33 @@ export default function NotificationPreferencesScreen(): React.JSX.Element {
label="Incidents"
description="New incidents and state changes"
value={prefs.incidents}
onValueChange={(v) => updatePref("incidents", v)}
onValueChange={(v: boolean) => {
return updatePref("incidents", v);
}}
/>
<PrefRow
label="Alerts"
description="New alerts and state changes"
value={prefs.alerts}
onValueChange={(v) => updatePref("alerts", v)}
onValueChange={(v: boolean) => {
return updatePref("alerts", v);
}}
/>
<PrefRow
label="Incident Episodes"
description="Grouped incident notifications"
value={prefs.incidentEpisodes}
onValueChange={(v) => updatePref("incidentEpisodes", v)}
onValueChange={(v: boolean) => {
return updatePref("incidentEpisodes", v);
}}
/>
<PrefRow
label="Alert Episodes"
description="Grouped alert notifications"
value={prefs.alertEpisodes}
onValueChange={(v) => updatePref("alertEpisodes", v)}
onValueChange={(v: boolean) => {
return updatePref("alertEpisodes", v);
}}
/>
</View>
</View>
@@ -166,25 +166,39 @@ export default function NotificationPreferencesScreen(): React.JSX.Element {
label="Critical Only"
description="Only receive notifications for critical and high severity events"
value={prefs.criticalOnly}
onValueChange={(v) => updatePref("criticalOnly", v)}
onValueChange={(v: boolean) => {
return updatePref("criticalOnly", v);
}}
/>
</View>
</View>
{/* Info */}
<View style={styles.infoSection}>
<Text
style={[styles.infoText, { color: theme.colors.textTertiary }]}
>
Notification preferences are stored locally on this device. Server-side
notification rules configured in your project settings take precedence.
<Text style={[styles.infoText, { color: theme.colors.textTertiary }]}>
Notification preferences are stored locally on this device.
Server-side notification rules configured in your project settings
take precedence.
</Text>
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
const styles: {
container: ViewStyle;
content: ViewStyle;
section: ViewStyle;
sectionTitle: TextStyle;
sectionHint: TextStyle;
rowGroup: ViewStyle;
row: ViewStyle;
rowText: ViewStyle;
rowLabel: TextStyle;
rowDescription: TextStyle;
infoSection: ViewStyle;
infoText: TextStyle;
} = StyleSheet.create({
container: {
flex: 1,
},

View File

@@ -98,7 +98,10 @@ export default function ProjectSelectionScreen(): React.JSX.Element {
>
<View style={styles.header}>
<Text
style={[theme.typography.titleLarge, { color: theme.colors.textPrimary }]}
style={[
theme.typography.titleLarge,
{ color: theme.colors.textPrimary },
]}
>
Select Project
</Text>
@@ -114,48 +117,54 @@ export default function ProjectSelectionScreen(): React.JSX.Element {
<FlatList
data={projectList}
keyExtractor={(item) => item._id}
keyExtractor={(item) => {
return item._id;
}}
contentContainerStyle={styles.list}
renderItem={({ item }) => (
<TouchableOpacity
style={[
styles.projectCard,
{
backgroundColor: theme.colors.backgroundSecondary,
borderColor: theme.colors.borderSubtle,
},
]}
onPress={() => handleSelect(item)}
activeOpacity={0.7}
>
<View
renderItem={({ item }) => {
return (
<TouchableOpacity
style={[
styles.projectDot,
{ backgroundColor: theme.colors.actionPrimary },
styles.projectCard,
{
backgroundColor: theme.colors.backgroundSecondary,
borderColor: theme.colors.borderSubtle,
},
]}
/>
<View style={styles.projectInfo}>
<Text
onPress={() => {
return handleSelect(item);
}}
activeOpacity={0.7}
>
<View
style={[
theme.typography.bodyLarge,
{ color: theme.colors.textPrimary, fontWeight: "600" },
styles.projectDot,
{ backgroundColor: theme.colors.actionPrimary },
]}
>
{item.name}
</Text>
{item.slug ? (
/>
<View style={styles.projectInfo}>
<Text
style={[
theme.typography.bodySmall,
{ color: theme.colors.textTertiary },
theme.typography.bodyLarge,
{ color: theme.colors.textPrimary, fontWeight: "600" },
]}
>
{item.slug}
{item.name}
</Text>
) : null}
</View>
</TouchableOpacity>
)}
{item.slug ? (
<Text
style={[
theme.typography.bodySmall,
{ color: theme.colors.textTertiary },
]}
>
{item.slug}
</Text>
) : null}
</View>
</TouchableOpacity>
);
}}
/>
</View>
);

View File

@@ -17,7 +17,10 @@ import { useHaptics } from "../hooks/useHaptics";
import { getServerUrl } from "../storage/serverUrl";
import type { SettingsStackParamList } from "../navigation/types";
type SettingsNavProp = NativeStackNavigationProp<SettingsStackParamList, "SettingsList">;
type SettingsNavProp = NativeStackNavigationProp<
SettingsStackParamList,
"SettingsList"
>;
const APP_VERSION = "1.0.0";
@@ -64,7 +67,9 @@ function SettingsRow({
</Text>
{rightElement ??
(value ? (
<Text style={[styles.rowValue, { color: theme.colors.textSecondary }]}>
<Text
style={[styles.rowValue, { color: theme.colors.textSecondary }]}
>
{value}
</Text>
) : onPress ? (
@@ -147,16 +152,16 @@ export default function SettingsScreen(): React.JSX.Element {
backgroundColor: theme.colors.actionPrimary,
},
]}
onPress={() => handleThemeChange(mode)}
onPress={() => {
return handleThemeChange(mode);
}}
activeOpacity={0.7}
>
<Text
style={[
styles.themeOptionIcon,
{
color: isActive
? "#FFFFFF"
: theme.colors.textSecondary,
color: isActive ? "#FFFFFF" : theme.colors.textSecondary,
},
]}
>
@@ -166,9 +171,7 @@ export default function SettingsScreen(): React.JSX.Element {
style={[
styles.themeOptionLabel,
{
color: isActive
? "#FFFFFF"
: theme.colors.textPrimary,
color: isActive ? "#FFFFFF" : theme.colors.textPrimary,
},
]}
>
@@ -189,7 +192,9 @@ export default function SettingsScreen(): React.JSX.Element {
</Text>
<SettingsRow
label="Notification Preferences"
onPress={() => navigation.navigate("NotificationPreferences")}
onPress={() => {
return navigation.navigate("NotificationPreferences");
}}
/>
</View>
@@ -245,10 +250,7 @@ export default function SettingsScreen(): React.JSX.Element {
>
Server
</Text>
<SettingsRow
label="Server URL"
value={serverUrl || "oneuptime.com"}
/>
<SettingsRow label="Server URL" value={serverUrl || "oneuptime.com"} />
</View>
{/* Account */}
@@ -258,11 +260,7 @@ export default function SettingsScreen(): React.JSX.Element {
>
Account
</Text>
<SettingsRow
label="Log Out"
onPress={logout}
destructive
/>
<SettingsRow label="Log Out" onPress={logout} destructive />
</View>
{/* About */}
@@ -279,9 +277,7 @@ export default function SettingsScreen(): React.JSX.Element {
{/* Footer branding */}
<View style={styles.footer}>
<Text
style={[styles.footerText, { color: theme.colors.textTertiary }]}
>
<Text style={[styles.footerText, { color: theme.colors.textTertiary }]}>
OneUptime On-Call
</Text>
</View>

View File

@@ -9,9 +9,12 @@ import {
KeyboardAvoidingView,
Platform,
ScrollView,
ViewStyle,
TextStyle,
} from "react-native";
import { useTheme } from "../../theme";
import { useAuth } from "../../hooks/useAuth";
import { LoginResponse } from "../../api/auth";
import { getServerUrl } from "../../storage/serverUrl";
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { useNavigation } from "@react-navigation/native";
@@ -25,7 +28,7 @@ type LoginNavigationProp = NativeStackNavigationProp<
export default function LoginScreen(): React.JSX.Element {
const { theme } = useTheme();
const { login, setNeedsServerUrl } = useAuth();
const navigation = useNavigation<LoginNavigationProp>();
const navigation: LoginNavigationProp = useNavigation<LoginNavigationProp>();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [serverUrl, setServerUrlState] = useState("");
@@ -36,7 +39,7 @@ export default function LoginScreen(): React.JSX.Element {
getServerUrl().then(setServerUrlState);
}, []);
const handleLogin = async (): Promise<void> => {
const handleLogin: () => Promise<void> = async (): Promise<void> => {
if (!email.trim() || !password.trim()) {
setError("Email and password are required.");
return;
@@ -46,7 +49,7 @@ export default function LoginScreen(): React.JSX.Element {
setIsLoading(true);
try {
const response = await login(email.trim(), password);
const response: LoginResponse = await login(email.trim(), password);
if (response.twoFactorRequired) {
setError(
@@ -54,7 +57,7 @@ export default function LoginScreen(): React.JSX.Element {
);
}
} catch (err: any) {
const message =
const message: string =
err?.response?.data?.message ||
err?.message ||
"Login failed. Please check your credentials.";
@@ -64,7 +67,7 @@ export default function LoginScreen(): React.JSX.Element {
}
};
const handleChangeServer = (): void => {
const handleChangeServer: () => void = (): void => {
setNeedsServerUrl(true);
navigation.navigate("ServerUrl");
};
@@ -123,7 +126,7 @@ export default function LoginScreen(): React.JSX.Element {
},
]}
value={email}
onChangeText={(text) => {
onChangeText={(text: string) => {
setEmail(text);
setError(null);
}}
@@ -158,7 +161,7 @@ export default function LoginScreen(): React.JSX.Element {
},
]}
value={password}
onChangeText={(text) => {
onChangeText={(text: string) => {
setPassword(text);
setError(null);
}}
@@ -229,7 +232,16 @@ export default function LoginScreen(): React.JSX.Element {
);
}
const styles = StyleSheet.create({
const styles: {
flex: ViewStyle;
scrollContent: ViewStyle;
container: ViewStyle;
header: ViewStyle;
form: ViewStyle;
input: TextStyle;
button: ViewStyle;
changeServer: ViewStyle;
} = StyleSheet.create({
flex: {
flex: 1,
},

View File

@@ -9,6 +9,8 @@ import {
KeyboardAvoidingView,
Platform,
ScrollView,
ViewStyle,
TextStyle,
} from "react-native";
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { useNavigation } from "@react-navigation/native";
@@ -26,12 +28,12 @@ type ServerUrlNavigationProp = NativeStackNavigationProp<
export default function ServerUrlScreen(): React.JSX.Element {
const { theme } = useTheme();
const { setNeedsServerUrl } = useAuth();
const navigation = useNavigation<ServerUrlNavigationProp>();
const navigation: ServerUrlNavigationProp = useNavigation<ServerUrlNavigationProp>();
const [url, setUrl] = useState("https://oneuptime.com");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleConnect = async (): Promise<void> => {
const handleConnect: () => Promise<void> = async (): Promise<void> => {
if (!url.trim()) {
setError("Please enter a server URL");
return;
@@ -41,8 +43,8 @@ export default function ServerUrlScreen(): React.JSX.Element {
setIsLoading(true);
try {
const normalizedUrl = url.trim().replace(/\/+$/, "");
const isValid = await validateServerUrl(normalizedUrl);
const normalizedUrl: string = url.trim().replace(/\/+$/, "");
const isValid: boolean = await validateServerUrl(normalizedUrl);
if (!isValid) {
setError(
@@ -118,7 +120,7 @@ export default function ServerUrlScreen(): React.JSX.Element {
},
]}
value={url}
onChangeText={(text) => {
onChangeText={(text: string) => {
setUrl(text);
setError(null);
}}
@@ -189,7 +191,15 @@ export default function ServerUrlScreen(): React.JSX.Element {
);
}
const styles = StyleSheet.create({
const styles: {
flex: ViewStyle;
scrollContent: ViewStyle;
container: ViewStyle;
header: ViewStyle;
form: ViewStyle;
input: TextStyle;
button: ViewStyle;
} = StyleSheet.create({
flex: {
flex: 1,
},

View File

@@ -1,6 +1,6 @@
import * as Keychain from "react-native-keychain";
const SERVICE_NAME = "com.oneuptime.oncall.tokens";
const SERVICE_NAME: string = "com.oneuptime.oncall.tokens";
export interface StoredTokens {
accessToken: string;
@@ -17,15 +17,13 @@ export function getCachedAccessToken(): string | null {
export async function storeTokens(tokens: StoredTokens): Promise<void> {
cachedAccessToken = tokens.accessToken;
await Keychain.setGenericPassword(
"tokens",
JSON.stringify(tokens),
{ service: SERVICE_NAME },
);
await Keychain.setGenericPassword("tokens", JSON.stringify(tokens), {
service: SERVICE_NAME,
});
}
export async function getTokens(): Promise<StoredTokens | null> {
const credentials = await Keychain.getGenericPassword({
const credentials: false | Keychain.UserCredentials = await Keychain.getGenericPassword({
service: SERVICE_NAME,
});

View File

@@ -1,7 +1,11 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import type { ThemeMode } from "../theme";
const KEYS = {
const KEYS: {
readonly THEME_MODE: "oneuptime_theme_mode";
readonly BIOMETRIC_ENABLED: "oneuptime_biometric_enabled";
readonly NOTIFICATION_PREFS: "oneuptime_notification_prefs";
} = {
THEME_MODE: "oneuptime_theme_mode",
BIOMETRIC_ENABLED: "oneuptime_biometric_enabled",
NOTIFICATION_PREFS: "oneuptime_notification_prefs",
@@ -24,7 +28,7 @@ const DEFAULT_NOTIFICATION_PREFS: NotificationPreferences = {
};
export async function getThemeMode(): Promise<ThemeMode> {
const stored = await AsyncStorage.getItem(KEYS.THEME_MODE);
const stored: string | null = await AsyncStorage.getItem(KEYS.THEME_MODE);
if (stored === "dark" || stored === "light" || stored === "system") {
return stored;
}
@@ -36,7 +40,7 @@ export async function setThemeMode(mode: ThemeMode): Promise<void> {
}
export async function getBiometricEnabled(): Promise<boolean> {
const stored = await AsyncStorage.getItem(KEYS.BIOMETRIC_ENABLED);
const stored: string | null = await AsyncStorage.getItem(KEYS.BIOMETRIC_ENABLED);
return stored === "true";
}
@@ -45,7 +49,7 @@ export async function setBiometricEnabled(enabled: boolean): Promise<void> {
}
export async function getNotificationPreferences(): Promise<NotificationPreferences> {
const stored = await AsyncStorage.getItem(KEYS.NOTIFICATION_PREFS);
const stored: string | null = await AsyncStorage.getItem(KEYS.NOTIFICATION_PREFS);
if (stored) {
try {
return { ...DEFAULT_NOTIFICATION_PREFS, ...JSON.parse(stored) };

View File

@@ -1,14 +1,14 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
const STORAGE_KEY = "oneuptime_server_url";
const DEFAULT_SERVER_URL = "https://oneuptime.com";
const STORAGE_KEY: string = "oneuptime_server_url";
const DEFAULT_SERVER_URL: string = "https://oneuptime.com";
function normalizeUrl(url: string): string {
return url.replace(/\/+$/, "");
}
export async function getServerUrl(): Promise<string> {
const stored = await AsyncStorage.getItem(STORAGE_KEY);
const stored: string | null = await AsyncStorage.getItem(STORAGE_KEY);
return stored || DEFAULT_SERVER_URL;
}
@@ -17,7 +17,7 @@ export async function setServerUrl(url: string): Promise<void> {
}
export async function hasServerUrl(): Promise<boolean> {
const stored = await AsyncStorage.getItem(STORAGE_KEY);
const stored: string | null = await AsyncStorage.getItem(STORAGE_KEY);
return stored !== null;
}

View File

@@ -31,29 +31,31 @@ interface ThemeContextValue {
setThemeMode: (mode: ThemeMode) => void;
}
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
const ThemeContext: React.Context<ThemeContextValue | undefined> = createContext<ThemeContextValue | undefined>(undefined);
interface ThemeProviderProps {
children: ReactNode;
}
export function ThemeProvider({ children }: ThemeProviderProps): React.JSX.Element {
const systemColorScheme = useColorScheme();
export function ThemeProvider({
children,
}: ThemeProviderProps): React.JSX.Element {
const systemColorScheme: "light" | "dark" | null | undefined = useColorScheme();
const [themeMode, setThemeModeState] = useState<ThemeMode>("dark");
// Load persisted theme on mount
useEffect(() => {
loadThemeMode().then((mode) => {
loadThemeMode().then((mode: ThemeMode) => {
setThemeModeState(mode);
});
}, []);
const setThemeMode = (mode: ThemeMode): void => {
const setThemeMode: (mode: ThemeMode) => void = (mode: ThemeMode): void => {
setThemeModeState(mode);
saveThemeMode(mode);
};
const theme = useMemo((): Theme => {
const theme: Theme = useMemo((): Theme => {
let isDark: boolean;
if (themeMode === "system") {
@@ -71,14 +73,13 @@ export function ThemeProvider({ children }: ThemeProviderProps): React.JSX.Eleme
};
}, [themeMode, systemColorScheme]);
const value = useMemo(
(): ThemeContextValue => ({
const value: ThemeContextValue = useMemo((): ThemeContextValue => {
return {
theme,
themeMode,
setThemeMode,
}),
[theme, themeMode],
);
};
}, [theme, themeMode]);
return (
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
@@ -86,7 +87,7 @@ export function ThemeProvider({ children }: ThemeProviderProps): React.JSX.Eleme
}
export function useTheme(): ThemeContextValue {
const context = useContext(ThemeContext);
const context: ThemeContextValue | undefined = useContext(ThemeContext);
if (!context) {
throw new Error("useTheme must be used within a ThemeProvider");
}

View File

@@ -1,4 +1,10 @@
export const spacing = {
export const spacing: {
readonly xs: 4;
readonly sm: 8;
readonly md: 16;
readonly lg: 24;
readonly xl: 32;
} = {
xs: 4,
sm: 8,
md: 16,
@@ -6,7 +12,11 @@ export const spacing = {
xl: 32,
} as const;
export const radius = {
export const radius: {
readonly sm: 6;
readonly md: 12;
readonly lg: 16;
} = {
sm: 6,
md: 12,
lg: 16,

View File

@@ -1,11 +1,7 @@
export function rgbToHex(color: {
r: number;
g: number;
b: number;
}): string {
const r = Math.max(0, Math.min(255, Math.round(color.r)));
const g = Math.max(0, Math.min(255, Math.round(color.g)));
const b = Math.max(0, Math.min(255, Math.round(color.b)));
export function rgbToHex(color: { r: number; g: number; b: number }): string {
const r: number = Math.max(0, Math.min(255, Math.round(color.r)));
const g: number = Math.max(0, Math.min(255, Math.round(color.g)));
const b: number = Math.max(0, Math.min(255, Math.round(color.b)));
return (
"#" +

View File

@@ -1,38 +1,38 @@
export function formatRelativeTime(dateString: string): string {
const now = Date.now();
const date = new Date(dateString).getTime();
const seconds = Math.floor((now - date) / 1000);
const now: number = Date.now();
const date: number = new Date(dateString).getTime();
const seconds: number = Math.floor((now - date) / 1000);
if (seconds < 60) {
return "just now";
}
const minutes = Math.floor(seconds / 60);
const minutes: number = Math.floor(seconds / 60);
if (minutes < 60) {
return `${minutes}m ago`;
}
const hours = Math.floor(minutes / 60);
const hours: number = Math.floor(minutes / 60);
if (hours < 24) {
return `${hours}h ago`;
}
const days = Math.floor(hours / 24);
const days: number = Math.floor(hours / 24);
if (days < 30) {
return `${days}d ago`;
}
const months = Math.floor(days / 30);
const months: number = Math.floor(days / 30);
if (months < 12) {
return `${months}mo ago`;
}
const years = Math.floor(months / 12);
const years: number = Math.floor(months / 12);
return `${years}y ago`;
}
export function formatDateTime(dateString: string): string {
const date = new Date(dateString);
const date: Date = new Date(dateString);
return date.toLocaleDateString(undefined, {
year: "numeric",
month: "short",