LynxJS Alerts: แจ้งเตือน IoT แบบครบวงจร

LynxJS Alerts: แจ้งเตือน IoT แบบครบวงจร

Showkhun · Workshop ·

Branch: workshop/dev-14-lynxjs-alerts Phase: Mobile Frontend (5/5) Repo: kangana1024/iot-workshop


เฮ้ น้องๆ มีอะไรมาบอกก่อน! (ง•̀_•́)ง

ลองนึกภาพนะ… sensor ในโรงงานอุณหภูมิพุ่งขึ้น 47°C แล้วไม่มีใครรู้เลย เพราะไม่มีระบบแจ้งเตือน

นั่นคือสาเหตุที่บทนี้สำคัญมาก

Notifications & Alerts ไม่ใช่แค่ “ฟีเจอร์เสริม” แต่คือ ระบบป้องกัน ที่ทำให้ IoT app ของเรามีประโยชน์จริงๆ เพราะถ้า sensor วัดค่าได้แต่ไม่บอกใคร มันก็เหมือนแอร์แมนชั้นที่ไม่มีไฟเตือนน้ำมันหมด — รู้ตอนจอดข้างทางแล้วก็สาย


สิ่งที่น้องๆ จะได้เรียนรู้ในบทนี้

  • WHY ก่อนเสมอ — ทำไมต้อง Push Notification ไม่ใช่แค่ polling?
  • ติดตั้งและ setup Push Notification ด้วย Expo พร้อม token registration
  • สร้าง NotificationService แบบ Singleton — sound/vibration ตาม severity
  • สร้าง Alert Store ด้วย Zustand พร้อม filter ครบ
  • สร้าง AlertRow + Severity Badges ให้ดูสวย อ่านง่าย
  • สร้าง Alerts List Screen จัดกลุ่มตามวันแบบ SectionList
  • สร้าง Alert Detail Screen + mark read/unread
  • สร้าง Notification Preferences พร้อม Quiet Hours

ก่อนจะ HOW ต้องรู้ WHY ก่อน

ทำไม Push Notification ไม่ใช่แค่ fetch ทุกๆ 5 วินาที?

นึกภาพน้องไปซื้อข้าวแล้วขอให้เพื่อนโทรมาถ้ามีด่วน vs น้องโทรถามเพื่อนทุก 5 นาทีว่า “มีอะไรด่วนมั้ย?”

แบบแรกคือ Push — เบา, ตอบสนองทันที, ไม่เปลือง battery แบบสองคือ Polling — หนัก, ช้า, แบตหมดก่อน

Push Notification Flow:

IoT Device
    |
    v (sensor เกิน threshold)
Backend Server
    |
    v (ส่ง push)
Expo Push Service
    |
    v (deliver)
มือถือน้องๆ  🔔

ทำไมต้องมี Severity?

เหมือนโรงพยาบาลมี triage — ไม่ใช่ทุกคนที่เจ็บต้องผ่าตัดทันที บางคนรอได้ บางคนต้องรีบ

Severityความหมายเปรียบเหมือน
LOWแจ้งเพื่อทราบป้ายบอกทางที่ซีด
MEDIUMควรดูแลน้ำมันเหลือ 1/4 ถัง
HIGHต้องดูแลยางแบน
CRITICALฉุกเฉินเบรกไม่อยู่

ภาพรวม Architecture ของระบบ

ก่อนจะลงโค้ด มาดู flow ทั้งหมดก่อนดีกว่า ให้เห็นภาพรวม:

Mermaid Diagram

เห็นมั้ย? ทุกอย่างวนรอบ NotificationService และ useAlertStore สองตัวนี้คือหัวใจของระบบ มาลุยกัน!


Step 1: ติดตั้ง Library ที่ต้องใช้

cd frontend-mobile

# Push notifications
npm install @lynx-js/notifications expo-notifications expo-device

# Vibration & Sound
npm install expo-haptics expo-av

# Background tasks
npm install expo-background-fetch expo-task-manager

ทำไมต้องใช้ expo-haptics ด้วย? เพราะเวลา CRITICAL alert มา การสั่น (haptic) จะทำให้คนรู้ได้แม้เปิดเสียงไว้ เหมือนโทรศัพท์สั่นในกระเป๋า — บางทีได้ยินกว่าเสียงเสียอีก


Step 2: Push Notification Service

นี่คือ หัวใจ ของระบบ เราทำเป็น Singleton เพราะต้องการให้มีแค่ instance เดียวทั้ง app (เหมือนเบอร์โทรฉุกเฉินมีแค่เบอร์เดียว ไม่งั้นสับสน)

// src/services/notifications.ts
import * as Notifications from 'expo-notifications';
import * as Device from 'expo-device';
import * as Haptics from 'expo-haptics';
import AsyncStorage from '@lynx-js/async-storage';
import { api } from './api';
import type { Alert } from '../types';

// ตั้งค่า notification handler — บอก OS ว่าเวลา notification มาให้ทำอะไร
Notifications.setNotificationHandler({
  handleNotification: async () => ({
    shouldShowAlert: true,
    shouldPlaySound: true,
    shouldSetBadge: true,
  }),
});

export class NotificationService {
  private static instance: NotificationService;
  private notifSub: Notifications.Subscription | null = null;
  private responseSub: Notifications.Subscription | null = null;

  static getInstance(): NotificationService {
    if (!this.instance) {
      this.instance = new NotificationService();
    }
    return this.instance;
  }

  // ========== Register ==========

  async registerForPushNotifications(): Promise<string | null> {
    if (!Device.isDevice) {
      console.warn('Push notifications require a physical device');
      return null;
    }

    const { status: existingStatus } = await Notifications.getPermissionsAsync();
    let finalStatus = existingStatus;

    if (existingStatus !== 'granted') {
      const { status } = await Notifications.requestPermissionsAsync();
      finalStatus = status;
    }

    if (finalStatus !== 'granted') {
      console.warn('Push notification permission denied');
      return null;
    }

    const token = (
      await Notifications.getExpoPushTokenAsync({
        projectId: process.env.EXPO_PUBLIC_PROJECT_ID,
      })
    ).data;

    // บันทึก token ลง AsyncStorage และส่งไป backend
    await AsyncStorage.setItem('push_token', token);
    await this.registerTokenWithBackend(token);

    return token;
  }

  private async registerTokenWithBackend(token: string): Promise<void> {
    try {
      await api['client']?.post('/api/v1/notifications/register', {
        token,
        platform: 'mobile',
      });
    } catch (err) {
      console.error('Failed to register push token:', err);
    }
  }

  // ========== Listeners ==========

  startListening(
    onReceive: (notification: Notifications.Notification) => void,
    onResponse: (response: Notifications.NotificationResponse) => void
  ) {
    this.notifSub = Notifications.addNotificationReceivedListener(onReceive);
    this.responseSub = Notifications.addNotificationResponseReceivedListener(onResponse);
  }

  stopListening() {
    this.notifSub?.remove();
    this.responseSub?.remove();
  }

  // ========== Local Notifications ==========

  async scheduleLocalNotification(alert: Alert): Promise<void> {
    const prefs = await this.getPreferences();
    if (!prefs.enabled) return;

    await Notifications.scheduleNotificationAsync({
      content: {
        title: `${alert.severity.toUpperCase()}: ${alert.deviceName}`,
        body: alert.message,
        data: { alertId: alert.id, deviceId: alert.deviceId },
        sound: prefs.sound ? 'default' : undefined,
        badge: 1,
      },
      trigger: null, // immediate
    });

    // สั่นตาม severity — CRITICAL สั่นแบบ error, HIGH แบบ warning, ที่เหลือ success
    if (prefs.vibration) {
      const pattern =
        alert.severity === 'critical'
          ? Haptics.NotificationFeedbackType.Error
          : alert.severity === 'high'
          ? Haptics.NotificationFeedbackType.Warning
          : Haptics.NotificationFeedbackType.Success;
      await Haptics.notificationAsync(pattern);
    }
  }

  // ========== Badge ==========

  async setBadgeCount(count: number): Promise<void> {
    await Notifications.setBadgeCountAsync(count);
  }

  async clearBadge(): Promise<void> {
    await Notifications.setBadgeCountAsync(0);
  }

  // ========== Preferences ==========

  async getPreferences(): Promise<NotificationPreferences> {
    const raw = await AsyncStorage.getItem('notification_prefs');
    if (!raw) return DEFAULT_PREFS;
    return JSON.parse(raw);
  }

  async savePreferences(prefs: NotificationPreferences): Promise<void> {
    await AsyncStorage.setItem('notification_prefs', JSON.stringify(prefs));
  }
}

export interface NotificationPreferences {
  enabled: boolean;
  sound: boolean;
  vibration: boolean;
  severityFilter: ('low' | 'medium' | 'high' | 'critical')[];
  quietHoursEnabled: boolean;
  quietHoursStart: string; // "22:00"
  quietHoursEnd: string;   // "07:00"
}

export const DEFAULT_PREFS: NotificationPreferences = {
  enabled: true,
  sound: true,
  vibration: true,
  severityFilter: ['low', 'medium', 'high', 'critical'],
  quietHoursEnabled: false,
  quietHoursStart: '22:00',
  quietHoursEnd: '07:00',
};

export const notificationService = NotificationService.getInstance();

สังเกตมั้ย? quietHoursEnabled ใน DEFAULT_PREFS เป็น false — เพราะเราไม่อยากให้ alert CRITICAL โดนปิดกั้นโดยที่ user ยังไม่รู้ตัว ต้องเปิดเองถึงจะเปิด


Step 3: Alert Store ด้วย Zustand

Store คือ “กล่องเก็บของ” กลางของ app เหมือนกระดานไวต์บอร์ดในออฟฟิศที่ทุกคนเห็นและอัพเดตได้

// src/stores/useAlertStore.ts
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
import type { Alert } from '../types';
import type { NotificationPreferences } from '../services/notifications';
import { DEFAULT_PREFS } from '../services/notifications';

interface AlertState {
  alerts: Alert[];
  unreadCount: number;
  isLoading: boolean;
  preferences: NotificationPreferences;
  filterSeverity: 'all' | Alert['severity'];
  filterRead: 'all' | 'read' | 'unread';

  setAlerts: (alerts: Alert[]) => void;
  addAlert: (alert: Alert) => void;
  markRead: (id: string) => void;
  markUnread: (id: string) => void;
  markAllRead: () => void;
  setLoading: (v: boolean) => void;
  setPreferences: (p: NotificationPreferences) => void;
  setFilterSeverity: (f: AlertState['filterSeverity']) => void;
  setFilterRead: (f: AlertState['filterRead']) => void;
}

export const useAlertStore = create<AlertState>()(
  immer((set) => ({
    alerts: [],
    unreadCount: 0,
    isLoading: false,
    preferences: DEFAULT_PREFS,
    filterSeverity: 'all',
    filterRead: 'all',

    setAlerts: (alerts) =>
      set((s) => {
        s.alerts = alerts;
        s.unreadCount = alerts.filter((a) => !a.isRead).length;
      }),

    addAlert: (alert) =>
      set((s) => {
        s.alerts.unshift(alert);
        if (!alert.isRead) s.unreadCount++;
      }),

    markRead: (id) =>
      set((s) => {
        const alert = s.alerts.find((a) => a.id === id);
        if (alert && !alert.isRead) {
          alert.isRead = true;
          s.unreadCount = Math.max(0, s.unreadCount - 1);
        }
      }),

    markUnread: (id) =>
      set((s) => {
        const alert = s.alerts.find((a) => a.id === id);
        if (alert && alert.isRead) {
          alert.isRead = false;
          s.unreadCount++;
        }
      }),

    markAllRead: () =>
      set((s) => {
        s.alerts.forEach((a) => {
          a.isRead = true;
        });
        s.unreadCount = 0;
      }),

    setLoading: (v) =>
      set((s) => {
        s.isLoading = v;
      }),

    setPreferences: (p) =>
      set((s) => {
        s.preferences = p;
      }),

    setFilterSeverity: (f) =>
      set((s) => {
        s.filterSeverity = f;
      }),

    setFilterRead: (f) =>
      set((s) => {
        s.filterRead = f;
      }),
  }))
);

ทำไมใช้ immer? เพราะ immer ให้เราเขียน s.alerts.unshift(alert) แบบ mutable ได้เลย แต่ข้างในมัน immutable จริงๆ เหมือนเขียนบนกระดาษร่างก่อน แล้วค่อยพิมพ์สะอาด


Step 4: Severity Badge และ AlertRow

ตอนนี้มาสร้างหน้าตาให้ alert แต่ละแถว เหมือนออกแบบซองจดหมาย — ต้องบอกได้ทันทีว่าเรื่องไหนด่วน

// src/components/alerts/AlertRow.tsx
import { View, Text, TouchableOpacity, StyleSheet } from '@lynx-js/react';
import { Badge } from '../ui/Badge';
import { useTheme } from '../../theme';
import { format, isToday, isYesterday } from 'date-fns';
import type { Alert } from '../../types';

interface AlertRowProps {
  alert: Alert;
  onPress: (alert: Alert) => void;
  onLongPress?: (alert: Alert) => void;
}

export function AlertRow({ alert, onPress, onLongPress }: AlertRowProps) {
  const { colors } = useTheme();

  const severityVariant = {
    low: 'info' as const,
    medium: 'warning' as const,
    high: 'danger' as const,
    critical: 'danger' as const,
  }[alert.severity];

  const formatTime = (iso: string) => {
    const date = new Date(iso);
    if (isToday(date)) return format(date, 'HH:mm');
    if (isYesterday(date)) return `Yesterday ${format(date, 'HH:mm')}`;
    return format(date, 'dd MMM HH:mm');
  };

  return (
    <TouchableOpacity
      style={[
        styles.row,
        { backgroundColor: alert.isRead ? colors.surface : colors.primaryLight },
      ]}
      onPress={() => onPress(alert)}
      onLongPress={() => onLongPress?.(alert)}
      activeOpacity={0.7}
    >
      {/* Unread indicator — เส้นสีทางซ้าย บอกว่ายังไม่ได้อ่าน */}
      <View
        style={[
          styles.indicator,
          { backgroundColor: alert.isRead ? 'transparent' : colors.primary },
        ]}
      />

      <View style={styles.content}>
        <View style={styles.topRow}>
          <Text
            style={[
              styles.deviceName,
              { color: colors.text, fontWeight: alert.isRead ? '400' : '600' },
            ]}
            numberOfLines={1}
          >
            {alert.deviceName}
          </Text>
          <Text style={[styles.time, { color: colors.textSecondary }]}>
            {formatTime(alert.triggeredAt)}
          </Text>
        </View>

        <Text
          style={[styles.message, { color: colors.textSecondary }]}
          numberOfLines={2}
        >
          {alert.message}
        </Text>

        <View style={styles.badges}>
          <Badge label={alert.severity.toUpperCase()} variant={severityVariant} size="sm" />
          <Badge label={alert.type} variant="default" size="sm" />
        </View>
      </View>
    </TouchableOpacity>
  );
}

const styles = StyleSheet.create({
  row: {
    flexDirection: 'row',
    paddingVertical: 14,
    paddingRight: 16,
  },
  indicator: {
    width: 4,
    borderRadius: 2,
    marginRight: 12,
    marginLeft: 8,
  },
  content: { flex: 1 },
  topRow: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    marginBottom: 4,
  },
  deviceName: { flex: 1, fontSize: 14, marginRight: 8 },
  time: { fontSize: 12 },
  message: { fontSize: 13, lineHeight: 18, marginBottom: 8 },
  badges: { flexDirection: 'row', gap: 6 },
});

เส้นสี 4px ทางซ้าย (indicator) คือ UX trick ที่เรียบง่ายมากแต่ได้ผล — แค่ดูทรงก็รู้แล้วว่าอ่านหรือยัง ไม่ต้องอ่านตัวหนังสือ เหมือน Gmail ที่ message ยังไม่อ่านจะตัวหนา


Step 5: Alert Detail Screen

หน้านี้คือหน้าที่คนจะเห็นหลังจากแตะ notification ต้องให้ข้อมูลครบ ตัดสินใจได้ทันที

// src/screens/AlertDetailScreen.tsx
import {
  View,
  Text,
  ScrollView,
  TouchableOpacity,
  StyleSheet,
} from '@lynx-js/react';
import { useRoute, useNavigation } from '@lynx-js/react-navigation';
import { useAlertStore } from '../stores/useAlertStore';
import { api } from '../services/api';
import { Card } from '../components/ui/Card';
import { Badge } from '../components/ui/Badge';
import { useTheme } from '../theme';
import { format } from 'date-fns';

export default function AlertDetailScreen() {
  const { colors } = useTheme();
  const route = useRoute<{ alertId: string }>();
  const navigation = useNavigation();
  const { alerts, markRead, markUnread } = useAlertStore();

  const alert = alerts.find((a) => a.id === route.params.alertId);

  if (!alert) {
    return (
      <View style={[styles.center, { backgroundColor: colors.background }]}>
        <Text style={{ color: colors.textSecondary }}>Alert not found.</Text>
      </View>
    );
  }

  const severityVariant = {
    low: 'info' as const,
    medium: 'warning' as const,
    high: 'danger' as const,
    critical: 'danger' as const,
  }[alert.severity];

  const handleToggleRead = async () => {
    if (alert.isRead) {
      markUnread(alert.id);
    } else {
      markRead(alert.id);
      await api.markAlertRead(alert.id);
    }
  };

  return (
    <ScrollView
      style={[styles.container, { backgroundColor: colors.background }]}
      contentContainerStyle={styles.content}
    >
      {/* Severity Banner — สีบอก severity ได้ทันทีไม่ต้องอ่าน */}
      <View
        style={[
          styles.banner,
          {
            backgroundColor:
              alert.severity === 'critical' || alert.severity === 'high'
                ? colors.dangerLight
                : alert.severity === 'medium'
                ? colors.warningLight
                : colors.infoLight,
          },
        ]}
      >
        <Badge label={`${alert.severity.toUpperCase()} ALERT`} variant={severityVariant} />
        <Text
          style={[
            styles.bannerText,
            {
              color:
                alert.severity === 'critical' || alert.severity === 'high'
                  ? colors.danger
                  : alert.severity === 'medium'
                  ? colors.warning
                  : colors.info,
            },
          ]}
        >
          {alert.message}
        </Text>
      </View>

      {/* Alert Info */}
      <Card title="Alert Details" variant="elevated">
        <InfoRow label="Device" value={alert.deviceName} />
        <InfoRow label="Type" value={alert.type} />
        <InfoRow label="Severity" value={alert.severity} />
        <InfoRow
          label="Triggered"
          value={format(new Date(alert.triggeredAt), 'dd MMM yyyy HH:mm:ss')}
        />
        <InfoRow label="Status" value={alert.isRead ? 'Read' : 'Unread'} />
      </Card>

      {/* Actions */}
      <TouchableOpacity
        style={[
          styles.actionButton,
          {
            backgroundColor: alert.isRead ? colors.surface : colors.primary,
            borderColor: alert.isRead ? colors.border : colors.primary,
          },
        ]}
        onPress={handleToggleRead}
      >
        <Text
          style={[
            styles.actionLabel,
            { color: alert.isRead ? colors.text : '#fff' },
          ]}
        >
          {alert.isRead ? 'Mark as Unread' : 'Mark as Read'}
        </Text>
      </TouchableOpacity>

      <TouchableOpacity
        style={[styles.actionButton, { backgroundColor: colors.surface, borderColor: colors.border }]}
        onPress={() => navigation.navigate('DeviceDetail', { deviceId: alert.deviceId })}
      >
        <Text style={[styles.actionLabel, { color: colors.primary }]}>
          View Device
        </Text>
      </TouchableOpacity>
    </ScrollView>
  );
}

function InfoRow({ label, value }: { label: string; value: string }) {
  const { colors } = useTheme();
  return (
    <View style={[styles.infoRow, { borderBottomColor: colors.border }]}>
      <Text style={[styles.infoLabel, { color: colors.textSecondary }]}>{label}</Text>
      <Text style={[styles.infoValue, { color: colors.text }]}>{value}</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1 },
  content: { padding: 16 },
  center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
  banner: {
    borderRadius: 12,
    padding: 16,
    marginBottom: 16,
    gap: 10,
  },
  bannerText: { fontSize: 15, lineHeight: 22, fontWeight: '500' },
  infoRow: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    paddingVertical: 12,
    borderBottomWidth: 1,
  },
  infoLabel: { fontSize: 13 },
  infoValue: { fontSize: 13, fontWeight: '500', maxWidth: '60%', textAlign: 'right' },
  actionButton: {
    borderWidth: 1,
    borderRadius: 10,
    paddingVertical: 14,
    alignItems: 'center',
    marginBottom: 12,
  },
  actionLabel: { fontSize: 15, fontWeight: '600' },
});

Step 6: Alerts List Screen — รวมทุกอย่างไว้ที่เดียว

หน้านี้ซับซ้อนที่สุด เพราะต้องจัดการทั้ง filter, grouping, และ real-time notification listener พร้อมกัน

// src/screens/AlertsScreen.tsx
import {
  View,
  SectionList,
  Text,
  TouchableOpacity,
  StyleSheet,
  RefreshControl,
} from '@lynx-js/react';
import { useEffect, useCallback, useMemo } from '@lynx-js/react';
import { useNavigation } from '@lynx-js/react-navigation';
import { useAlertStore } from '../stores/useAlertStore';
import { api } from '../services/api';
import { notificationService } from '../services/notifications';
import { AlertRow } from '../components/alerts/AlertRow';
import { useTheme } from '../theme';
import { isToday, isYesterday, format } from 'date-fns';
import type { Alert } from '../types';

const SEVERITY_FILTERS = ['all', 'critical', 'high', 'medium', 'low'] as const;

export default function AlertsScreen() {
  const { colors } = useTheme();
  const navigation = useNavigation();
  const {
    alerts,
    unreadCount,
    isLoading,
    filterSeverity,
    filterRead,
    setAlerts,
    markRead,
    markAllRead,
    setLoading,
    setFilterSeverity,
    setFilterRead,
  } = useAlertStore();

  const loadAlerts = useCallback(async () => {
    setLoading(true);
    try {
      const res = await api.getAlerts({ limit: 100 });
      setAlerts(res.data);
      notificationService.setBadgeCount(res.data.filter((a) => !a.isRead).length);
    } catch (err) {
      console.error('Failed to load alerts:', err);
    } finally {
      setLoading(false);
    }
  }, []);

  useEffect(() => {
    loadAlerts();

    // Setup push notification listener — รับ notification แล้ว reload หรือ navigate
    notificationService.startListening(
      async (notification) => {
        const data = notification.request.content.data as {
          alertId?: string;
        };
        if (data.alertId) {
          await loadAlerts();
        }
      },
      (response) => {
        const data = response.notification.request.content.data as {
          alertId?: string;
        };
        if (data.alertId) {
          navigation.navigate('AlertDetail', { alertId: data.alertId });
        }
      }
    );

    return () => notificationService.stopListening();
  }, []);

  // Filter alerts ตาม severity และ read status
  const filtered = useMemo(() => {
    return alerts.filter((a) => {
      const matchSeverity = filterSeverity === 'all' || a.severity === filterSeverity;
      const matchRead =
        filterRead === 'all' ||
        (filterRead === 'read' && a.isRead) ||
        (filterRead === 'unread' && !a.isRead);
      return matchSeverity && matchRead;
    });
  }, [alerts, filterSeverity, filterRead]);

  // Group by date — Today / Yesterday / วันที่
  const sections = useMemo(() => {
    const groups: Record<string, Alert[]> = {};
    for (const alert of filtered) {
      const date = new Date(alert.triggeredAt);
      const key = isToday(date)
        ? 'Today'
        : isYesterday(date)
        ? 'Yesterday'
        : format(date, 'dd MMM yyyy');
      if (!groups[key]) groups[key] = [];
      groups[key].push(alert);
    }
    return Object.entries(groups).map(([title, data]) => ({ title, data }));
  }, [filtered]);

  const handlePress = (alert: Alert) => {
    if (!alert.isRead) {
      markRead(alert.id);
      api.markAlertRead(alert.id).catch(console.error);
    }
    navigation.navigate('AlertDetail', { alertId: alert.id });
  };

  const handleMarkAllRead = async () => {
    markAllRead();
    await api.markAllAlertsRead();
    await notificationService.clearBadge();
  };

  return (
    <View style={[styles.container, { backgroundColor: colors.background }]}>
      {/* Header Controls */}
      <View style={[styles.controls, { borderBottomColor: colors.border }]}>
        {/* Severity filter chips */}
        <View style={styles.filterRow}>
          {SEVERITY_FILTERS.map((s) => (
            <TouchableOpacity
              key={s}
              style={[
                styles.chip,
                {
                  backgroundColor:
                    filterSeverity === s ? colors.primary : colors.surface,
                  borderColor:
                    filterSeverity === s ? colors.primary : colors.border,
                },
              ]}
              onPress={() => setFilterSeverity(s)}
            >
              <Text
                style={[
                  styles.chipText,
                  { color: filterSeverity === s ? '#fff' : colors.textSecondary },
                ]}
              >
                {s.charAt(0).toUpperCase() + s.slice(1)}
              </Text>
            </TouchableOpacity>
          ))}
        </View>

        {/* Read filter + Mark all */}
        <View style={styles.actionRow}>
          <View style={styles.readFilterRow}>
            {(['all', 'unread', 'read'] as const).map((f) => (
              <TouchableOpacity
                key={f}
                style={[
                  styles.readChip,
                  filterRead === f && { borderColor: colors.primary },
                ]}
                onPress={() => setFilterRead(f)}
              >
                <Text
                  style={[
                    styles.readChipText,
                    { color: filterRead === f ? colors.primary : colors.textSecondary },
                  ]}
                >
                  {f.charAt(0).toUpperCase() + f.slice(1)}
                </Text>
              </TouchableOpacity>
            ))}
          </View>
          {unreadCount > 0 && (
            <TouchableOpacity onPress={handleMarkAllRead}>
              <Text style={[styles.markAll, { color: colors.primary }]}>
                Mark all read ({unreadCount})
              </Text>
            </TouchableOpacity>
          )}
        </View>
      </View>

      <SectionList
        sections={sections}
        keyExtractor={(item) => item.id}
        renderItem={({ item }) => (
          <AlertRow alert={item} onPress={handlePress} />
        )}
        renderSectionHeader={({ section: { title } }) => (
          <Text
            style={[
              styles.sectionHeader,
              { color: colors.textSecondary, backgroundColor: colors.background },
            ]}
          >
            {title}
          </Text>
        )}
        refreshControl={
          <RefreshControl
            refreshing={isLoading}
            onRefresh={loadAlerts}
            tintColor={colors.primary}
          />
        }
        ListEmptyComponent={
          <Text style={[styles.emptyText, { color: colors.textSecondary }]}>
            No alerts found.
          </Text>
        }
        stickySectionHeadersEnabled
      />
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1 },
  controls: {
    borderBottomWidth: 1,
    paddingHorizontal: 16,
    paddingVertical: 10,
    gap: 8,
  },
  filterRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 },
  chip: {
    paddingHorizontal: 12,
    paddingVertical: 5,
    borderRadius: 16,
    borderWidth: 1,
  },
  chipText: { fontSize: 12, fontWeight: '500' },
  actionRow: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    alignItems: 'center',
  },
  readFilterRow: { flexDirection: 'row', gap: 6 },
  readChip: {
    paddingHorizontal: 10,
    paddingVertical: 4,
    borderRadius: 12,
    borderWidth: 1,
    borderColor: 'transparent',
  },
  readChipText: { fontSize: 12 },
  markAll: { fontSize: 13, fontWeight: '500' },
  sectionHeader: {
    paddingHorizontal: 16,
    paddingVertical: 8,
    fontSize: 12,
    fontWeight: '600',
    textTransform: 'uppercase',
    letterSpacing: 0.5,
  },
  emptyText: { textAlign: 'center', marginTop: 40, fontSize: 14 },
});

Tip จากพี่: useMemo สองตัวนั้น (filtered + sections) สำคัญมาก ถ้าไม่ใส่ ทุกครั้งที่ render จะคำนวณใหม่ ซึ่งถ้า alerts มี 1,000 รายการ จะช้าพอให้รู้สึกได้


Step 7: Notification Preferences Screen

หน้านี้คือ “ห้องควบคุม” ของการแจ้งเตือน — ให้ user ตัดสินใจเองว่าจะรับแบบไหน

// src/screens/NotificationPreferencesScreen.tsx
import {
  View,
  Text,
  ScrollView,
  Switch,
  StyleSheet,
} from '@lynx-js/react';
import { useState, useEffect } from '@lynx-js/react';
import { useAlertStore } from '../stores/useAlertStore';
import { notificationService } from '../services/notifications';
import { Card } from '../components/ui/Card';
import { useTheme } from '../theme';
import type { NotificationPreferences } from '../services/notifications';

const SEVERITIES = ['low', 'medium', 'high', 'critical'] as const;

export default function NotificationPreferencesScreen() {
  const { colors } = useTheme();
  const { preferences, setPreferences } = useAlertStore();
  const [prefs, setLocalPrefs] = useState<NotificationPreferences>(preferences);

  useEffect(() => {
    notificationService.getPreferences().then(setLocalPrefs);
  }, []);

  const update = async (partial: Partial<NotificationPreferences>) => {
    const updated = { ...prefs, ...partial };
    setLocalPrefs(updated);
    setPreferences(updated);
    await notificationService.savePreferences(updated);
  };

  const toggleSeverity = async (sev: typeof SEVERITIES[number]) => {
    const current = prefs.severityFilter;
    const updated = current.includes(sev)
      ? current.filter((s) => s !== sev)
      : [...current, sev];
    await update({ severityFilter: updated });
  };

  return (
    <ScrollView
      style={[styles.container, { backgroundColor: colors.background }]}
      contentContainerStyle={styles.content}
    >
      {/* Master Toggle — ปิดตัวนี้ตัวเดียว ทุกอย่างหยุดหมด */}
      <Card title="Notifications" variant="elevated">
        <PrefRow
          label="Enable Notifications"
          description="Receive alerts from IoT devices"
          value={prefs.enabled}
          onToggle={(v) => update({ enabled: v })}
        />
      </Card>

      {/* Sound & Vibration */}
      <Card title="Feedback" variant="elevated">
        <PrefRow
          label="Sound"
          description="Play sound when alert received"
          value={prefs.sound}
          onToggle={(v) => update({ sound: v })}
          disabled={!prefs.enabled}
        />
        <PrefRow
          label="Vibration"
          description="Vibrate on critical alerts"
          value={prefs.vibration}
          onToggle={(v) => update({ vibration: v })}
          disabled={!prefs.enabled}
        />
      </Card>

      {/* Severity Filter */}
      <Card title="Alert Severity" variant="elevated">
        <Text style={[styles.note, { color: colors.textSecondary }]}>
          Select which severities to receive notifications for
        </Text>
        {SEVERITIES.map((sev) => (
          <PrefRow
            key={sev}
            label={sev.charAt(0).toUpperCase() + sev.slice(1)}
            value={prefs.severityFilter.includes(sev)}
            onToggle={() => toggleSeverity(sev)}
            disabled={!prefs.enabled}
          />
        ))}
      </Card>

      {/* Quiet Hours — ไม่รบกวนตอนนอน */}
      <Card title="Quiet Hours" variant="elevated">
        <PrefRow
          label="Enable Quiet Hours"
          description={`Silent from ${prefs.quietHoursStart} to ${prefs.quietHoursEnd}`}
          value={prefs.quietHoursEnabled}
          onToggle={(v) => update({ quietHoursEnabled: v })}
          disabled={!prefs.enabled}
        />
      </Card>
    </ScrollView>
  );
}

function PrefRow({
  label,
  description,
  value,
  onToggle,
  disabled = false,
}: {
  label: string;
  description?: string;
  value: boolean;
  onToggle: (v: boolean) => void;
  disabled?: boolean;
}) {
  const { colors } = useTheme();
  return (
    <View style={[styles.row, { borderBottomColor: colors.border }]}>
      <View style={styles.labelCol}>
        <Text style={[styles.label, { color: disabled ? colors.textTertiary : colors.text }]}>
          {label}
        </Text>
        {description && (
          <Text style={[styles.description, { color: colors.textSecondary }]}>
            {description}
          </Text>
        )}
      </View>
      <Switch
        value={value}
        onValueChange={onToggle}
        disabled={disabled}
        trackColor={{ false: colors.border, true: colors.primary }}
        thumbColor="#fff"
      />
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1 },
  content: { padding: 16 },
  note: { fontSize: 12, marginBottom: 8 },
  row: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    alignItems: 'center',
    paddingVertical: 14,
    borderBottomWidth: 1,
  },
  labelCol: { flex: 1, marginRight: 16 },
  label: { fontSize: 15, fontWeight: '500' },
  description: { fontSize: 12, marginTop: 2 },
});

Step 8: ทดสอบ Push Notification

ก่อนจะ deploy จริง เราทดสอบด้วย Expo Push API ได้เลย ไม่ต้องรอ backend:

# ส่ง test notification ผ่าน Expo Push API
curl -X POST https://exp.host/--/api/v2/push/send \
  -H "Content-Type: application/json" \
  -d '{
    "to": "ExponentPushToken[xxxxxxxxxxxxxxxxxx]",
    "title": "CRITICAL: Temperature Sensor A1",
    "body": "Temperature exceeded 45°C threshold (current: 47.3°C)",
    "data": {
      "alertId": "alert-001",
      "deviceId": "sensor-a1"
    },
    "sound": "default",
    "badge": 1
  }'

ถ้าทุกอย่าง setup ถูก มือถือจะดังขึ้น แตะแล้วไปหน้า AlertDetail เลย (ง •̀ω•́)ง


สรุปสิ่งที่เราทำในบทนี้

  +-----------------------------------------+
  |   Notifications & Alerts System ✓       |
  |                                         |
  |  [x] Push Notification + token reg      |
  |  [x] NotificationService (Singleton)    |
  |  [x] Sound / Vibration per severity     |
  |  [x] Alert Store (Zustand + immer)      |
  |  [x] AlertRow + Severity Badges         |
  |  [x] Alerts List (grouped by date)      |
  |  [x] Alert Detail + mark read/unread    |
  |  [x] Notification Preferences           |
  |  [x] Quiet Hours support               |
  +-----------------------------------------+

ระบบ alert ที่ดีคือระบบที่คนไม่รู้สึกรำคาญ แต่รู้สึกปลอดภัย — เหมือนสัญญาณเตือนไฟไหม้ที่ไม่ดังบ่อยจนคนชิน แต่เมื่อดัง ทุกคนวิ่งออกมาจริงๆ


Mobile Frontend Complete! ครบทั้ง 5 บทแล้ว

น้องๆ มาไกลมากนะ ตั้งแต่ setup จนมาถึง alerts system ครบวงจร:

BranchFeature
workshop/dev-10-lynxjs-setupProject setup, navigation, components, theme
workshop/dev-11-lynxjs-dashboardReal-time dashboard, WebSocket
workshop/dev-12-lynxjs-controlDevice control, sliders, command history
workshop/dev-13-lynxjs-chartsData visualization, CSV export
workshop/dev-14-lynxjs-alertsPush notifications, alerts management

Phase Mobile Frontend จบแล้ว! เดี๋ยวบทหน้าเราจะไปเริ่ม Admin Panel ด้วย Vite กัน — มาลุยกันต่อ!


Navigation: