Author SHA1 Message Date
harukin-expo-dev-env 292fb96106 feat: 新しい設定ファイルを追加し、Sentryの初期化を改善 2026-08-20 05:05:26 +00:00
harukin-expo-dev-env c0be3941fb feat: APIエンドポイントを定数化し、SpecialTrainInfoBoxコンポーネントでの使用を更新
refactor: NewsReleaseInfoBoxコンポーネントのスタイルをレスポンシブに調整
2026-08-19 15:18:59 +00:00
harukin-expo-dev-env c2228a0e0f feat: R2CurrentTrainDBインターフェースを追加し、API呼び出しのデータ型を更新 2026-08-19 14:21:20 +00:00
harukin-expo-dev-env 3684084185 feat: APIエンドポイントを動的に設定し、現在の列車位置情報を取得するロジックを追加 2026-08-18 14:57:15 +00:00
harukin-expo-dev-env e02ad09f64 fix: API呼び出しのリトライロジックを改善し、ローディングインジケーターを追加 2026-08-18 05:09:19 +00:00
harukin-expo-dev-env 7a0a3a7ea6 Refactor code structure for improved readability and maintainability 2026-08-18 02:33:30 +00:00
harukin-expo-dev-env 20c18713e2 refactor: replace Date with dayjs for consistent date handling across components
- Updated FelicaHistoryPage to use dayjs for formatting scannedAt timestamps.
- Refactored ResearchToolsSettings to utilize dayjs for date formatting.
- Modified VoicepeakDebugLogSection to replace Date with dayjs for createdAt timestamps.
- Changed settings component to use dayjs for scannedAt timestamps.
- Updated ExGridSimpleView and ExGridView to use dayjs for time parsing and formatting.
- Refactored ExGridSimpleViewItem and ExGridViewItem to utilize dayjs for time formatting.
- Adjusted ListView and ListViewItem to use dayjs for time handling.
- Updated EachData to use dayjs for train time calculations.
- Refactored LED_vidion to remove redundant getServiceMinute function.
- Replaced Date with dayjs in trainRecorder for exportedAt timestamps.
- Updated appLifecycleCrashSentinel to use dayjs for timestamp handling.
- Refactored operationInfoSchedule to use dayjs for fetchedAt timestamp.
- Updated trainTimeFiltering to utilize dayjs for time comparisons.
- Refactored useWebViewRemount to handle timeouts with dayjs.
- Updated voicepeak and voicepeakDebugLog to use dayjs for timestamp handling.
- Introduced timeUtils module for consistent time parsing and formatting using dayjs.
2026-08-16 09:23:54 +00:00
harukin-expo-dev-env d03b804feb fix: APIエンドポイントを更新し、遅延情報の取得方法を改善 2026-08-13 00:39:28 +00:00
harukin-expo-dev-env 7990361d04 Merge commit '96477296fa9e6de24c10a6d83a949c25599348e9' into feature/migrate-api 2026-08-10 01:57:46 +00:00
harukin-expo-dev-env 96477296fa fix: CommonTypesからeachTrainDiagramTypeをインポートするように修正 2026-08-10 01:57:34 +00:00
harukin-expo-dev-env 4b5a8d44fb Merge commit 'ab71fab73b87dad991262c2e6fcbf1036020dcc8' into develop 2026-08-10 01:50:10 +00:00
harukin-expo-dev-env ab71fab73b fix: .gitignoreに.agents-noteを追加 2026-08-09 16:49:58 +00:00
harukin-expo-dev-env 5c97ff0c6b fix: お気に入り駅の並び替え機能を改善し、関連コードを整理 2026-08-09 16:18:54 +00:00
harukin-expo-dev-env 30b8b37343 fix: 津島ノ宮駅の表示ロジックを削除し、関連コードを整理 2026-08-06 08:06:34 +00:00
harukin-expo-dev-env 39d2be704e Refactor favorite station management and normalization
- Removed direct usage of favorite station state in multiple components (MenuPage, StationDetailView, WebView, FavoriteList, FavoriteSettings).
- Introduced utility functions for handling favorite station keys and normalization.
- Updated favorite station context to include methods for adding, removing, and toggling favorite stations.
- Implemented normalization logic to ensure consistency with the current station master.
- Added documentation for the favorite station normalization plan.
- Improved error handling and data validation for favorite stations.
2026-08-06 07:42:25 +00:00
harukin-expo-dev-env 9fb7b1ef09 fix: 対象列車0件時にLED表示を更新 2026-08-06 01:50:32 +00:00
harukin-expo-dev-env 3a7e2c060c fix: 津島ノ宮駅のGoogleマップURLを修正 2026-08-03 23:29:35 +00:00
harukin-expo-dev-env 0207a728e8 fix: 津島ノ宮駅の表示ロジックを追加し、駅間の位置データを正しく表示 2026-08-03 22:52:57 +00:00
harukin-expo-dev-env 8059261b68 津島ノ宮開放 2026-08-03 21:27:06 +00:00
harukin-expo-dev-env 26090ff486 feat: Android列車位置通知をR2へ移行 2026-07-30 15:24:41 +00:00
harukin-expo-dev-env 11cbb586be docs: areaInfoの観察方針を記録 2026-07-30 15:24:33 +00:00
harukin-expo-dev-env a3707f2970 feat: 運行情報をR2スナップショットへ移行 2026-07-30 15:24:23 +00:00
74 changed files with 1979 additions and 773 deletions
+1
View File
@@ -61,3 +61,4 @@ ios/
*.apk *.apk
*.aab *.aab
.env.local .env.local
.agents-note
+16
View File
@@ -0,0 +1,16 @@
{
"mcp": {
"excluded": [
"Sentry"
]
},
"$version": 4,
"permissions": {
"allow": [
"Read(//home/ubuntu/.qwen/debug/**)",
"Bash(curl *)",
"mcp__sentry__search_events",
"mcp__sentry__find_organizations"
]
}
}
+41 -15
View File
@@ -34,7 +34,7 @@ import {
} from "./lib/rootNavigation"; } from "./lib/rootNavigation";
import { AppThemeProvider } from "./lib/theme"; import { AppThemeProvider } from "./lib/theme";
import StatusbarDetect from "./StatusbarDetect"; import StatusbarDetect from "./StatusbarDetect";
import * as Sentry from '@sentry/react-native'; import * as Sentry from "@sentry/react-native";
import { import {
startAppLifecycleCrashSentinel, startAppLifecycleCrashSentinel,
stopAppLifecycleCrashSentinel, stopAppLifecycleCrashSentinel,
@@ -42,7 +42,7 @@ import {
import { migrateLegacyVoicepeakSettings } from "./lib/migrateLegacyVoicepeakSettings"; import { migrateLegacyVoicepeakSettings } from "./lib/migrateLegacyVoicepeakSettings";
Sentry.init({ Sentry.init({
dsn: 'https://1090312e4cf501f5a455d523eff2d538@o4511646874664960.ingest.us.sentry.io/4511646880432128', dsn: "https://1090312e4cf501f5a455d523eff2d538@o4511646874664960.ingest.us.sentry.io/4511646880432128",
// Adds more context data to events (IP address, cookies, user, etc.) // Adds more context data to events (IP address, cookies, user, etc.)
// For more information, visit: https://docs.sentry.io/platforms/react-native/data-management/data-collected/ // For more information, visit: https://docs.sentry.io/platforms/react-native/data-management/data-collected/
@@ -54,7 +54,10 @@ Sentry.init({
// Configure Session Replay // Configure Session Replay
replaysSessionSampleRate: 0.1, replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1, replaysOnErrorSampleRate: 1,
integrations: [Sentry.mobileReplayIntegration(), Sentry.feedbackIntegration()], integrations: [
Sentry.mobileReplayIntegration(),
Sentry.feedbackIntegration(),
],
tracesSampleRate: __DEV__ ? 1.0 : 0.05, tracesSampleRate: __DEV__ ? 1.0 : 0.05,
@@ -147,11 +150,14 @@ export default Sentry.wrap(function App() {
const navigateWhenReady = ( const navigateWhenReady = (
callback: () => void, callback: () => void,
url: string, url: string,
retryCount = 0 retryCount = 0,
) => { ) => {
if (!rootNavigationRef.isReady()) { if (!rootNavigationRef.isReady()) {
if (retryCount < 8) { if (retryCount < 8) {
setTimeout(() => navigateWhenReady(callback, url, retryCount + 1), 250); setTimeout(
() => navigateWhenReady(callback, url, retryCount + 1),
250,
);
} }
return; return;
} }
@@ -172,44 +178,64 @@ export default Sentry.wrap(function App() {
} }
if (normalized.includes("open/traininfo")) { if (normalized.includes("open/traininfo")) {
markStartupExplicitTarget(); markStartupExplicitTarget();
navigateWhenReady(() => { navigateWhenReady(
() => {
stackAwareNavigate("topMenu", { screen: "menu" }); stackAwareNavigate("topMenu", { screen: "menu" });
setTimeout(() => { setTimeout(() => {
SheetManager.show("JRSTraInfo"); SheetManager.show("JRSTraInfo");
}, 450); }, 450);
}, url, retryCount); },
url,
retryCount,
);
return true; return true;
} }
if (normalized.includes("open/operation")) { if (normalized.includes("open/operation")) {
markStartupExplicitTarget(); markStartupExplicitTarget();
navigateWhenReady(() => { navigateWhenReady(
() => {
stackAwareNavigate("information"); stackAwareNavigate("information");
}, url, retryCount); },
url,
retryCount,
);
return true; return true;
} }
if (normalized.includes("open/settings")) { if (normalized.includes("open/settings")) {
markStartupExplicitTarget(); markStartupExplicitTarget();
navigateWhenReady(() => { navigateWhenReady(
() => {
stackAwareNavigate("topMenu", { stackAwareNavigate("topMenu", {
screen: "setting", screen: "setting",
}); });
}, url, retryCount); },
url,
retryCount,
);
return true; return true;
} }
if (normalized.includes("open/topmenu")) { if (normalized.includes("open/topmenu")) {
markStartupExplicitTarget(); markStartupExplicitTarget();
navigateWhenReady(() => { navigateWhenReady(
() => {
stackAwareNavigate("topMenu", { stackAwareNavigate("topMenu", {
screen: "menu", screen: "menu",
}); });
}, url, retryCount); },
url,
retryCount,
);
return true; return true;
} }
if (normalized.includes("positions/apps")) { if (normalized.includes("positions/apps")) {
markStartupExplicitTarget(); markStartupExplicitTarget();
navigateWhenReady(() => { navigateWhenReady(
() => {
stackAwareNavigate("positions"); stackAwareNavigate("positions");
}, url, retryCount); },
url,
retryCount,
);
return true; return true;
} }
-18
View File
@@ -15,7 +15,6 @@ import HowTo from "@/howto";
import { Menu } from "@/menu"; import { Menu } from "@/menu";
import News from "@/components/news"; import News from "@/components/news";
import Setting from "@/components/Settings/settings"; import Setting from "@/components/Settings/settings";
import { useFavoriteStation } from "@/stateBox/useFavoriteStation";
import { optionData } from "@/lib/stackOption"; import { optionData } from "@/lib/stackOption";
import { AllTrainDiagramView } from "@/components/AllTrainDiagramView"; import { AllTrainDiagramView } from "@/components/AllTrainDiagramView";
import { useNavigation, useIsFocused } from "@react-navigation/native"; import { useNavigation, useIsFocused } from "@react-navigation/native";
@@ -27,7 +26,6 @@ import * as Sentry from "@sentry/react-native";
const Stack = createStackNavigator(); const Stack = createStackNavigator();
export function MenuPage() { export function MenuPage() {
const { favoriteStation, setFavoriteStation } = useFavoriteStation();
const { height, width } = useWindowDimensions(); const { height, width } = useWindowDimensions();
const { verticalScale } = useResponsive(); const { verticalScale } = useResponsive();
const tabBarHeight = useBottomTabBarHeight(); const tabBarHeight = useBottomTabBarHeight();
@@ -92,7 +90,6 @@ export function MenuPage() {
}, [height, isFocused, mapMode, width]); }, [height, isFocused, mapMode, width]);
const [mapHeight, setMapHeight] = useState(0); const [mapHeight, setMapHeight] = useState(0);
const mapHeightRef = useRef(0); const mapHeightRef = useRef(0);
const favoriteStationRef = useRef(favoriteStation);
useEffect(() => { useEffect(() => {
const MapHeight = const MapHeight =
height - height -
@@ -103,9 +100,6 @@ export function MenuPage() {
setMapHeight(MapHeight); setMapHeight(MapHeight);
mapHeightRef.current = MapHeight; mapHeightRef.current = MapHeight;
}, [height, tabBarHeight, width]); }, [height, tabBarHeight, width]);
useEffect(() => {
favoriteStationRef.current = favoriteStation;
}, [favoriteStation]);
const [MapFullHeight, setMapFullHeight] = useState(0); const [MapFullHeight, setMapFullHeight] = useState(0);
useEffect(() => { useEffect(() => {
const MapFullHeight = const MapFullHeight =
@@ -138,18 +132,6 @@ export function MenuPage() {
animated: true, animated: true,
}); });
setMapMode(false); setMapMode(false);
AS.getItem(STORAGE_KEYS.FAVORITE_STATION)
.then((d) => {
const returnData = JSON.parse(d);
if (favoriteStationRef.current.toString() != d) {
setFavoriteStation(returnData);
}
})
.catch((error) => {
if (__DEV__) {
logger.warn("お気に入り駅の読み込みに失敗しました:", error);
}
});
}); });
return unsubscribe; return unsubscribe;
+3 -3
View File
@@ -8,7 +8,7 @@
"android", "android",
"web" "web"
], ],
"version": "7.1.0", "version": "7.2",
"userInterfaceStyle": "automatic", "userInterfaceStyle": "automatic",
"orientation": "default", "orientation": "default",
"icon": "./assets/icons/s8600.png", "icon": "./assets/icons/s8600.png",
@@ -24,7 +24,7 @@
"**/*" "**/*"
], ],
"ios": { "ios": {
"buildNumber": "67", "buildNumber": "68",
"supportsTablet": true, "supportsTablet": true,
"bundleIdentifier": "jrshikokuinfo.xprocess.hrkn", "bundleIdentifier": "jrshikokuinfo.xprocess.hrkn",
"appleTeamId": "54CRDT797G", "appleTeamId": "54CRDT797G",
@@ -54,7 +54,7 @@
}, },
"android": { "android": {
"package": "jrshikokuinfo.xprocess.hrkn", "package": "jrshikokuinfo.xprocess.hrkn",
"versionCode": 33, "versionCode": 34,
"intentFilters": [ "intentFilters": [
{ {
"action": "VIEW", "action": "VIEW",
@@ -1,6 +1,7 @@
import React, { FC } from "react"; import React, { FC } from "react";
import { View, Text, TouchableWithoutFeedback } from "react-native"; import { View, Text, TouchableWithoutFeedback } from "react-native";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { setClockTime } from "@/lib/timeUtils";
import lineColorList from "../../../assets/originData/lineColorList"; import lineColorList from "../../../assets/originData/lineColorList";
import { trainDataType } from "@/lib/trainPositionTextArray"; import { trainDataType } from "@/lib/trainPositionTextArray";
import { getStopListColors } from "./colorScheme"; import { getStopListColors } from "./colorScheme";
@@ -423,10 +424,11 @@ type StationTimeBoxType = {
const StationTimeBox: FC<StationTimeBoxType> = (props) => { const StationTimeBox: FC<StationTimeBoxType> = (props) => {
const { delay, textColor, seType, se, time, isDouble, isBefore } = props; const { delay, textColor, seType, se, time, isDouble, isBefore } = props;
const { fontScale, moderateScale } = useResponsive(); const { fontScale, moderateScale } = useResponsive();
const dates = dayjs() const dates = setClockTime(
.set("hour", parseInt(time.split(":")[0])) dayjs(),
.set("minute", parseInt(time.split(":")[1])) time,
.add(delay == "入線" || delay == undefined ? 0 : delay, "minute"); delay == "入線" || delay == undefined ? 0 : delay
);
return ( return (
<Text <Text
@@ -438,7 +440,7 @@ const StationTimeBox: FC<StationTimeBoxType> = (props) => {
fontStyle: seType == "community" ? "italic" : "normal", fontStyle: seType == "community" ? "italic" : "normal",
}} }}
> >
{se.includes("通") && time == "" ? "レ" : dates.format("HH:mm")} {se.includes("通") && time == "" ? "レ" : dates?.format("HH:mm") ?? ""}
</Text> </Text>
); );
}; };
@@ -0,0 +1,142 @@
import React, { FC, useEffect } from "react";
import {
View,
Platform,
Text,
TouchableOpacity,
Linking,
useWindowDimensions,
} from "react-native";
import ActionSheet from "react-native-actions-sheet";
import { ScrollView } from "react-native-actions-sheet";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { NewsReleaseInfoBox } from "../Menu/NewsReleaseInfoBox";
import { useThemeColors } from "@/lib/theme";
import { useResponsive } from "@/lib/responsive";
type props = {
payload: { navigate: (screen: string, params?: object) => void };
};
export const NewsReleaseInfo: FC<props> = ({ payload }) => {
if (!payload) return <></>;
const actionSheetRef = React.useRef<any>(null);
const scrollRef = React.useRef<any>(null);
const insets = useSafeAreaInsets();
const windowDimen = useWindowDimensions();
// シートの高さ制御: shortSide >= 600 の場合はデフォルト、手机のみ画面の80%固定
const sheetHeight = windowDimen.height * 0.8;
useEffect(() => {
if (Platform.OS === "android") {
const backAction = () => true;
const backHandler = require("react-native").BackHandler.addEventListener(
"hardwareBackPress",
backAction
);
return () => backHandler.remove();
}
}, []);
return (
<ActionSheet
gestureEnabled={true}
CustomHeaderComponent={<></>}
ref={actionSheetRef}
isModal={Platform.OS === "ios" && !Platform.isPad}
containerStyle={{
...(Platform.OS == "android" ? { paddingBottom: insets.bottom } : {}),
height: sheetHeight,
borderTopLeftRadius: 5,
borderTopRightRadius: 5,
}}
useBottomSafeAreaPadding={Platform.OS == "android"}
>
<NewsReleaseInfoContent payload={payload} scrollRef={scrollRef} sheetHeight={sheetHeight} />
</ActionSheet>
);
};
type contentProps = {
payload: { navigate: (screen: string, params?: object) => void };
scrollRef: React.RefObject<any>;
sheetHeight: number;
};
const NewsReleaseInfoContent: FC<contentProps> = ({ payload: { navigate }, scrollRef, sheetHeight }) => {
const { colors, fixed } = useThemeColors();
const { fontScale, moderateScale, verticalScale } = useResponsive();
return (
<View
style={{
backgroundColor: fixed.primary,
borderTopLeftRadius: 5,
borderTopRightRadius: 5,
}}
>
{/* ドラッグハンドル */}
<View style={{ height: verticalScale(26), width: "100%", backgroundColor: fixed.primary }}>
<View
style={{
height: verticalScale(6),
width: moderateScale(45),
borderRadius: 100,
backgroundColor: colors.borderLight,
marginVertical: 10,
alignSelf: "center",
}}
/>
</View>
{/* タイトル(タップでスクロールトップ) */}
<View
onTouchStart={() => scrollRef.current?.scrollTo({ y: 0, animated: true })}
style={{ padding: 10, flexDirection: "row", alignItems: "center", backgroundColor: fixed.primary }}
>
<Text style={{ fontSize: fontScale(30), fontWeight: "bold", color: fixed.textOnPrimary }}>
</Text>
</View>
{/* スクロール領域(ニュースコンテンツのみ) */}
<ScrollView
ref={scrollRef}
bounces={false}
nestedScrollEnabled
style={{height: sheetHeight-verticalScale(26 + 10 + 30 + 10 + 10 + 12 + 10 + 12 + 10+50)}}
>
<NewsReleaseInfoBox navigate={navigate} />
</ScrollView>
{/* 下部固定ボタン(ScrollViewの外、画面の下固定) */}
<View style={{ padding: 10, backgroundColor: fixed.primary }}>
<TouchableOpacity
onPress={() => Linking.openURL("https://www.jr-shikoku.co.jp/03_news/press/")}
style={{
paddingVertical: 12,
paddingLeft: 14,
paddingRight: 18,
flexDirection: "row",
borderColor: fixed.textOnPrimary,
borderWidth: 1,
borderRadius: 5,
backgroundColor: fixed.primary,
alignItems: "center",
justifyContent:"center",
width: "100%",
}}
>
<MaterialCommunityIcons name="web" color={fixed.textOnPrimary} size={30} />
<View style={{ flex: 1 }} />
<Text style={{ fontSize: fontScale(25), fontWeight: "bold", color: fixed.textOnPrimary }}>
</Text>
<View style={{ flex: 1 }} />
</TouchableOpacity>
</View>
</View>
);
};
@@ -6,9 +6,10 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { SpecialTrainInfoBox } from "../Menu/SpecialTrainInfoBox"; import { SpecialTrainInfoBox } from "../Menu/SpecialTrainInfoBox";
import { useThemeColors } from "@/lib/theme"; import { useThemeColors } from "@/lib/theme";
import { useSheetMaxHeight } from "./useSheetMaxHeight"; import { useSheetMaxHeight } from "./useSheetMaxHeight";
import { NavigateFunction } from "@/types";
type props = { type props = {
payload: { navigate: (screen: string, params?: object) => void }; payload: { navigate: NavigateFunction };
}; };
export const SpecialTrainInfo: FC<props> = ({ payload }) => { export const SpecialTrainInfo: FC<props> = ({ payload }) => {
const { navigate } = payload; const { navigate } = payload;
@@ -24,9 +25,7 @@ export const SpecialTrainInfo: FC<props> = ({ payload }) => {
ref={actionSheetRef} ref={actionSheetRef}
isModal={Platform.OS === "ios" && !Platform.isPad} isModal={Platform.OS === "ios" && !Platform.isPad}
containerStyle={{ containerStyle={{
...(Platform.OS == "android" ...(Platform.OS == "android" ? { paddingBottom: insets.bottom } : {}),
? { paddingBottom: insets.bottom }
: {}),
...(maxHeight != null ? { maxHeight } : {}), ...(maxHeight != null ? { maxHeight } : {}),
}} }}
useBottomSafeAreaPadding={Platform.OS == "android"} useBottomSafeAreaPadding={Platform.OS == "android"}
@@ -40,7 +39,9 @@ export const SpecialTrainInfo: FC<props> = ({ payload }) => {
borderWidth: 1, borderWidth: 1,
}} }}
> >
<View style={{ height: 26, width: "100%", backgroundColor: fixed.primary }}> <View
style={{ height: 26, width: "100%", backgroundColor: fixed.primary }}
>
<View <View
style={{ style={{
height: 6, height: 6,
@@ -29,6 +29,7 @@ import { useSheetMaxHeight } from "./useSheetMaxHeight";
export const StationDeteilView = (props) => { export const StationDeteilView = (props) => {
if (!props.payload) return <></>; if (!props.payload) return <></>;
const { currentStation, navigate, onExit, goTo, useShow } = props.payload; const { currentStation, navigate, onExit, goTo, useShow } = props.payload;
const station = currentStation?.[0];
const { width } = useWindowDimensions(); const { width } = useWindowDimensions();
const { verticalScale, moderateScale } = useResponsive(); const { verticalScale, moderateScale } = useResponsive();
const { busAndTrainData } = useBusAndTrainData(); const { busAndTrainData } = useBusAndTrainData();
@@ -36,38 +37,35 @@ export const StationDeteilView = (props) => {
const { colors } = useThemeColors(); const { colors } = useThemeColors();
useEffect(() => { useEffect(() => {
if (!currentStation) return () => {}; if (!station) return;
const data = busAndTrainData.filter( const data = busAndTrainData.filter((d) => d.name === station.Station_JP);
(d) => d.name === currentStation[0].Station_JP
);
if (data.length == 0) { if (data.length == 0) {
setTrainBus(undefined); setTrainBus(undefined);
} }
setTrainBus(data[0]); setTrainBus(data[0]);
}, [currentStation, busAndTrainData]); }, [station, busAndTrainData]);
const [usePDFView, setUsePDFView] = useState(undefined); const [usePDFView, setUsePDFView] = useState<"true" | "false">("false");
useEffect(() => { useEffect(() => {
AS.getItem(STORAGE_KEYS.USE_PDF_VIEW) AS.getItem(STORAGE_KEYS.USE_PDF_VIEW)
.then(setUsePDFView) .then(setUsePDFView)
.catch(() => setUsePDFView("false")); .catch(() => setUsePDFView("false"));
}, []); }, []);
const info = const info =
currentStation && station &&
(currentStation[0].StationTimeTable.match(".pdf") (station.StationTimeTable.match(".pdf")
? getPDFViewURL(currentStation[0].StationTimeTable) ? getPDFViewURL(station.StationTimeTable)
: currentStation[0].StationTimeTable); : station.StationTimeTable);
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const maxHeight = useSheetMaxHeight(); const maxHeight = useSheetMaxHeight();
if (!station) return null;
return ( return (
<ActionSheet <ActionSheet
gestureEnabled gestureEnabled
CustomHeaderComponent={<></>} CustomHeaderComponent={<></>}
isModal={Platform.OS === "ios" && !Platform.isPad} isModal={Platform.OS === "ios" && !Platform.isPad}
containerStyle={{ containerStyle={{
...(Platform.OS == "android" ...(Platform.OS == "android" ? { paddingBottom: insets.bottom } : {}),
? { paddingBottom: insets.bottom }
: {}),
...(maxHeight != null ? { maxHeight } : {}), ...(maxHeight != null ? { maxHeight } : {}),
}} }}
useBottomSafeAreaPadding={Platform.OS == "android"} useBottomSafeAreaPadding={Platform.OS == "android"}
@@ -96,14 +94,14 @@ export const StationDeteilView = (props) => {
/> />
</View> </View>
<View> <View>
{currentStation && ( {
<> <>
<View style={{ margin: 10, marginHorizontal: width * 0.1 }}> <View style={{ margin: 10, marginHorizontal: width * 0.1 }}>
<Sign <Sign
stationID={currentStation[0].StationNumber} stationID={station.StationNumber}
oP={() => { oP={() => {
usePDFView == "true" usePDFView == "true"
? Linking.openURL(currentStation[0].StationTimeTable) ? Linking.openURL(station.StationTimeTable)
: navigate("howto", { : navigate("howto", {
info, info,
goTo, goTo,
@@ -111,33 +109,30 @@ export const StationDeteilView = (props) => {
}); });
onExit(); onExit();
}} }}
oLP={() => oLP={() => Linking.openURL(station.StationTimeTable)}
Linking.openURL(currentStation[0].StationTimeTable)
}
/> />
</View> </View>
<View style={{ flexDirection: "row" }}> <View style={{ flexDirection: "row" }}>
<StationTrainPositionButton <StationTrainPositionButton
stationNumber={currentStation[0].StationNumber} stationNumber={station.StationNumber}
onExit={onExit} onExit={onExit}
navigate={navigate} navigate={navigate}
/> />
{currentStation[0].JrHpUrl && {station.JrHpUrl && station.StationNumber != "M12" && (
currentStation[0].StationNumber != "M12" && (
<駅構内図 //児島例外/ <駅構内図 //児島例外/
navigate={navigate} navigate={navigate}
goTo={goTo} goTo={goTo}
useShow={useShow} useShow={useShow}
address={currentStation[0].JrHpUrl} address={station.JrHpUrl}
onExit={onExit} onExit={onExit}
/> />
)} )}
</View> </View>
<View style={{ flexDirection: "row" }}> <View style={{ flexDirection: "row" }}>
{!currentStation[0].JrHpUrl || ( {!station.JrHpUrl || (
<WebSiteButton <WebSiteButton
navigate={navigate} navigate={navigate}
info={currentStation[0].JrHpUrl} info={station.JrHpUrl}
goTo={goTo} goTo={goTo}
useShow={useShow} useShow={useShow}
onExit={onExit} onExit={onExit}
@@ -148,10 +143,10 @@ export const StationDeteilView = (props) => {
onExit={onExit} onExit={onExit}
currentStation={currentStation} currentStation={currentStation}
/> />
{!currentStation[0].StationTimeTable || ( {!station.StationTimeTable || (
<StationTimeTableButton <StationTimeTableButton
info={info} info={info}
address={currentStation[0].StationTimeTable} address={station.StationTimeTable}
usePDFView={usePDFView} usePDFView={usePDFView}
navigate={navigate} navigate={navigate}
onExit={onExit} onExit={onExit}
@@ -161,8 +156,8 @@ export const StationDeteilView = (props) => {
)} )}
<StationMapButton <StationMapButton
stationMap={ stationMap={
currentStation[0].StationMap || station.StationMap ||
`https://www.google.co.jp/maps/place/${currentStation[0].lat},${currentStation[0].lng}` `https://www.google.co.jp/maps/place/${station.lat},${station.lng}`
} }
/> />
{!trainBus || ( {!trainBus || (
@@ -180,7 +175,7 @@ export const StationDeteilView = (props) => {
)} )}
</View> </View>
</> </>
)} }
</View> </View>
</View> </View>
</ActionSheet> </ActionSheet>
@@ -195,7 +190,7 @@ const Handler = () => {
}; };
const backHandler = BackHandler.addEventListener( const backHandler = BackHandler.addEventListener(
"hardwareBackPress", "hardwareBackPress",
backAction backAction,
); );
return () => backHandler.remove(); return () => backHandler.remove();
}, []); }, []);
@@ -27,6 +27,7 @@ import {
} from "@/lib/elesiteTrainOrder"; } from "@/lib/elesiteTrainOrder";
import ViewShot from "react-native-view-shot"; import ViewShot from "react-native-view-shot";
import * as Sharing from "expo-sharing"; import * as Sharing from "expo-sharing";
import dayjs from "dayjs";
export type TrainDataSourcesPayload = { export type TrainDataSourcesPayload = {
trainNum: string; trainNum: string;
@@ -59,14 +60,8 @@ const ELESITE_LOGO_PNG = require("@/assets/relationLogo/elesite_logo.jpg");
/** ISO 8601 日時文字列を "HH:MM" 形式にフォーマット */ /** ISO 8601 日時文字列を "HH:MM" 形式にフォーマット */
const formatHHMM = (iso: string): string => { const formatHHMM = (iso: string): string => {
try { const parsed = dayjs(iso);
const d = new Date(iso); return parsed.isValid() ? parsed.format("HH:mm") : "";
const h = d.getHours().toString().padStart(2, "0");
const m = d.getMinutes().toString().padStart(2, "0");
return `${h}:${m}`;
} catch {
return "";
}
}; };
/** /**
@@ -74,17 +69,8 @@ const formatHHMM = (iso: string): string => {
* ISO 8601 文字列にも対応 * ISO 8601 文字列にも対応
*/ */
const formatDateHHMM = (datetime: string): string => { const formatDateHHMM = (datetime: string): string => {
try { const parsed = dayjs(datetime.replace(" ", "T"));
// "YYYY-MM-DD HH:MM:SS" → space を T に置換して安全にパース return parsed.isValid() ? parsed.format("M/D HH:mm") : "";
const d = new Date(datetime.replace(" ", "T"));
const mo = d.getMonth() + 1;
const day = d.getDate();
const h = d.getHours().toString().padStart(2, "0");
const m = d.getMinutes().toString().padStart(2, "0");
return `${mo}/${day} ${h}:${m}`;
} catch {
return "";
}
}; };
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
@@ -537,7 +523,7 @@ export const TrainDataSources: FC<{ payload?: TrainDataSourcesPayload }> = ({
})); }));
// 投稿日時が今日でない場合はカードを薄く表示("YYYY-MM-DD HH:MM:SS" 形式) // 投稿日時が今日でない場合はカードを薄く表示("YYYY-MM-DD HH:MM:SS" 形式)
const todayDateStr = new Date().toLocaleDateString("sv"); // "YYYY-MM-DD" const todayDateStr = dayjs().format("YYYY-MM-DD");
const isUnyohubStale = const isUnyohubStale =
unyohubLastPostedDatetime == null || unyohubLastPostedDatetime == null ||
!unyohubLastPostedDatetime.startsWith(todayDateStr); !unyohubLastPostedDatetime.startsWith(todayDateStr);
+1
View File
@@ -10,5 +10,6 @@ declare module "react-native-actions-sheet" {
SpecialTrainInfo: SheetDefinition<{ payload: any }>; SpecialTrainInfo: SheetDefinition<{ payload: any }>;
Social: SheetDefinition; Social: SheetDefinition;
TrainDataSources: SheetDefinition; TrainDataSources: SheetDefinition;
NewsReleaseInfo: SheetDefinition<{ payload: any }>;
} }
} }
+4 -2
View File
@@ -1,4 +1,4 @@
import { registerSheet, SheetDefinition } from "react-native-actions-sheet"; import { registerSheet, SheetManager, SheetDefinition } from "react-native-actions-sheet";
import { EachTrainInfo } from "./EachTrainInfo"; import { EachTrainInfo } from "./EachTrainInfo";
import { JRSTraInfo } from "./JRSTraInfo"; import { JRSTraInfo } from "./JRSTraInfo";
import { StationDeteilView } from "./StationDeteilView"; import { StationDeteilView } from "./StationDeteilView";
@@ -6,6 +6,7 @@ import { TrainMenuLineSelector } from "./TrainMenuLineSelector";
import { TrainIconUpdate } from "./TrainIconUpdate"; import { TrainIconUpdate } from "./TrainIconUpdate";
import { SpecialTrainInfo } from "./SpecialTrainInfo"; import { SpecialTrainInfo } from "./SpecialTrainInfo";
import { Social } from "./SocialMenu"; import { Social } from "./SocialMenu";
import { NewsReleaseInfo } from "./NewsReleaseInfo";
import { TrainDataSources } from "./TrainDataSources"; import { TrainDataSources } from "./TrainDataSources";
registerSheet("EachTrainInfo", EachTrainInfo); registerSheet("EachTrainInfo", EachTrainInfo);
@@ -16,8 +17,9 @@ registerSheet("TrainIconUpdate", TrainIconUpdate);
registerSheet("SpecialTrainInfo", SpecialTrainInfo); registerSheet("SpecialTrainInfo", SpecialTrainInfo);
registerSheet("Social", Social); registerSheet("Social", Social);
registerSheet("TrainDataSources", TrainDataSources); registerSheet("TrainDataSources", TrainDataSources);
registerSheet("NewsReleaseInfo", NewsReleaseInfo);
export {}; export { SheetManager, NewsReleaseInfo };
declare module "react-native-actions-sheet" { declare module "react-native-actions-sheet" {
interface Sheets { interface Sheets {
@@ -3,7 +3,7 @@ import { useWindowDimensions } from "react-native";
/** /**
* スマホ(短辺 < 600dp)のみ maxHeight を返す。タブレットでは undefined。 * スマホ(短辺 < 600dp)のみ maxHeight を返す。タブレットでは undefined。
*/ */
export function useSheetMaxHeight(ratio = 0.7): number | undefined { export function useSheetMaxHeight(ratio = .85): number | undefined {
const { width, height } = useWindowDimensions(); const { width, height } = useWindowDimensions();
const shortSide = Math.min(width, height); const shortSide = Math.min(width, height);
if (shortSide >= 600) return undefined; // タブレット if (shortSide >= 600) return undefined; // タブレット
+14 -11
View File
@@ -6,21 +6,24 @@ import {
} from "react-native-android-widget"; } from "react-native-android-widget";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { WidgetColors, widgetLightColors } from "./widget-theme"; import { WidgetColors, widgetLightColors } from "./widget-theme";
import { API_ENDPOINTS } from "@/constants";
import type { OperationInfoSnapshot } from "@/types";
export const getInfoString = async () => { export const getInfoString = async () => {
// Fetch data from the server
const time = dayjs().format("HH:mm"); const time = dayjs().format("HH:mm");
const text = await fetch( const response = await fetch(API_ENDPOINTS.OPERATION_INFO, {
"https://script.google.com/macros/s/AKfycbz80LcaEUrhnlEsLkJy0LG2IRO3DBVQhfNmN1d_0f_HvtsujNQpxM90SrV9yKWH_JG1Ww/exec" cache: "no-store",
)
.then((response) => response.text())
.then((data) => {
if (data !== "") {
return data.split("^");
}
return null;
}); });
//ToastAndroid.show(`${text}`, ToastAndroid.SHORT); if (!response.ok) {
throw new Error(
`Operation information request failed: ${response.status}`
);
}
const snapshot = (await response.json()) as OperationInfoSnapshot;
const operationInfoText = snapshot.compatibility?.operationInfoText ?? "";
const text = operationInfoText === "" ? null : operationInfoText.split("^");
return { time, text }; return { time, text };
}; };
+2 -3
View File
@@ -6,13 +6,12 @@ import {
} from "react-native-android-widget"; } from "react-native-android-widget";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { WidgetColors, widgetLightColors } from "./widget-theme"; import { WidgetColors, widgetLightColors } from "./widget-theme";
import { API_ENDPOINTS } from "@/constants";
export const getDelayData = async () => { export const getDelayData = async () => {
// Fetch data from the server // Fetch data from the server
const time = dayjs().format("HH:mm"); const time = dayjs().format("HH:mm");
const delayString = await fetch( const delayString = await fetch(API_ENDPOINTS.DELAY_INFO_LEGACY)
"https://script.google.com/macros/s/AKfycbw-0RDLAu8EQAEWA860tk4KVW6VOr3iIU900AcWEfqIP16gtNUG1XO_A3oBfAGiNeCf/exec"
)
.then((response) => response.text()) .then((response) => response.text())
.then((data) => { .then((data) => {
if (data !== "") { if (data !== "") {
@@ -24,6 +24,7 @@ import { CustomTrainData, trainTypeID } from "@/lib/CommonTypes";
import { getCurrentTrainData } from "@/lib/getCurrentTrainData"; import { getCurrentTrainData } from "@/lib/getCurrentTrainData";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { getServiceTimeDifference } from "@/lib/timeUtils";
import { useTrainMenu } from "@/stateBox/useTrainMenu"; import { useTrainMenu } from "@/stateBox/useTrainMenu";
import { useThemeColors } from "@/lib/theme"; import { useThemeColors } from "@/lib/theme";
import { normalizeIconDisplayMode } from "@/lib/iconDisplayMode"; import { normalizeIconDisplayMode } from "@/lib/iconDisplayMode";
@@ -78,13 +79,7 @@ const calcDistanceMinute = (
) => { ) => {
if (!time || time === "") return null; if (!time || time === "") return null;
const now = playbackCurrentTimeIso ? dayjs(playbackCurrentTimeIso) : dayjs(); const now = playbackCurrentTimeIso ? dayjs(playbackCurrentTimeIso) : dayjs();
const hour = parseInt(time.split(":")[0], 10); return getServiceTimeDifference(now, time, delayTime);
const target = now
.hour(hour < 4 ? hour + 24 : hour)
.minute(parseInt(time.split(":")[1], 10));
let diff = target.diff(now, "minute") + delayTime;
if (now.hour() < 4 && hour < 4) diff -= 1440;
return diff;
}; };
export const FixedTrain: FC<props> = ({ trainID }) => { export const FixedTrain: FC<props> = ({ trainID }) => {
@@ -1329,13 +1324,7 @@ const EachStopData: FC<eachStopType> = (props) => {
const calcMinute = (t: string) => { const calcMinute = (t: string) => {
if (!t || t === "") return null; if (!t || t === "") return null;
const now = playbackCurrentTimeIso ? dayjs(playbackCurrentTimeIso) : dayjs(); const now = playbackCurrentTimeIso ? dayjs(playbackCurrentTimeIso) : dayjs();
const hour = parseInt(t.split(":")[0]); return getServiceTimeDifference(now, t, delayTime);
const dt = now
.hour(hour < 4 ? hour + 24 : hour)
.minute(parseInt(t.split(":")[1]));
let diff = dt.diff(now, "minute") + delayTime;
if (now.hour() < 4 && hour < 4) diff -= 1440;
return diff;
}; };
const distanceMinute = calcMinute(time) ?? 0; const distanceMinute = calcMinute(time) ?? 0;
const arrivalMinute = arrivalTime ? calcMinute(arrivalTime) : null; const arrivalMinute = arrivalTime ? calcMinute(arrivalTime) : null;
@@ -4,6 +4,7 @@ import { findReversalPoints } from "@/lib/eachTrainInfoCoreLib/findReversalPoint
import { StationProps } from "@/lib/CommonTypes"; import { StationProps } from "@/lib/CommonTypes";
import { trainDataType } from "@/lib/trainPositionTextArray"; import { trainDataType } from "@/lib/trainPositionTextArray";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { getServiceTimeDifference } from "@/lib/timeUtils";
/** /**
* 次駅と着駅を計算するカスタムフック * 次駅と着駅を計算するカスタムフック
@@ -50,16 +51,7 @@ export const useNextStationCalculator = (
let distanceMinute = 0; let distanceMinute = 0;
if (time != "") { if (time != "") {
const now = dayjs(); const now = dayjs();
const hour = parseInt(time.split(":")[0]); distanceMinute = getServiceTimeDifference(now, time, delayTime) ?? -1;
const distanceTime = now
.hour(hour < 4 ? hour + 24 : hour)
.minute(parseInt(time.split(":")[1]));
distanceMinute = distanceTime.diff(now, "minute") + delayTime;
// 深夜帯の補正
if (now.hour() < 4 && hour < 4) {
distanceMinute = distanceMinute - 1440;
}
} }
// 時間が未来の場合のみ次駅として設定 // 時間が未来の場合のみ次駅として設定
+48 -4
View File
@@ -55,17 +55,60 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo, onInitialLoadReady
data, data,
}); });
}; };
const watchdogMode = Platform.OS === "ios" ? "report_only" : "remount";
const { remountKey, remount, processHandlers, pingHandlers, webViewRef } = useWebViewRemount({ const { remountKey, remount, processHandlers, pingHandlers, webViewRef } = useWebViewRemount({
pingEnabled: Platform.OS === "ios", pingEnabled: Platform.OS === "ios",
backgroundThresholdMs: null, backgroundThresholdMs: null,
isFocused, isFocused,
pauseWatchdogWhenUnfocused: Platform.OS === "ios", pauseWatchdogWhenUnfocused: Platform.OS === "ios",
ignoreProcessTerminationWhenUnfocused: Platform.OS === "ios", ignoreProcessTerminationWhenUnfocused: Platform.OS === "ios",
watchdogMode,
onRemount: (reason, data) => { onRemount: (reason, data) => {
addWebViewBreadcrumb("webview remount requested", { addWebViewBreadcrumb("webview remount requested", {
reason, reason,
...(data ?? {}), ...(data ?? {}),
}); });
Sentry.captureMessage("positions.webview.remount_requested", {
level: "warning",
tags: {
area: "positions_webview",
platform: Platform.OS,
reason,
watchdogMode,
},
contexts: {
positions_webview_remount: {
focused: isFocused,
landscape: isLandscape,
...(data ?? {}),
},
},
fingerprint: ["positions_webview_remount", reason],
});
},
onWatchdog: (reason, data) => {
addWebViewBreadcrumb("watchdog detected; remount suppressed", {
reason,
watchdogMode,
...(data ?? {}),
});
Sentry.captureMessage("positions.webview.watchdog_detected", {
level: "warning",
tags: {
area: "positions_webview",
platform: Platform.OS,
reason,
watchdogMode,
},
contexts: {
positions_webview_watchdog: {
focused: isFocused,
landscape: isLandscape,
...(data ?? {}),
},
},
fingerprint: ["positions_webview_watchdog", reason],
});
}, },
}); });
const lastRemountKeyRef = useRef<typeof remountKey | null>(null); const lastRemountKeyRef = useRef<typeof remountKey | null>(null);
@@ -132,9 +175,10 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo, onInitialLoadReady
landscape: isLandscape, landscape: isLandscape,
mockApi: mockApiFeatureEnabled, mockApi: mockApiFeatureEnabled,
remountKey, remountKey,
watchdogMode,
currentUrl: urlCacheRef.current || null, currentUrl: urlCacheRef.current || null,
}); });
}, [isFocused, isLandscape, mockApiFeatureEnabled, remountKey]); }, [isFocused, isLandscape, mockApiFeatureEnabled, remountKey, watchdogMode]);
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -321,9 +365,9 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo, onInitialLoadReady
if (!stationData) return () => {}; if (!stationData) return () => {};
if (!originalStationList) return () => {}; if (!originalStationList) return () => {};
if (favoriteStation.length < 1) return () => {}; if (favoriteStation.length < 1) return () => {};
const string = getInjectJavascriptAddress( const firstFavorite = favoriteStation.find((station) => station?.[0]?.StationNumber);
favoriteStation[0][0].StationNumber if (!firstFavorite) return () => {};
); const string = getInjectJavascriptAddress(firstFavorite[0].StationNumber);
if (!string) return () => {}; if (!string) return () => {};
if (loadEndTimeoutRef.current) { if (loadEndTimeoutRef.current) {
clearTimeout(loadEndTimeoutRef.current); clearTimeout(loadEndTimeoutRef.current);
+1 -1
View File
@@ -41,7 +41,7 @@ export const FavoriteList: FC = () => {
</Text> </Text>
<ScrollView style={{ height: "100%", backgroundColor: colors.background }}> <ScrollView style={{ height: "100%", backgroundColor: colors.background }}>
{favoriteStation {favoriteStation.filter((currentStation) => currentStation.length > 0 && !!currentStation[0].StationNumber)
.map((currentStation) => { .map((currentStation) => {
return ( return (
<FavoriteListItem <FavoriteListItem
+17 -1
View File
@@ -28,8 +28,24 @@ import Animated, {
} from "react-native-reanimated"; } from "react-native-reanimated";
import { useSortMode } from "./useSortMode"; import { useSortMode } from "./useSortMode";
import { StationSource } from "@/types"; import { StationSource } from "@/types";
import { getFavoriteStationKey } from "@/lib/favoriteStationUtils";
import { StationProps } from "@/lib/CommonTypes";
import * as Sentry from "@sentry/react-native"; import * as Sentry from "@sentry/react-native";
const invalidFavoriteKeys = new WeakMap<object, string>();
let nextInvalidFavoriteKey = 0;
const getSortGridItemKey = (item: unknown): string => {
const key = getFavoriteStationKey(item);
if (key) return key;
if (typeof item !== "object" || item === null) return "invalid:primitive:" + String(item);
const previousKey = invalidFavoriteKeys.get(item);
if (previousKey) return previousKey;
const fallbackKey = "invalid:" + (++nextInvalidFavoriteKey);
invalidFavoriteKeys.set(item, fallbackKey);
return fallbackKey;
};
const isMissingStorageKeyError = (error: unknown) => const isMissingStorageKeyError = (error: unknown) =>
String(error).includes("Not Found!"); String(error).includes("Not Found!");
@@ -380,7 +396,7 @@ export const CarouselBox = ({
rowGap={gridGap} rowGap={gridGap}
data={listUpStation} data={listUpStation}
renderItem={sortGridRenderItem} renderItem={sortGridRenderItem}
keyExtractor={(item) => item[0].StationNumber ?? item[0].Station_JP} keyExtractor={(item: StationProps[]) => getSortGridItemKey(item)}
onDragEnd={onSortDragEnd} onDragEnd={onSortDragEnd}
sortEnabled={stationSource.type === "favorite"} sortEnabled={stationSource.type === "favorite"}
/> />
+8 -13
View File
@@ -1,12 +1,12 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { AS } from "@/storageControl";
import { STORAGE_KEYS } from "@/constants";
import { useFavoriteStation } from "@/stateBox/useFavoriteStation"; import { useFavoriteStation } from "@/stateBox/useFavoriteStation";
import { SortGridCard } from "./SortGridCard"; import { SortGridCard } from "./SortGridCard";
import { CarouselUIMode, StationSource } from "@/types"; import { CarouselUIMode, StationSource } from "@/types";
import { SortableGridDragEndParams, SortableGridRenderItemInfo } from "react-native-sortables";
import { StationProps } from "@/lib/CommonTypes";
type SortModeConfig = { type SortModeConfig = {
listUpStation: any[][]; listUpStation: StationProps[][];
setListIndex: (i: number) => void; setListIndex: (i: number) => void;
width: number; width: number;
origW: number; origW: number;
@@ -35,7 +35,7 @@ export function useSortMode({
carouselHeight, carouselHeight,
stationSource, stationSource,
}: SortModeConfig) { }: SortModeConfig) {
const { setFavoriteStation } = useFavoriteStation(); const { reorderFavoriteStations } = useFavoriteStation();
// "carousel" | "sort" | "sort-exiting" の 3 値で UI モードを管理 // "carousel" | "sort" | "sort-exiting" の 3 値で UI モードを管理
const [uiMode, setUiMode] = useState<CarouselUIMode>("carousel"); const [uiMode, setUiMode] = useState<CarouselUIMode>("carousel");
// ソート開始時のカルーセル位置を保存(setListIndex(-1) される前の値) // ソート開始時のカルーセル位置を保存(setListIndex(-1) される前の値)
@@ -69,7 +69,7 @@ export function useSortMode({
/** Sortable.Grid の renderItemuseCallback でメモ化) */ /** Sortable.Grid の renderItemuseCallback でメモ化) */
const sortGridRenderItem = useCallback( const sortGridRenderItem = useCallback(
({ item, index }: { item: any; index: number }) => { ({ item, index }: SortableGridRenderItemInfo<StationProps[]>) => {
const col = index % cols; const col = index % cols;
const row = Math.floor(index / cols); const row = Math.floor(index / cols);
const carouselCardCenterX = const carouselCardCenterX =
@@ -85,7 +85,6 @@ export function useSortMode({
const exitY = carouselCardCenterY - cellCenterY; const exitY = carouselCardCenterY - cellCenterY;
return ( return (
<SortGridCard <SortGridCard
key={item[0].StationNumber}
item={item} item={item}
cellW={cellW} cellW={cellW}
cellH={cellH} cellH={cellH}
@@ -110,16 +109,12 @@ export function useSortMode({
/** Sortable.Grid の onDragEnd */ /** Sortable.Grid の onDragEnd */
const onSortDragEnd = useCallback( const onSortDragEnd = useCallback(
(newOrder: { indexToKey: string[] }) => { (newOrder: SortableGridDragEndParams<StationProps[]>) => {
// お気に入りモード以外はデータを書き換えない(安全策) // お気に入りモード以外はデータを書き換えない(安全策)
if (stationSource.type !== "favorite") return; if (stationSource.type !== "favorite") return;
const newList = newOrder.indexToKey.map( reorderFavoriteStations(newOrder.data);
(key) => listUpStation.find((s) => s[0].StationNumber === key) ?? []
);
setFavoriteStation(newList);
AS.setItem(STORAGE_KEYS.FAVORITE_STATION, JSON.stringify(newList));
}, },
[listUpStation, setFavoriteStation, stationSource] [reorderFavoriteStations, stationSource]
); );
return { return {
+4 -2
View File
@@ -95,14 +95,16 @@ export const FixedContentBottom = (props) => {
backgroundColor={fixed.primary} backgroundColor={fixed.primary}
flex={1} flex={1}
onPressButton={() => onPressButton={() =>
Linking.openURL("https://www.jr-shikoku.co.jp/03_news/press/") SheetManager.show("NewsReleaseInfo", {
payload: { navigate: props.navigate },
})
} }
> >
<Text style={{ color: fixed.textOnPrimary, fontWeight: "bold", fontSize: fontScale(20) }}> <Text style={{ color: fixed.textOnPrimary, fontWeight: "bold", fontSize: fontScale(20) }}>
</Text> </Text>
<Text style={{ color: fixed.textOnPrimary, fontSize: fontScale(18) }}> <Text style={{ color: fixed.textOnPrimary, fontSize: fontScale(18) }}>
</Text> </Text>
</TextBox> </TextBox>
<TextBox <TextBox
+118
View File
@@ -0,0 +1,118 @@
import { FC, useLayoutEffect, useState } from "react";
import {
View,
Text,
TouchableOpacity,
Linking,
ScrollView,
} from "react-native";
import LottieView from "lottie-react-native";
import { SheetManager } from "react-native-actions-sheet";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { API_ENDPOINTS } from "@/constants/api";
import { useThemeColors } from "@/lib/theme";
import { useResponsive } from "@/lib/responsive";
import { logger } from "@/utils/logger";
import { getPDFViewURL } from "@/lib/getPdfViewURL";
import { NavigateFunction } from "@/types";
type newsDataType = {
publishedDate: string;
title: string;
categories: string[];
url: string;
};
type navigateProps = {
navigate: NavigateFunction;
};
export const NewsReleaseInfoBox: FC<navigateProps> = ({ navigate }) => {
const { colors } = useThemeColors();
const { fontScale, moderateScale } = useResponsive();
const [newsData, setNewsData] = useState<newsDataType[]>([]);
const [loading, setLoading] = useState(false);
const MAX_RETRIES = 3;
const fetchWhenReady = async () => {
setLoading(true);
for (let i = 0; i < MAX_RETRIES; i++) {
try {
const res = await fetch(API_ENDPOINTS.NEWS_RELEASES_STORAGE);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
setNewsData(data.items || []);
setLoading(false);
return;
} catch (err: any) {
if (i === MAX_RETRIES - 1) {
logger.error("Failed to fetch news releases after retries", err);
setNewsData([]);
setLoading(false);
}
}
}
};
const onPressItem = (d: newsDataType) => {
navigate("howto", { info: getPDFViewURL(d.url), goTo: "menu" });
SheetManager.hide("NewsReleaseInfo");
};
useLayoutEffect(() => {
void fetchWhenReady();
}, []);
return (
<View style={{ flexDirection: "column", flex: 1 }}>
{loading ? (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: colors.surface,
}}
>
<LottieView
source={require("@/assets/51690-loading-diamonds.json")}
autoPlay
loop
style={{
width: moderateScale(150),
height: moderateScale(150),
backgroundColor: colors.background,
}}
/>
</View>
) : (
newsData.map((d) => (
<TouchableOpacity
key={d.url}
onPress={() => onPressItem(d)}
style={{
padding: 10,
borderBottomWidth: 1,
borderBottomColor: colors.border,
backgroundColor: colors.surface,
}}
>
<Text
style={{
color: colors.text,
fontSize: fontScale(16),
fontWeight: "bold",
}}
>
{d.title}
</Text>
<Text style={{ color: colors.text, fontSize: fontScale(12) }}>
{d.publishedDate} {d.categories.map((c) => `[${c}]`).join(" ")}
</Text>
</TouchableOpacity>
))
)}
</View>
);
};
+37 -7
View File
@@ -5,21 +5,27 @@ import { useResponsive } from "@/lib/responsive";
import { logger } from "@/utils/logger"; import { logger } from "@/utils/logger";
import { getPDFViewURL } from "@/lib/getPdfViewURL"; import { getPDFViewURL } from "@/lib/getPdfViewURL";
import { ScrollView, SheetManager } from "react-native-actions-sheet"; import { ScrollView, SheetManager } from "react-native-actions-sheet";
import { API_ENDPOINTS } from "@/constants/api";
import { NavigateFunction } from "@/types";
import LottieView from "lottie-react-native";
type props = { type props = {
navigate: (screen: string, params?: object) => void; navigate: NavigateFunction;
}; };
type specialDataType = { address: string; text: string; description: string }; type specialDataType = { address: string; text: string; description: string };
export const SpecialTrainInfoBox: FC<props> = ({ navigate }) => { export const SpecialTrainInfoBox: FC<props> = ({ navigate }) => {
const { colors, fixed } = useThemeColors(); const { colors, fixed } = useThemeColors();
const { fontScale } = useResponsive(); const { fontScale, moderateScale } = useResponsive();
const [specialData, setSpecialData] = useState<specialDataType[]>([]); const [specialData, setSpecialData] = useState<specialDataType[]>([]);
const [loading, setLoading] = useState(false);
useLayoutEffect(() => { useLayoutEffect(() => {
fetch("https://n8n.haruk.in/webhook/sptrainfo") setLoading(true);
fetch(API_ENDPOINTS.SPECIAL_TRAIN_INFO_STORAGE)
.then((res) => res.json()) .then((res) => res.json())
.then((data) => setSpecialData(data.data)) .then((data) => setSpecialData(data.data))
.catch((err) => logger.error('Failed to fetch special train info', err)); .catch((err) => logger.error("Failed to fetch special train info", err))
.finally(() => setLoading(false));
}, []); }, []);
const onPressItem: (d: specialDataType) => void = (d) => { const onPressItem: (d: specialDataType) => void = (d) => {
@@ -46,7 +52,28 @@ export const SpecialTrainInfoBox: FC<props> = ({ navigate }) => {
</Text> </Text>
</View> </View>
<ScrollView style={{ backgroundColor: colors.background }}> <ScrollView style={{ backgroundColor: colors.background }}>
{specialData.map((d) => ( {loading ? (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: colors.surface,
}}
>
<LottieView
source={require("@/assets/51690-loading-diamonds.json")}
autoPlay
loop
style={{
width: moderateScale(150),
height: moderateScale(150),
backgroundColor: colors.background,
}}
/>
</View>
) : (
specialData.map((d) => (
<TouchableOpacity <TouchableOpacity
onPress={() => onPressItem(d)} onPress={() => onPressItem(d)}
onLongPress={() => alert(d.description)} onLongPress={() => alert(d.description)}
@@ -59,9 +86,12 @@ export const SpecialTrainInfoBox: FC<props> = ({ navigate }) => {
alignItems: "center", alignItems: "center",
}} }}
> >
<Text style={{ color: colors.text, fontSize: fontScale(20) }}>{d.text}</Text> <Text style={{ color: colors.text, fontSize: fontScale(20) }}>
{d.text}
</Text>
</TouchableOpacity> </TouchableOpacity>
))} ))
)}
</ScrollView> </ScrollView>
</View> </View>
); );
+24 -20
View File
@@ -6,19 +6,33 @@ import Sortable from "react-native-sortables";
import { useFavoriteStation } from "../../stateBox/useFavoriteStation"; import { useFavoriteStation } from "../../stateBox/useFavoriteStation";
import { FavoriteSettingsItem } from "./FavoliteSettings/FavoiliteSettingsItem"; import { FavoriteSettingsItem } from "./FavoliteSettings/FavoiliteSettingsItem";
import { SheetHeaderItem } from "@/components/atom/SheetHeaderItem"; import { SheetHeaderItem } from "@/components/atom/SheetHeaderItem";
import { AS } from "@/storageControl"; import { getFavoriteStationKey } from "@/lib/favoriteStationUtils";
import { STORAGE_KEYS } from "@/constants"; import { StationProps } from "@/lib/CommonTypes";
import { SortableGridDragEndParams, SortableGridRenderItemInfo } from "react-native-sortables";
import { useThemeColors } from "@/lib/theme"; import { useThemeColors } from "@/lib/theme";
const invalidFavoriteKeys = new WeakMap<object, string>();
let nextInvalidFavoriteKey = 0;
const getFavoriteSettingsItemKey = (item: unknown): string => {
const key = getFavoriteStationKey(item);
if (key) return key;
if (typeof item !== "object" || item === null) return "invalid:primitive:" + String(item);
const previousKey = invalidFavoriteKeys.get(item);
if (previousKey) return previousKey;
const fallbackKey = "invalid:" + (++nextInvalidFavoriteKey);
invalidFavoriteKeys.set(item, fallbackKey);
return fallbackKey;
};
export const FavoriteSettings = () => { export const FavoriteSettings = () => {
const { favoriteStation, setFavoriteStation } = useFavoriteStation(); const { favoriteStation, reorderFavoriteStations } = useFavoriteStation();
const scrollableRef = useAnimatedRef(); const scrollableRef = useAnimatedRef();
const { goBack } = useNavigation(); const { goBack } = useNavigation();
const { colors, fixed } = useThemeColors(); const { colors, fixed } = useThemeColors();
const renderItem = useCallback((props) => { const renderItem = useCallback(({ item }: SortableGridRenderItemInfo<StationProps[]>) => {
const { item, index } = props;
return ( return (
<FavoriteSettingsItem currentStation={item} key={item[0].StationNumber} /> <FavoriteSettingsItem currentStation={item} />
); );
}, []); }, []);
return ( return (
@@ -40,20 +54,10 @@ export const FavoriteSettings = () => {
rowGap={0} rowGap={0}
scrollableRef={scrollableRef} // required for auto scroll scrollableRef={scrollableRef} // required for auto scroll
snapOffsetY={0} snapOffsetY={0}
onDragEnd={(newOrder) => { onDragEnd={(newOrder: SortableGridDragEndParams<StationProps[]>) => {
const newFavoriteStation = newOrder.indexToKey.map( reorderFavoriteStations(newOrder.data);
(item, index, array) => {
let returnData = [];
favoriteStation.forEach((station) => {
if (station[0].StationNumber === item) returnData = station;
});
return returnData;
}
);
setFavoriteStation(newFavoriteStation);
AS.setItem(STORAGE_KEYS.FAVORITE_STATION, JSON.stringify(newFavoriteStation));
}} }}
keyExtractor={(item) => item[0].StationNumber} keyExtractor={(item: StationProps[]) => getFavoriteSettingsItemKey(item)}
/> />
</Animated.ScrollView> </Animated.ScrollView>
<Text <Text
@@ -63,7 +67,7 @@ export const FavoriteSettings = () => {
borderStyle: "solid", borderStyle: "solid",
}} }}
> >
()
</Text> </Text>
</View> </View>
); );
+3 -2
View File
@@ -14,6 +14,7 @@ import * as Clipboard from "expo-clipboard";
import { BigButton } from "../atom/BigButton"; import { BigButton } from "../atom/BigButton";
import { SheetHeaderItem } from "../atom/SheetHeaderItem"; import { SheetHeaderItem } from "../atom/SheetHeaderItem";
import { useThemeColors } from "@/lib/theme"; import { useThemeColors } from "@/lib/theme";
import dayjs from "dayjs";
import * as ExpoFelicaReader from "../../modules/expo-felica-reader/src"; import * as ExpoFelicaReader from "../../modules/expo-felica-reader/src";
import { saveWidgetData } from "@/modules/expo-felica-reader/src"; import { saveWidgetData } from "@/modules/expo-felica-reader/src";
import type { FelicaCardInfo, FelicaHistoryEntry } from "../../modules/expo-felica-reader/src"; import type { FelicaCardInfo, FelicaHistoryEntry } from "../../modules/expo-felica-reader/src";
@@ -176,14 +177,14 @@ export function FelicaHistoryPage() {
balance: data.balance, balance: data.balance,
idm: data.idm, idm: data.idm,
systemCode: data.systemCode, systemCode: data.systemCode,
scannedAt: new Date().toLocaleString("ja-JP"), scannedAt: dayjs().format("YYYY/M/D HH:mm:ss"),
}); });
// iOS ウィジェットにも残高データを同期 // iOS ウィジェットにも残高データを同期
saveWidgetData("felicaLastSnapshot", { saveWidgetData("felicaLastSnapshot", {
balance: data.balance, balance: data.balance,
idm: data.idm, idm: data.idm,
systemCode: data.systemCode, systemCode: data.systemCode,
scannedAt: new Date().toLocaleString("ja-JP"), scannedAt: dayjs().format("YYYY/M/D HH:mm:ss"),
}); });
} }
@@ -15,6 +15,7 @@ import { SheetHeaderItem } from "@/components/atom/SheetHeaderItem";
import { useThemeColors } from "@/lib/theme"; import { useThemeColors } from "@/lib/theme";
import { useTrainMenu } from "@/stateBox/useTrainMenu"; import { useTrainMenu } from "@/stateBox/useTrainMenu";
import { useNotification } from "@/stateBox/useNotifications"; import { useNotification } from "@/stateBox/useNotifications";
import dayjs from "dayjs";
export const ResearchToolsSettings = () => { export const ResearchToolsSettings = () => {
const navigation = useNavigation<any>(); const navigation = useNavigation<any>();
@@ -299,12 +300,7 @@ export const ResearchToolsSettings = () => {
const durationLabel = durationSec >= 60 const durationLabel = durationSec >= 60
? `${Math.floor(durationSec / 60)}${durationSec % 60}` ? `${Math.floor(durationSec / 60)}${durationSec % 60}`
: `${durationSec}`; : `${durationSec}`;
const dateLabel = new Date(rec.recordedAt).toLocaleString("ja-JP", { const dateLabel = dayjs(rec.recordedAt).format("M/D HH:mm");
month: "numeric",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
const recordingRow = ( const recordingRow = (
<TouchableOpacity <TouchableOpacity
onPress={() => startPlayback(rec.id)} onPress={() => startPlayback(rec.id)}
@@ -9,6 +9,7 @@ import {
View, View,
} from "react-native"; } from "react-native";
import * as Clipboard from "expo-clipboard"; import * as Clipboard from "expo-clipboard";
import dayjs from "dayjs";
import { useThemeColors } from "@/lib/theme"; import { useThemeColors } from "@/lib/theme";
import { import {
clearVoicepeakDebugLogs, clearVoicepeakDebugLogs,
@@ -23,7 +24,7 @@ const formatDebugLog = (log: VoicepeakDebugLogEntry) =>
const formatAllDebugLogs = (logs: VoicepeakDebugLogEntry[]) => const formatAllDebugLogs = (logs: VoicepeakDebugLogEntry[]) =>
JSON.stringify( JSON.stringify(
{ {
exportedAt: new Date().toISOString(), exportedAt: dayjs().toISOString(),
retentionDays: 7, retentionDays: 7,
count: logs.length, count: logs.length,
logs, logs,
@@ -240,7 +241,7 @@ export const VoicepeakDebugLogSection = () => {
fontSize: 12, fontSize: 12,
}} }}
> >
{new Date(log.createdAt).toLocaleString("ja-JP")} {dayjs(log.createdAt).format("YYYY/M/D HH:mm:ss")}
</Text> </Text>
</View> </View>
<Text <Text
+2 -1
View File
@@ -17,6 +17,7 @@ import { AS } from "../../storageControl";
import { STORAGE_KEYS } from "@/constants"; import { STORAGE_KEYS } from "@/constants";
import { Switch } from "@rneui/themed"; import { Switch } from "@rneui/themed";
import { SettingTopPage } from "./SettingTopPage"; import { SettingTopPage } from "./SettingTopPage";
import dayjs from "dayjs";
import { LayoutSettings } from "./LayoutSettings"; import { LayoutSettings } from "./LayoutSettings";
import { FavoriteSettings } from "./FavoriteSettings"; import { FavoriteSettings } from "./FavoriteSettings";
import { NotificationSettings } from "./NotificationSettings"; import { NotificationSettings } from "./NotificationSettings";
@@ -120,7 +121,7 @@ export default function Setting(props) {
balance: result.balance, balance: result.balance,
idm: result.idm, idm: result.idm,
systemCode: result.systemCode, systemCode: result.systemCode,
scannedAt: new Date().toLocaleString("ja-JP"), scannedAt: dayjs().format("YYYY/M/D HH:mm:ss"),
}); });
} }
+9 -17
View File
@@ -12,6 +12,7 @@ import Animated, {
import { useCurrentTrain } from "@/stateBox/useCurrentTrain"; import { useCurrentTrain } from "@/stateBox/useCurrentTrain";
import { useThemeColors } from "@/lib/theme"; import { useThemeColors } from "@/lib/theme";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { parseClockTime, setServiceTime } from "@/lib/timeUtils";
import { ExGridSimpleViewItem } from "./ExGridSimpleViewItem"; import { ExGridSimpleViewItem } from "./ExGridSimpleViewItem";
type hoge = { type hoge = {
trainNumber: string; trainNumber: string;
@@ -75,25 +76,19 @@ export const ExGridSimpleView: FC<{
data.forEach((item) => { data.forEach((item) => {
let isOperating = false; let isOperating = false;
let [hour, minute] = dayjs() let parsedTime = parseClockTime(item.time);
.hour(parseInt(item.time.split(":")[0])) if (!parsedTime) return;
.minute(parseInt(item.time.split(":")[1]))
.format("H:m")
.split(":");
if (currentTrain.findIndex((x) => x.num == item.trainNumber) != -1) { if (currentTrain.findIndex((x) => x.num == item.trainNumber) != -1) {
const currentTrainTime = currentTrain.find( const currentTrainTime = currentTrain.find(
(x) => x.num == item.trainNumber (x) => x.num == item.trainNumber
)?.delay; )?.delay;
if (currentTrainTime != "入線") { if (currentTrainTime != "入線") {
[hour, minute] = dayjs() parsedTime = parsedTime.add(currentTrainTime, "minute");
.hour(parseInt(hour))
.minute(parseInt(minute))
.add(currentTrainTime, "minute")
.format("H:m")
.split(":");
} }
isOperating = true; isOperating = true;
} }
const hour = parsedTime.format("H");
const minute = parsedTime.format("m");
initialData[hour].push({ ...item, time: `${hour}:${minute}`, isOperating }); initialData[hour].push({ ...item, time: `${hour}:${minute}`, isOperating });
}); });
@@ -114,14 +109,11 @@ export const ExGridSimpleView: FC<{
const timer = setTimeout(() => { const timer = setTimeout(() => {
const now = dayjs(); const now = dayjs();
const nextTrain = data.find((d) => { const nextTrain = data.find((d) => {
const [h, m] = d.time.split(":").map(Number); const trainTime = setServiceTime(dayjs(), d.time);
const trainTime = h < 4 return !!trainTime?.isAfter(now);
? dayjs().add(1, "day").hour(h).minute(m)
: dayjs().hour(h).minute(m);
return trainTime.isAfter(now);
}); });
if (nextTrain) { if (nextTrain) {
const targetHour = String(parseInt(nextTrain.time.split(":")[0])); const targetHour = String(parseClockTime(nextTrain.time)?.hour() ?? "");
const y = yOffsets.current[targetHour]; const y = yOffsets.current[targetHour];
if (y !== undefined) { if (y !== undefined) {
scrollRef.current?.scrollTo({ y: Math.max(0, y - 30), animated: true }); scrollRef.current?.scrollTo({ y: Math.max(0, y - 30), animated: true });
@@ -11,6 +11,7 @@ import {
} from "react-native"; } from "react-native";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { parseClockTime } from "@/lib/timeUtils";
import { SheetManager } from "react-native-actions-sheet"; import { SheetManager } from "react-native-actions-sheet";
import { useNavigation } from "@react-navigation/native"; import { useNavigation } from "@react-navigation/native";
import { lineList } from "@/lib/getStationList"; import { lineList } from "@/lib/getStationList";
@@ -81,11 +82,7 @@ export const ExGridSimpleViewItem: FC<{
// 列車名の取得(上部表示用) // 列車名の取得(上部表示用)
const trainName = trainData?.train_name || ""; const trainName = trainData?.train_name || "";
const timeArray = d.time.split(":").map((s) => parseInt(s)); const formattedTime = parseClockTime(d.time)?.format("m") ?? "";
const formattedTime = dayjs()
.set("hour", timeArray[0])
.set("minute", timeArray[1])
.format("m");
const openStationACFromEachTrainInfo = async (stationName) => { const openStationACFromEachTrainInfo = async (stationName) => {
await SheetManager.hide("EachTrainInfo"); await SheetManager.hide("EachTrainInfo");
+6 -11
View File
@@ -20,6 +20,7 @@ import { useCurrentTrain } from "@/stateBox/useCurrentTrain";
import { useThemeColors } from "@/lib/theme"; import { useThemeColors } from "@/lib/theme";
import { logger } from "@/utils/logger"; import { logger } from "@/utils/logger";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { parseClockTime } from "@/lib/timeUtils";
type hoge = { type hoge = {
trainNumber: string; trainNumber: string;
array: string; array: string;
@@ -100,25 +101,19 @@ export const ExGridView: FC<{
data.forEach((item) => { data.forEach((item) => {
let isOperating = false; let isOperating = false;
let [hour, minute] = dayjs() let parsedTime = parseClockTime(item.time);
.hour(parseInt(item.time.split(":")[0])) if (!parsedTime) return;
.minute(parseInt(item.time.split(":")[1]))
.format("H:m")
.split(":");
if (currentTrain.findIndex((x) => x.num == item.trainNumber) != -1) { if (currentTrain.findIndex((x) => x.num == item.trainNumber) != -1) {
const currentTrainTime = currentTrain.find( const currentTrainTime = currentTrain.find(
(x) => x.num == item.trainNumber (x) => x.num == item.trainNumber
)?.delay; )?.delay;
if (currentTrainTime != "入線") { if (currentTrainTime != "入線") {
[hour, minute] = dayjs() parsedTime = parsedTime.add(currentTrainTime, "minute");
.hour(parseInt(hour))
.minute(parseInt(minute))
.add(currentTrainTime, "minute")
.format("H:m")
.split(":");
} }
isOperating = true; isOperating = true;
} }
const hour = parsedTime.format("H");
const minute = parsedTime.format("m");
initialData[hour].push({ ...item, time: `${hour}:${minute}`, isOperating }); initialData[hour].push({ ...item, time: `${hour}:${minute}`, isOperating });
}); });
+3 -10
View File
@@ -11,6 +11,7 @@ import {
} from "react-native"; } from "react-native";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { parseClockTime } from "@/lib/timeUtils";
import { SheetManager } from "react-native-actions-sheet"; import { SheetManager } from "react-native-actions-sheet";
import { useNavigation } from "@react-navigation/native"; import { useNavigation } from "@react-navigation/native";
import { lineList } from "@/lib/getStationList"; import { lineList } from "@/lib/getStationList";
@@ -82,19 +83,11 @@ export const ExGridViewItem: FC<{
]; ];
} }
}, [d.array, trainData]); }, [d.array, trainData]);
const timeArray = d.time.split(":").map((s) => parseInt(s)); const formattedTime = parseClockTime(d.time)?.format("m") ?? "";
const formattedTime = dayjs()
.set("hour", timeArray[0])
.set("minute", timeArray[1])
.format("m");
let isSameTimeBefore = false; let isSameTimeBefore = false;
if (index > 0) { if (index > 0) {
const beforeItem = array[index - 1]; const beforeItem = array[index - 1];
const beforeTimeArray = beforeItem.time.split(":").map((s) => parseInt(s)); const beforeFormattedTime = parseClockTime(beforeItem.time)?.format("m") ?? "";
const beforeFormattedTime = dayjs()
.set("hour", beforeTimeArray[0])
.set("minute", beforeTimeArray[1])
.format("m");
isSameTimeBefore = beforeFormattedTime === formattedTime; isSameTimeBefore = beforeFormattedTime === formattedTime;
} }
+5 -7
View File
@@ -2,6 +2,7 @@ import { FC, useRef, useEffect } from "react";
import { ListViewItem } from "@/components/StationDiagram/ListViewItem"; import { ListViewItem } from "@/components/StationDiagram/ListViewItem";
import { View, Text, ScrollView } from "react-native"; import { View, Text, ScrollView } from "react-native";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { parseClockTime, setServiceTime } from "@/lib/timeUtils";
import { useUnyohub } from "@/stateBox/useUnyohub"; import { useUnyohub } from "@/stateBox/useUnyohub";
import { useElesite } from "@/stateBox/useElesite"; import { useElesite } from "@/stateBox/useElesite";
import { useThemeColors } from "@/lib/theme"; import { useThemeColors } from "@/lib/theme";
@@ -26,7 +27,7 @@ export const ListView: FC<{
const groupedData: Record<string, hoge[]> = {}; const groupedData: Record<string, hoge[]> = {};
const groupKeys = []; const groupKeys = [];
data.forEach((item) => { data.forEach((item) => {
const hour = dayjs().hour(parseInt(item.time.split(":")[0])).format("H"); const hour = String(parseClockTime(item.time)?.hour() ?? "");
if (!groupedData[hour]) { if (!groupedData[hour]) {
groupedData[hour] = []; groupedData[hour] = [];
groupKeys.push(hour); groupKeys.push(hour);
@@ -40,14 +41,11 @@ export const ListView: FC<{
const timer = setTimeout(() => { const timer = setTimeout(() => {
const now = dayjs(); const now = dayjs();
const nextTrain = data.find((d) => { const nextTrain = data.find((d) => {
const [h, m] = d.time.split(":").map(Number); const trainTime = setServiceTime(dayjs(), d.time);
const trainTime = h < 4 return !!trainTime?.isAfter(now);
? dayjs().add(1, "day").hour(h).minute(m)
: dayjs().hour(h).minute(m);
return trainTime.isAfter(now);
}); });
if (nextTrain) { if (nextTrain) {
const targetHour = String(parseInt(nextTrain.time.split(":")[0])); const targetHour = String(parseClockTime(nextTrain.time)?.hour() ?? "");
const y = yOffsets.current[targetHour]; const y = yOffsets.current[targetHour];
if (y !== undefined) { if (y !== undefined) {
scrollRef.current?.scrollTo({ y: Math.max(0, y - 30), animated: true }); scrollRef.current?.scrollTo({ y: Math.max(0, y - 30), animated: true });
+2 -5
View File
@@ -12,6 +12,7 @@ import Animated, {
} from "react-native-reanimated"; } from "react-native-reanimated";
import { customTrainDataDetector } from "../custom-train-data"; import { customTrainDataDetector } from "../custom-train-data";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { parseClockTime } from "@/lib/timeUtils";
import { SheetManager } from "react-native-actions-sheet"; import { SheetManager } from "react-native-actions-sheet";
import { useNavigation } from "@react-navigation/native"; import { useNavigation } from "@react-navigation/native";
import { lineList } from "@/lib/getStationList"; import { lineList } from "@/lib/getStationList";
@@ -157,11 +158,7 @@ export const ListViewItem: FC<{
]; ];
} }
}, [d.array, allCustomTrainData]); }, [d.array, allCustomTrainData]);
const timeArray = d.time.split(":").map((s) => parseInt(s)); const formattedTime = parseClockTime(d.time)?.format("HH:mm") ?? d.time;
const formattedTime = dayjs()
.set("hour", timeArray[0])
.set("minute", timeArray[1])
.format("HH:mm");
const openStationACFromEachTrainInfo = async (stationName) => { const openStationACFromEachTrainInfo = async (stationName) => {
await SheetManager.hide("EachTrainInfo"); await SheetManager.hide("EachTrainInfo");
@@ -19,6 +19,7 @@ import { useUnyohub } from "@/stateBox/useUnyohub";
import { useElesite } from "@/stateBox/useElesite"; import { useElesite } from "@/stateBox/useElesite";
import { ListView } from "@/components/StationDiagram/ListView"; import { ListView } from "@/components/StationDiagram/ListView";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { setServiceTime } from "@/lib/timeUtils";
import { ExGridView } from "./ExGridView"; import { ExGridView } from "./ExGridView";
import { Switch } from "@rneui/themed"; import { Switch } from "@rneui/themed";
import { customTrainDataDetector } from "../custom-train-data"; import { customTrainDataDetector } from "../custom-train-data";
@@ -206,14 +207,12 @@ export const StationDiagramView: FC<props> = ({ route }) => {
setCurrentStationDiagram( setCurrentStationDiagram(
returnDataArray.sort((a, b) => { returnDataArray.sort((a, b) => {
const adjustTime = (t: string) => { const adjustTime = (t: string) => {
const [h, m] = t.split(":").map(Number); // 4時未満は翌日の時刻とみなす
// 4時未満は翌日の時刻とみなして+24時間 return setServiceTime(dayjs(), t);
return h < 4
? dayjs().add(1, "day").hour(h).minute(m)
: dayjs().hour(h).minute(m);
}; };
const aa = adjustTime(a.time); const aa = adjustTime(a.time);
const bb = adjustTime(b.time); const bb = adjustTime(b.time);
if (!aa || !bb) return 0;
const x = aa.isAfter(bb); const x = aa.isAfter(bb);
return x ? 1 : -1; return x ? 1 : -1;
//return true; //return true;
@@ -259,7 +258,7 @@ export const StationDiagramView: FC<props> = ({ route }) => {
}; };
const isNotDeparted = (d: hoge[number]) => const isNotDeparted = (d: hoge[number]) =>
isInApproachSection(d.trainNumber) || isInApproachSection(d.trainNumber) ||
dayjs(d.time, "HH:mm").add(getDelayMinutes(d.trainNumber), "minute").isAfter(now); !!setServiceTime(now, d.time, getDelayMinutes(d.trainNumber))?.isAfter(now);
const nextTrain = currentStationDiagram.find(isNotDeparted); const nextTrain = currentStationDiagram.find(isNotDeparted);
const followingTrain = currentStationDiagram.find( const followingTrain = currentStationDiagram.find(
(d) => isNotDeparted(d) && d !== nextTrain (d) => isNotDeparted(d) && d !== nextTrain
+6 -6
View File
@@ -17,6 +17,7 @@ import { ScrollingDescription } from "@/components/発車時刻表/LED_inside_Co
import { useStationList } from "@/stateBox/useStationList"; import { useStationList } from "@/stateBox/useStationList";
import useInterval from "@/lib/useInterval"; import useInterval from "@/lib/useInterval";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { setServiceTime } from "@/lib/timeUtils";
import { useAllTrainDiagram } from "@/stateBox/useAllTrainDiagram"; import { useAllTrainDiagram } from "@/stateBox/useAllTrainDiagram";
import { import {
CustomTrainData, CustomTrainData,
@@ -133,13 +134,12 @@ export const EachData: FC<Props> = (props) => {
const [isShow, setIsShow] = useState(true); const [isShow, setIsShow] = useState(true);
const [isDepartureNow, setIsDepartureNow] = useState(false); const [isDepartureNow, setIsDepartureNow] = useState(false);
useEffect(() => { useEffect(() => {
const [h, m] = d.time.split(":");
const IntH = parseInt(h);
const IntM = parseInt(m);
const currentTime = playbackCurrentTimeIso ? dayjs(playbackCurrentTimeIso) : dayjs(); const currentTime = playbackCurrentTimeIso ? dayjs(playbackCurrentTimeIso) : dayjs();
const trainTime = currentTime const trainTime = setServiceTime(currentTime, d.time);
.set("hour", IntH < 4 ? IntH + 24 : IntH) if (!trainTime) {
.set("minute", IntM); setIsDepartureNow(false);
return;
}
const diff = trainTime.diff(currentTime, "minute"); const diff = trainTime.diff(currentTime, "minute");
if (diff < 2) setIsDepartureNow(true); if (diff < 2) setIsDepartureNow(true);
else setIsDepartureNow(false); else setIsDepartureNow(false);
+26 -48
View File
@@ -1,6 +1,5 @@
import React, { useState, useEffect, FC, useCallback, useRef } from "react"; import React, { useState, useEffect, useMemo, FC, useCallback, useRef } from "react";
import { View, useWindowDimensions, Text, Platform } from "react-native"; import { View, useWindowDimensions, Text, Platform } from "react-native";
import { objectIsEmpty } from "@/lib/objectIsEmpty";
import { useCurrentTrain } from "@/stateBox/useCurrentTrain"; import { useCurrentTrain } from "@/stateBox/useCurrentTrain";
import { useAreaInfo } from "@/stateBox/useAreaInfo"; import { useAreaInfo } from "@/stateBox/useAreaInfo";
import { AS } from "@/storageControl"; import { AS } from "@/storageControl";
@@ -10,7 +9,8 @@ import { EachData } from "@/components/発車時刻表/EachData";
import { useAllTrainDiagram } from "@/stateBox/useAllTrainDiagram"; import { useAllTrainDiagram } from "@/stateBox/useAllTrainDiagram";
import { AreaDescription } from "@/components/発車時刻表/LED_inside_Component/AreaDescription"; import { AreaDescription } from "@/components/発車時刻表/LED_inside_Component/AreaDescription";
import { getTime, trainTimeFiltering } from "@/lib/trainTimeFiltering"; import { getTime, trainTimeFiltering } from "@/lib/trainTimeFiltering";
import { eachTrainDiagramType, StationProps } from "@/lib/CommonTypes"; import { getServiceMinute } from "@/lib/timeUtils";
import type { eachTrainDiagramType, StationProps } from "@/lib/CommonTypes";
import { useNavigation } from "@react-navigation/native"; import { useNavigation } from "@react-navigation/native";
import { useThemeColors } from "@/lib/theme"; import { useThemeColors } from "@/lib/theme";
import { getCurrentTrainData } from "@/lib/getCurrentTrainData"; import { getCurrentTrainData } from "@/lib/getCurrentTrainData";
@@ -88,14 +88,6 @@ type VoicepeakCandidate = {
priority: number; priority: number;
}; };
const getServiceMinute = (timeText: string) => {
const [hourText, minuteText] = timeText.split(":");
const hour = Number.parseInt(hourText, 10);
const minute = Number.parseInt(minuteText, 10);
if (Number.isNaN(hour) || Number.isNaN(minute)) return null;
return (hour < 4 ? hour + 24 : hour) * 60 + minute;
};
const getDwellMinutes = (arrivalTime: string, departureTime: string) => { const getDwellMinutes = (arrivalTime: string, departureTime: string) => {
const arrivalMinute = getServiceMinute(arrivalTime); const arrivalMinute = getServiceMinute(arrivalTime);
const departureMinute = getServiceMinute(departureTime); const departureMinute = getServiceMinute(departureTime);
@@ -112,9 +104,6 @@ export const LED_vision: FC<props> = (props) => {
const { currentTrain } = useCurrentTrain(); const { currentTrain } = useCurrentTrain();
const { stationList } = useStationList(); const { stationList } = useStationList();
const { playbackCurrentTimeIso } = useTrainMenu(); const { playbackCurrentTimeIso } = useTrainMenu();
const [stationDiagram, setStationDiagram] = useState<{
[key: string]: string;
}>({}); //当該駅の全時刻表
const [finalSwitch, setFinalSwitch] = useState(false); const [finalSwitch, setFinalSwitch] = useState(false);
const [trainIDSwitch, setTrainIDSwitch] = useState(false); const [trainIDSwitch, setTrainIDSwitch] = useState(false);
const [trainDescriptionSwitch, setTrainDescriptionSwitch] = useState(false); const [trainDescriptionSwitch, setTrainDescriptionSwitch] = useState(false);
@@ -190,48 +179,37 @@ export const LED_vision: FC<props> = (props) => {
}, [addListener, refreshVoicepeakSettings]); }, [addListener, refreshVoicepeakSettings]);
const currentStation = station[0];
const stationDiagram = useMemo<{ [key: string]: string }>(() => {
if (!allTrainDiagram || !currentStation) return {};
return Object.keys(allTrainDiagram).reduce((result, key) => {
if (allTrainDiagram[key].match(currentStation.Station_JP + ",")) {
result[key] = allTrainDiagram[key];
}
return result;
}, {} as { [key: string]: string });
}, [allTrainDiagram, currentStation]);
useEffect(() => { useEffect(() => {
// 現在の駅に停車するダイヤを作成する副作用[列車ダイヤと現在駅情報]
if (!allTrainDiagram) {
setStationDiagram({});
return;
}
let returnData = {};
Object.keys(allTrainDiagram).forEach((key) => {
if (allTrainDiagram[key].match(station[0].Station_JP + ",")) {
returnData[key] = allTrainDiagram[key];
}
});
setStationDiagram(returnData);
setIsInfoArea(station.some((s) => areaStationID.includes(s.StationNumber))); setIsInfoArea(station.some((s) => areaStationID.includes(s.StationNumber)));
}, [allTrainDiagram, station]); }, [areaStationID, station]);
/* /*
{lastStation: "当駅止", time: "12:34", train: "1234M"} {lastStation: "当駅止", time: "12:34", train: "1234M"}
*/ */
const [trainTimeAndNumber, setTrainTimeAndNumber] = useState< const trainTimeAndNumber = useMemo(
eachTrainDiagramType[] () => (currentStation ? getTime(stationDiagram, currentStation) : []),
>([]); [currentStation, stationDiagram]
useEffect(() => {
//現在の駅に停車する列車から時刻を切り出してLEDベースにフォーマット
if (objectIsEmpty(stationDiagram)) return () => {};
const getTimeData = getTime(stationDiagram, station[0]);
setTrainTimeAndNumber(getTimeData);
}, [stationDiagram]);
const [selectedTrain, setSelectedTrain] = useState<eachTrainDiagramType[]>(
[]
); );
useEffect(() => {
if (!trainTimeAndNumber) return () => {}; const selectedTrain = useMemo(() => {
if (!currentTrain) return () => {}; if (!currentStation || !currentTrain) return [];
const data = trainTimeAndNumber const currentTrainNumbers = new Set(currentTrain.map((train) => train.num));
.filter((d) => currentTrain.map((m) => m.num).includes(d.train)) //現在の列車に絞る[ToDo] return trainTimeAndNumber
.filter((d) => currentTrainNumbers.has(d.train)) //現在の列車に絞る[ToDo]
.filter((d) => trainTimeFiltering({ d, currentTrain, station, stationList, now: playbackCurrentTimeIso })) //時間フィルター .filter((d) => trainTimeFiltering({ d, currentTrain, station, stationList, now: playbackCurrentTimeIso })) //時間フィルター
.filter((d) => !!finalSwitch || d.lastStation != station[0].Station_JP); //最終列車表示設定 .filter((d) => !!finalSwitch || d.lastStation != currentStation.Station_JP); //最終列車表示設定
setSelectedTrain(data); }, [currentStation, currentTrain, finalSwitch, playbackCurrentTimeIso, station, stationList, trainTimeAndNumber]);
}, [trainTimeAndNumber, currentTrain, finalSwitch, stationList, playbackCurrentTimeIso]);
const getVoicepeakCandidates = useCallback(() => { const getVoicepeakCandidates = useCallback(() => {
if (!currentTrain?.length || !allCustomTrainData) return []; if (!currentTrain?.length || !allCustomTrainData) return [];
+2 -5
View File
@@ -1,6 +1,5 @@
import React, { FC, useEffect } from "react"; import React, { FC, useEffect } from "react";
import { Platform, Text } from "react-native"; import { Platform, Text } from "react-native";
import { useFavoriteStation } from "../../stateBox/useFavoriteStation";
import { StationProps } from "@/lib/CommonTypes"; import { StationProps } from "@/lib/CommonTypes";
import { lightColors } from "@/lib/theme"; import { lightColors } from "@/lib/theme";
type Props = { type Props = {
@@ -9,10 +8,10 @@ type Props = {
}; };
export const AddressText: FC<Props> = (props) => { export const AddressText: FC<Props> = (props) => {
const { currentStation, isMatsuyama } = props; const { currentStation, isMatsuyama } = props;
const {lodAddMigration} = useFavoriteStation();
const [stationAddress, setStationAddress] = React.useState(""); const [stationAddress, setStationAddress] = React.useState("");
useEffect(() => { useEffect(() => {
if (!!currentStation[0].jslodApi) { setStationAddress("");
if (!!currentStation[0]?.jslodApi) {
fetch(`${currentStation[0].jslodApi}.json`) fetch(`${currentStation[0].jslodApi}.json`)
.then((response) => response.json()) .then((response) => response.json())
.then((data) => { .then((data) => {
@@ -22,8 +21,6 @@ export const AddressText: FC<Props> = (props) => {
][0]["value"]; ][0]["value"];
setStationAddress(c); setStationAddress(c);
}); });
}else{
lodAddMigration();
} }
}, [currentStation]); }, [currentStation]);
return ( return (
+23 -47
View File
@@ -1,4 +1,4 @@
import React, { useRef, useState, useEffect, useLayoutEffect } from "react"; import React, { useMemo, useState, useEffect } from "react";
import { import {
View, View,
Text, Text,
@@ -8,8 +8,6 @@ import {
import { MaterialCommunityIcons } from "@expo/vector-icons"; import { MaterialCommunityIcons } from "@expo/vector-icons";
import LottieView from "lottie-react-native"; import LottieView from "lottie-react-native";
import { useInterval } from "../../lib/useInterval"; import { useInterval } from "../../lib/useInterval";
import { AS } from "../../storageControl";
import { STORAGE_KEYS } from "@/constants";
import { useFavoriteStation } from "../../stateBox/useFavoriteStation"; import { useFavoriteStation } from "../../stateBox/useFavoriteStation";
import { StationNameArea } from "./StationNameArea"; import { StationNameArea } from "./StationNameArea";
@@ -24,38 +22,22 @@ export default function Sign(props) {
const { oP, oLP, isCurrentStation = false, stationID } = props; const { oP, oLP, isCurrentStation = false, stationID } = props;
const { width, height } = useWindowDimensions(); const { width, height } = useWindowDimensions();
const { getStationDataFromId } = useStationList(); const { getStationDataFromId, originalStationList } = useStationList();
const { fixed } = useThemeColors(); const { fixed } = useThemeColors();
if (!stationID) { // 駅マスター読込後にも再解決する。Hooks は stationID の有無にかかわらず同順で呼ぶ。
return <></>; const currentStationData = useMemo(
} () => (stationID ? getStationDataFromId(stationID) : []),
const [currentStationData] = useState(getStationDataFromId(stationID)); [stationID, originalStationList]
const { favoriteStation, setFavoriteStation } = useFavoriteStation(); );
const { isFavoriteStation, toggleFavoriteStation } = useFavoriteStation();
const [nexPrePosition, setNexPrePosition] = useState(0); const [nexPrePosition, setNexPrePosition] = useState(0);
const { originalStationList } = useStationList();
const [preStation, setPreStation] = useState(); const [preStation, setPreStation] = useState();
const [nexStation, setNexStation] = useState(); const [nexStation, setNexStation] = useState();
const [testButtonStatus, setTestButtonStatus] = useState(false); const testButtonStatus = isFavoriteStation(currentStationData);
useLayoutEffect(() => {
const isFavorite = favoriteStation.filter((d) => {
const compare = JSON.stringify(d);
const current = JSON.stringify(currentStationData);
return compare === current;
});
setTestButtonStatus(isFavorite.length == 0 ? false : true);
}, [favoriteStation, currentStationData]);
useEffect(() => {
const isFavorite = favoriteStation.filter((d) => {
const compare = JSON.stringify(d);
const current = JSON.stringify(currentStationData);
return compare === current;
});
setTestButtonStatus(isFavorite.length == 0 ? false : true);
}, [favoriteStation, currentStationData]);
useInterval(() => { useInterval(() => {
if (currentStationData.length == 1) { if (currentStationData.length <= 1) {
setNexPrePosition(0); setNexPrePosition(0);
return () => {}; return () => {};
} }
@@ -66,10 +48,15 @@ export default function Sign(props) {
useEffect(() => { useEffect(() => {
setNexPrePosition(0); setNexPrePosition(0);
if (!currentStationData.length) {
setPreStation(undefined);
setNexStation(undefined);
return;
}
getPreNextStation(currentStationData[0]); getPreNextStation(currentStationData[0]);
if (currentStationData.length == 1) return () => {}; if (currentStationData.length == 1) return () => {};
getPreNextStation(currentStationData[1]); getPreNextStation(currentStationData[1]);
}, [currentStationData]); }, [currentStationData, originalStationList]);
useEffect(() => { useEffect(() => {
if (!currentStationData[nexPrePosition]) return () => {}; if (!currentStationData[nexPrePosition]) return () => {};
@@ -89,16 +76,17 @@ export default function Sign(props) {
]; ];
let returnData; let returnData;
lineList.forEach((d) => { lineList.forEach((d) => {
let cache = originalStationList[d].findIndex( let cache = originalStationList[d]?.findIndex(
(data) => data.StationNumber == now.StationNumber (data) => data.StationNumber == now.StationNumber
); );
if (cache != -1) { if (cache != null && cache != -1) {
returnData = [ returnData = [
originalStationList[d][cache - 1], originalStationList[d][cache - 1],
originalStationList[d][cache + 1], originalStationList[d][cache + 1],
]; ];
} }
}); });
if (!returnData) return;
if (now.Station_JP == "宇多津" && now.StationNumber == null) { if (now.Station_JP == "宇多津" && now.StationNumber == null) {
if (returnData[1]) setPreStation(returnData[1]); if (returnData[1]) setPreStation(returnData[1]);
if (returnData[0]) setNexStation(returnData[0]); if (returnData[0]) setNexStation(returnData[0]);
@@ -107,26 +95,14 @@ export default function Sign(props) {
if (returnData[1]) setNexStation(returnData[1]); if (returnData[1]) setNexStation(returnData[1]);
} }
}; };
const isMatsuyama = currentStationData[0].StationNumber == "Y55"; const isMatsuyama = currentStationData[0]?.StationNumber == "Y55";
//const isMatsuyama = true; //const isMatsuyama = true;
const favoliteChanger = () => { const favoliteChanger = () => {
if (testButtonStatus) { toggleFavoriteStation(currentStationData);
const otherData = favoriteStation.filter((d) => {
const compare = JSON.stringify(d);
const current = JSON.stringify(currentStationData);
return compare !== current;
});
AS.setItem(STORAGE_KEYS.FAVORITE_STATION, JSON.stringify(otherData));
setFavoriteStation(otherData);
} else {
let ret = favoriteStation;
ret.push(currentStationData);
AS.setItem(STORAGE_KEYS.FAVORITE_STATION, JSON.stringify(ret));
setFavoriteStation(ret);
}
setTestButtonStatus(!testButtonStatus);
}; };
if (!stationID || currentStationData.length === 0) return null;
const styleSheet = { const styleSheet = {
: { : {
width: width * 0.8, width: width * 0.8,
+15 -2
View File
@@ -11,11 +11,15 @@ export const API_ENDPOINTS = {
/** 本日のダイアグラムデータ(experimental環境用) */ /** 本日のダイアグラムデータ(experimental環境用) */
DIAGRAM_TODAY_BETA: `${BASE_URL}/tmp/diagram-today-beta.json`, DIAGRAM_TODAY_BETA: `${BASE_URL}/tmp/diagram-today-beta.json`,
/** JR四国運行情報スナップショット */
OPERATION_INFO: `${BASE_URL}/operation-info/jr-shikoku/latest.json`,
/** カスタム列車データ */ /** カスタム列車データ */
CUSTOM_TRAIN_DATA: 'https://haruk.in/api/jr/getTrain.php', CUSTOM_TRAIN_DATA: 'https://haruk.in/api/jr/getTrain.php',
/** 遅延情報 */ /** 遅延情報 */
DELAY_INFO: 'https://haruk.in/api/jr/getTrainDelay.php', DELAY_INFO: `${BASE_URL}/derived/delays/latest.json`,
DELAY_INFO_LEGACY: `${BASE_URL}/legacy/trainfo-ex.txt`,
/** 特急列車情報 */ /** 特急列車情報 */
SPECIAL_TRAIN_INFO: 'https://haruk.in/api/jr/getSpecialTrain.php', SPECIAL_TRAIN_INFO: 'https://haruk.in/api/jr/getSpecialTrain.php',
@@ -39,7 +43,16 @@ export const API_ENDPOINTS = {
UNYOHUB_DATA: 'https://jr-shikoku-api-data-storage.haruk.in/thirdparty/unyohub-unyo.json', UNYOHUB_DATA: 'https://jr-shikoku-api-data-storage.haruk.in/thirdparty/unyohub-unyo.json',
/** えれサイト運用データ */ /** えれサイト運用データ */
ELESITE_DATA: 'https://jr-shikoku-api-data-storage.haruk.in/thirdparty/elesite-unyo.json', ELESITE_DATA: `${BASE_URL}/thirdparty/elesite-unyo.json`,
/** 本番列車位置情報 */
CURRENT_POSITIONS: `${BASE_URL}/tmp/currentPositions.json`,
/** 臨時列車情報 */
SPECIAL_TRAIN_INFO_STORAGE: `${BASE_URL}/sptrainfo`,
/** ニュースリリース情報 */
NEWS_RELEASES_STORAGE: `${BASE_URL}/news-releases/latest.json`,
} as const; } as const;
/** /**
+149
View File
@@ -0,0 +1,149 @@
# ActionSheet Gesture Specification
## Overview
JR Shikoku mobile appのActionSheet(ニュースリリース、各列車情報など)のジェスチャー実装仕様です。
---
## Core Behavior
### Swipe-to-Dismiss (スワイプで閉じる)
| Platform | 動作 | 方法 |
|----------|------|------|
| **iOS (Phone)** | シート下方向スワイプで閉じる | `isModal={true}` + `gestureEnabled={true}` |
| **iOS (iPad)** | スワイプ無効 | `isModal={false}` → 通常のViewとして表示 |
| **Android** | スワイプ無効 | `isModal={false}` |
### isModalの役割
- `isModal={true}` にするとActionSheet内部で`<Modal>`ラッパーが適用されます
- iOSのModalはOSレベルのswipe-to-dismissジェスチャーを組み込みでサポートしています
- `gestureEnabled={true}` でそのジェスチャーを有効化します
- iPadでは物理的に画面が larg いため、モーダル化しません(通常Viewとして表示)
---
## Height Control
### EachTrainInfo (共用パターン)
```tsx
const maxHeight = useSheetMaxHeight();
// ↓
containerStyle={{ maxHeight }}
```
`useSheetMaxHeight()`フックが以下を判定して高さを計算:
| shortSide(デバイス短辺) | maxHeight値 | 意味 |
|---------------------------|-------------|------|
| ≥ 600 (iPad etc.) | `undefined` | 全画面表示、高さ制限なし |
| < 600 (Phone) | deviceHeight * 0.75 | 画面の高さの75% |
### NewsReleaseInfo (個別実装)
```tsx
const sheetHeight = windowDimen.height * 0.8;
// ↓
containerStyle={{ height: sheetHeight }}
```
- Phoneのみ画面高さの80%固定
- shortSide判定はしない点で差異あり
---
## Platform-Specific Layout
### Android
```tsx
containerStyle={{
paddingBottom: insets.bottom, // SafeArea対応
useBottomSafeAreaPadding={true},
}}
```
AndroidはSafeAreaパディングが必要です。 Bottom barの領域を避けます。
---
## ScrollView inside ActionSheet
### react-native-actions-sheet内部のScrollView使用(注意)
```tsx
import { ScrollView } from "react-native-actions-sheet"; // ← これ使う
```
- `nestedScrollEnabled={true}` はAndroid用(nested scrolling有効化)
- ScrollViewコンテンツとActionSheetのジェスチャーが干渉しないように重要
---
## Layout Structure (テンプレート)
```tsx
// ActionSheet
containerStyle={{ height: ..., borderTopLeftRadius: 5, borderTopRightRadius: 5 }}
CustomHeaderComponent={<></>} // カスタムヘッダーなし
gestureEnabled={true} // iOSでswipe-to-dismiss有効
isModal={Platform.OS === "ios" && !Platform.isPad}
Content
DragHandle ()
Title ()
ScrollView
NewsReleaseInfoBox / EachTrainInfoCore
BottomButton (ScrollView外)
```
### ボタンの配置ルール
- **重要な操作ボタン(「公式でもっと見る」etc.)はScrollViewの外に配置**
- ScrollView内にあるとスクロールで隠れ、クリック不可になる
- ActionSheetの下部、SafeAreaパディングの直上に固定する
---
## Back Handler (Androidのみ)
```tsx
useEffect(() => {
if (Platform.OS === "android") {
const backAction = () => true; // default prevent(スワイプ無効化)
const backHandler = BackHandler.addEventListener(
"hardwareBackPress", backAction
);
return () => backHandler.remove();
}
}, []);
```
Androidでハードウェアバックキーを押下時にスワイプを無効化します。
---
## Gesture Flow (iOS)
```
ユーザー操作 ActionSheet内部処理
──────── ────────────────
シートを下方向にスワイプ → Modalがgesture検知
↓ ↓
スワイプ距離閾値超過 ↓(閉じる判定)
↓ ↓
Modal-dismiss実行 sheet ref close + onCloseコールバック
```
---
## Key Points Summary
1. **ジェスチャー有効化**: `gestureEnabled={true}` は必須
2. **iOSモーダル化**: `isModal={Platform.OS === "ios" && !Platform.isPad}`
3. **Androidパディング**: `useBottomSafeAreaPadding` + `containerStyle.pb` 両方必要
4. **ScrollView配置**: ActionSheetコンテンツ内のスクロールにはライブラリ版(`react-native-actions-sheet`)を使用
5. **ボタン配置**: 重要ボタンはScrollView外、画面下部固定
+254
View File
@@ -0,0 +1,254 @@
# お気に入り駅の正規化・削除済み駅対策 実装計画
## 1. 目的
端末の永続ストレージに残った旧形式・重複・削除済みのお気に入り駅を、安全に現在の駅マスターへ移行する。
この対応で、主に次の2件を同時に解消する。
- 津島ノ宮(`Y13-x`)を駅マスターから削除した後、保存済みのお気に入り参照によってアプリがクラッシュする問題
- 徳島・高知・多度津など、複数路線の駅番号を持つ駅が「消せないお気に入り」または重複したお気に入りとして表示される問題
## 2. 対象ブランチと反映手順
- 実装元(Android側): `hotfix/support-tsushimanomiya-temp`
- チェリーピック先: `hotfix/tsusimanomiya-old`
実装・検証・コミットは必ずAndroid側ブランチで先に行い、確定したコミットを旧側ブランチへチェリーピックする。両ブランチへ別々に同内容を書き込まない。
## 3. 現状と原因
### 3.1 EAS更新と端末ストレージの世代不一致
EAS UpdateでJavaScriptとアセットを以前の状態へ戻しても、AsyncStorage内の `favoriteStation` は更新前の値を保持する。
津島ノ宮追加版で `Y13-x` を保存した端末が、津島ノ宮を含まない版を受け取ると次の状態になる。
1. 保存済みお気に入りには `Y13-x` が存在する
2. 現在の駅マスターには `Y13-x` が存在しない
3. `getStationDataFromId("Y13-x")` が空配列を返す
4. 画面が `currentStationData[0]` を参照してクラッシュする
### 3.2 複数路線駅の旧形式不整合
同じ物理駅に複数の駅番号が割り当てられている。
| 物理駅 | 駅番号 |
| --- | --- |
| 徳島 | `T00`, `B00` |
| 高知 | `D45`, `K00` |
| 多度津 | `Y12`, `D12` |
現在の `getStationDataFromId()` は、1つの駅番号から駅名を引き直し、同名の全路線データをまとめて返す。このため `T00` の検索結果は `[T00, B00]` になる。
一方、お気に入りの登録判定と解除は、駅グループ全体の `JSON.stringify()` 完全一致に依存している。端末に旧形式 `[T00]` が残っている場合、現在形式 `[T00, B00]` と一致しない。
その結果、旧形式の徳島を解除しようとした操作が新形式の徳島追加として処理され、2件表示になる。次の解除では新形式だけが消え、旧形式が残るため、利用者からは「消せない徳島駅」に見える。
### 3.3 現行移行処理の問題
現行の `lodAddMigration()` には次の問題がある。
- 変換後のデータをReact stateに入れるだけで、AsyncStorageへ保存し直していない
- 現在の駅マスターに存在しない駅を空配列のまま残す
- 同じ物理駅の重複を除去しない
- 先頭要素に `jslodApi` がある旧データでは移行が開始されない
- 表示コンポーネント `AddressText` から移行副作用を起動している
### 3.4 駅マスター読み込み前のレース
`Sign``useState(getStationDataFromId(stationID))` で初回検索結果を固定している。
駅マスターの非同期読み込み前に描画されると空配列がstateへ固定され、駅マスター読み込み後も再検索されない。その後の `[0]` 参照でクラッシュする可能性がある。削除済み駅対策と同時に、この起動時レースも解消する必要がある。
## 4. 設計方針
### 4.1 お気に入りの物理駅キー
お気に入りの同一性は、駅データ配列全体や単一の駅番号ではなく「物理駅」を表す安定キーで判定する。
当面のキー仕様は次の通りとする。
1. 有効な先頭要素の `Station_JP` をtrimした文字列
2. 駅名が取得できない場合のみ、有効な `StationNumber` をフォールバックとして使用
3. 空配列、駅名・駅番号の双方がない値は無効
対応路線内では、徳島・高知・多度津のような同一駅名は同一の物理駅として扱う。将来、同名だが別の物理駅を収録する場合は、専用の物理駅IDを駅マスターへ追加する。
### 4.2 保存形式
今回のホットフィックスでは影響範囲を抑えるため、保存形式 `StationProps[][]` は維持する。
ただし、保存される各要素は必ず現在の駅マスターから再構築した正規形とする。
- 徳島は常に `[T00, B00]`
- 高知は常に `[D45, K00]`
- 多度津は常に `[Y12, D12]`
- 存在しない駅は保存しない
- 同じ物理駅は1件だけ保存する
## 5. 実装内容
### フェーズA: 純粋な正規化処理の追加
お気に入り処理を表示コンポーネントから分離し、テスト可能な純粋関数として実装する。
想定する処理は次の通り。
1. ストレージ値が配列か検証する
2. 各要素から有効な駅オブジェクトを1件探す
3. 駅名を使って現在の駅マスターから同名駅グループを取得する
4. 駅名がない場合は駅番号検索をフォールバックとして使う
5. 現在の駅マスターで解決できない駅を除外する
6. 物理駅キーで重複を除外する
7. 元のお気に入り順を維持する
8. 正規化済み配列を返す
正規化処理は同じ入力へ複数回適用しても結果が変わらない、冪等な処理にする。
### フェーズB: 起動時ロードと永続化の統合
`FavoriteStationProvider` の起動処理を次の順番へ変更する。
1. 駅マスターの読み込み完了を待つ
2. `favoriteStation` をAsyncStorageから読み込む
3. JSONおよび配列構造を検証する
4. 現在の駅マスターへ正規化する
5. React stateを正規化結果で更新する
6. 読み込み値と正規化結果が異なる場合だけAsyncStorageへ保存する
空・破損・旧形式の値はクラッシュさせず、空のお気に入りとして復旧する。ストレージの読み書き失敗は既存loggerへ記録する。
現在の `lodAddMigration()` と、`AddressText` から移行を起動する処理は削除する。
### フェーズC: お気に入り操作APIの一元化
`FavoriteStationContext` に次の操作を集約する。
- `isFavoriteStation(stationGroup)`
- `addFavoriteStation(stationGroup)`
- `removeFavoriteStation(stationGroup)`
- `toggleFavoriteStation(stationGroup)`
- 並び替え用の `replaceFavoriteStations(stationGroups)`
登録・解除は物理駅キーで判定し、`JSON.stringify()` 完全一致を廃止する。
更新時は配列を直接 `push()` せず、新しい配列を生成する。state更新とAsyncStorage保存は同じ正規化済みデータを使用する。
### フェーズD: 表示側の防御
`Sign` を次のように変更する。
- 駅マスター読み込み後または `stationID` 変更後に駅データを再解決する
- 初回検索結果を `useState` へ固定しない
- 解決結果が空の場合は `[0]` を参照せず、安全なプレースホルダーまたは非表示を返す
- お気に入り状態とトグル操作はContextの物理駅キーAPIを使う
合わせて、次の利用箇所でも空配列を防御する。
- お気に入りカルーセル
- お気に入りクイック移動
- お気に入り並び替え設定
- WebView初期移動での先頭お気に入り参照
- 駅詳細画面
正規化が正常に動いた場合でも、描画側の防御は将来の駅マスター変更やストレージ破損に備えて恒久的に残す。
### フェーズE: 津島ノ宮削除への備え
この計画の実装時点では、津島ノ宮の駅マスター・走行位置インジェクションを直ちに削除しない。
先に正規化・防御版を配信して動作を確認する。その後、津島ノ宮を削除する版では、起動時正規化が `Y13-x` を解決不能として除外することを確認する。
最新バージョンへ直接更新する利用者もいるため、津島ノ宮削除版にも正規化処理と空配列防御を必ず含める。
## 6. 変更予定ファイル
主な対象は次の通り。実装時の責務分割に応じてファイル名は調整する。
- `stateBox/useFavoriteStation.tsx`
- 起動時正規化、永続化、操作APIの一元化
- `stateBox/useStationList.tsx`
- 駅マスター準備状態の提供、駅検索関数の安定化
- `components/駅名表/Sign.tsx`
- 再解決、空配列防御、物理駅キーによる登録・解除
- `components/駅名表/AddressText.tsx`
- 表示中に実行している旧移行処理の撤去
- `components/FavoriteList.tsx`
- 無効データ防御
- `components/Settings/FavoriteSettings.tsx`
- 安定キーと正規化済み並び替え保存
- `components/Apps/WebView.tsx`
- 先頭お気に入り参照の防御
- `lib/favoriteStationUtils.ts`(新規候補)
- 物理駅キー、検証、正規化、重複排除の純粋関数
## 7. 検証計画
### 7.1 正規化ケース
- 現行形式の通常駅1件は変更されない
- 旧形式の徳島 `[T00]``[T00, B00]` へ変換される
- 旧形式の高知 `[D45]``[D45, K00]` へ変換される
- 旧形式の多度津 `[Y12]``[Y12, D12]` へ変換される
- `[T00]``[T00, B00]` が共存していても徳島1件になる
- 同じ物理駅が複数回保存されていても最初の1件だけ残る
- `Y13-x` が駅マスターに存在しない条件では津島ノ宮が除外される
- 空配列、`null`、壊れたオブジェクトが除外される
- お気に入りの並び順が維持される
- 正規化を2回適用しても結果が変化しない
### 7.2 操作ケース
- 徳島・高知・多度津を1回の操作で登録できる
- 同じ駅を別路線側の駅番号から開いても重複登録されない
- どちらの路線側からでも1回で解除できる
- 解除後に同じ駅がもう1件現れない
- 通常駅の登録・解除に退行がない
- 並び替え後も正規形と順番が保存される
### 7.3 起動・更新ケース
- 駅マスター読み込み前にお気に入りが復元されてもクラッシュしない
- 削除済み `Y13-x` を含むストレージで起動してもクラッシュしない
- ストレージが空、未作成、壊れたJSONでも起動できる
- 正規化が必要な場合だけAsyncStorageが更新される
- アプリ再起動後も正規化結果が維持され、毎回移行されない
- EAS Update適用後およびロールバック相当のデータ組み合わせで起動できる
### 7.4 静的・実機確認
- `npx tsc --noEmit`
- `git diff --check`
- Android実機でお気に入りカルーセル、クイック移動、設定画面、駅詳細を確認
- Sentryで `StationNumber of undefined``Station_JP of undefined`、お気に入り関連クラッシュの再発を確認
テストフレームワークを新規導入する大規模変更は避ける。正規化ロジックは純粋関数にし、必要であれば `npx tsx` で実行できる小さな回帰確認スクリプトを追加する。
## 8. ロールアウト手順
1. Android側ブランチでフェーズA〜Dを実装
2. 型チェック、差分チェック、手動フィクスチャ検証を実施
3. Android側ブランチでコミット
4. 旧側ブランチへコミットをチェリーピック
5. 両ブランチの差分と型チェックを確認
6. 対象EASチャンネルへ防御版を配信
7. Sentryおよび利用者報告でクラッシュ・重複問題を監視
8. 問題がないことを確認後、別コミットで津島ノ宮の削除を実施
## 9. 完了条件
- 削除済み駅を含むお気に入りデータでアプリがクラッシュしない
- 存在しない駅は起動時にstateとAsyncStorageの両方から除外される
- 徳島・高知・多度津が物理駅単位で1件に正規化される
- 複数路線駅を1回で登録・解除でき、重複が再生成されない
- 旧移行処理が表示コンポーネントから撤去されている
- 正規化処理が冪等で、アプリ再起動後も同じ結果になる
- Android側で先行コミットし、そのコミットを旧側へチェリーピックできる状態になっている
## 10. 対象外
- この計画書作成時点での津島ノ宮データ削除
- お気に入り保存形式を駅IDだけの新スキーマへ全面変更すること
- 対応路線外に存在する同名別駅への一般化
- お気に入りUIのデザイン変更
@@ -0,0 +1,40 @@
# operation-info `areaInfo` 観察メモ
更新日: 2026-07-30
## 方針
`latest.json``compatibility.areaInfo` は、現時点で値域をアプリ側から
過度に固定しない。実際の運行情報発生時・正常復帰時・公式ページ変更時のデータを
観察し、確認できた挙動に合わせて順次型定義と判定処理を調整する。
当面は次の方針を維持する。
- 路線要素の `status``boolean` として扱う。
- `area === "genelic"``status` は公式HTML由来の文字列として扱う。
- 未知の `area` は無理に駅IDへ変換せず、安全に読み飛ばす。
- `OperationInfoAreaState.status` は、観察が進むまで `boolean | string` を維持する。
- automation側・アプリ側の値域を推測だけで狭めない。
## 現時点で確認できている値
- 公開中の正常時JSON: 路線は `false``genelic``"nodelay"`
- automation側の異常時fixture: 影響路線は `true``genelic``"delay"`
- automation側の正常時fixtureには、`genelic``"normal"` になる入力もある。
- automationは `genelic.status` を正規化せず、公式HTMLのclass文字列を格納する。
## 観察項目
実データで運行情報が発生・更新・解除された際は、次を確認する。
1. `status``compatibility.hasOperationInfo` が一致するか。
2. `operationInfoText` が空でないとき、影響路線の `status` がどうなるか。
3. 予告・参考情報など、本文はあるが影響路線がないケースが存在するか。
4. `genelic.status``"nodelay"` / `"delay"` 以外の値が現れるか。
5. 既知10路線以外の `area` が追加されるか。
6. 正常復帰時に、本文・路線フラグ・`genelic.status` が同じ更新で切り替わるか。
観察時は最低限、`fetchedAt``contentHash``stateHash`
`compatibility` 全体を記録する。個別事例が集まった段階で、
`hasOperationInfo` を中心とした判定への整理、discriminated union化、
runtime validation追加を再検討する。
+130
View File
@@ -0,0 +1,130 @@
import type { OriginalStationList, StationProps } from "@/lib/CommonTypes";
export type FavoriteStationGroup = StationProps[];
export type FavoriteStationReorderFailureReason =
| "before_not_array"
| "candidate_not_array"
| "count_mismatch"
| "before_key_missing"
| "candidate_key_missing"
| "before_key_duplicate"
| "candidate_key_duplicate"
| "key_set_mismatch";
export type FavoriteStationReorderResult =
| { ok: true; groups: FavoriteStationGroup[] }
| { ok: false; reason: FavoriteStationReorderFailureReason };
/** 同一の物理駅を判定するための、保存形式に依存しないキー。 */
export const getFavoriteStationKey = (stationGroup: unknown): string | undefined => {
if (!Array.isArray(stationGroup)) return undefined;
const station = stationGroup.find(isFavoriteStationEntry);
if (!station) return undefined;
const name = typeof station.Station_JP === "string" ? station.Station_JP.trim() : "";
if (name) return `name:${name}`;
const number = typeof station.StationNumber === "string" ? station.StationNumber.trim() : "";
return number ? `number:${number}` : undefined;
};
const isStationProps = (value: unknown): value is StationProps =>
!!value && typeof value === "object" && !Array.isArray(value);
/** 駅名または駅番号を持つ要素だけを、保存値として有効な駅とみなす。 */
const isFavoriteStationEntry = (value: unknown): value is StationProps => {
if (!isStationProps(value)) return false;
return (typeof value.Station_JP === "string" && value.Station_JP.trim().length > 0) ||
(typeof value.StationNumber === "string" && value.StationNumber.trim().length > 0);
};
const isFavoriteStationGroup = (value: unknown): value is FavoriteStationGroup =>
Array.isArray(value) && value.length > 0 && value.every(isFavoriteStationEntry);
const findStationGroup = (station: StationProps, originalStationList: OriginalStationList): FavoriteStationGroup | undefined => {
const allStations = Object.values(originalStationList).flat();
const name = typeof station.Station_JP === "string" ? station.Station_JP.trim() : "";
const number = typeof station.StationNumber === "string" ? station.StationNumber.trim() : "";
const nameMatches = name
? allStations.filter((candidate) => candidate.Station_JP?.trim() === name)
: [];
const matches = nameMatches.length > 0
? nameMatches
: number ? allStations.filter((candidate) => candidate.StationNumber === number) : [];
const validMatches = matches.filter((candidate) => !!candidate.jslodApi);
return validMatches.length ? validMatches : undefined;
};
/** 保存済みのお気に入りを現行の駅マスターへ再解決する。 */
export const normalizeFavoriteStations = (value: unknown, originalStationList: OriginalStationList): FavoriteStationGroup[] => {
if (!Array.isArray(value) || Object.keys(originalStationList).length === 0) return [];
const seen = new Set<string>();
const normalized: FavoriteStationGroup[] = [];
value.forEach((entry) => {
if (!Array.isArray(entry)) return;
const station = entry.find(isFavoriteStationEntry);
if (!station) return;
const stationGroup = findStationGroup(station, originalStationList);
const key = getFavoriteStationKey(stationGroup);
if (!stationGroup || !key || seen.has(key)) return;
seen.add(key);
normalized.push(stationGroup);
});
return normalized;
};
/**
* 並び替え候補が現在のお気に入りの厳密な順列か検証し、
* 候補のデータは採用せず、現在のcanonical groupだけで並べ直す。
*
* 並び替えUIから返る値は表示用データなので、駅キー以外の内容を
* 信頼して永続化しない。入力はunknownとして受け、破損値もfalseで返す。
*/
export const validateAndRebuildFavoriteStationOrder = (
current: unknown,
candidate: unknown,
): FavoriteStationReorderResult => {
if (!Array.isArray(current)) return { ok: false, reason: "before_not_array" };
if (!Array.isArray(candidate)) return { ok: false, reason: "candidate_not_array" };
if (current.length !== candidate.length) return { ok: false, reason: "count_mismatch" };
const currentByKey = new Map<string, FavoriteStationGroup>();
for (const group of current) {
const key = getFavoriteStationKey(group);
if (!key) return { ok: false, reason: "before_key_missing" };
if (currentByKey.has(key)) return { ok: false, reason: "before_key_duplicate" };
currentByKey.set(key, group);
}
const candidateKeys: string[] = [];
const candidateKeySet = new Set<string>();
for (const group of candidate) {
const key = getFavoriteStationKey(group);
if (!key) return { ok: false, reason: "candidate_key_missing" };
if (candidateKeySet.has(key)) return { ok: false, reason: "candidate_key_duplicate" };
candidateKeys.push(key);
candidateKeySet.add(key);
}
if (candidateKeySet.size !== currentByKey.size ||
[...currentByKey.keys()].some((key) => !candidateKeySet.has(key))) {
return { ok: false, reason: "key_set_mismatch" };
}
return { ok: true, groups: candidateKeys.map((key) => currentByKey.get(key) as FavoriteStationGroup) };
};
const hasSameStationProps = (a: StationProps, b: StationProps) => {
const keys = Object.keys(a) as (keyof StationProps)[];
return keys.length === Object.keys(b).length &&
keys.every((key) => a[key] === b[key]);
};
/** 破損したストレージ値も安全に比較し、不正な値は常に不一致とする。 */
export const hasSameFavoriteStations = (a: unknown, b: unknown) =>
Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((group, index) => {
const other = b[index];
if (!isFavoriteStationGroup(group) || !isFavoriteStationGroup(other)) return false;
return getFavoriteStationKey(group) === getFavoriteStationKey(other) &&
group.length === other?.length &&
group.every((station, stationIndex) => hasSameStationProps(station, other[stationIndex]));
});
+28 -30
View File
@@ -88,23 +88,10 @@ export const stationNamePair = {
"瀬戸大橋線(児島 - 宇多津)": "seto", "瀬戸大橋線(児島 - 宇多津)": "seto",
}; };
export const getStationList = async () => { export const getStationList = () => {
if (status) return status; if (status) return status;
//駅リストイニシャライズ //駅リストイニシャライズ
return await Promise.all([ let stationList: { [key: string]: any } = {};
yosan,
uwajima,
uwajima2,
dosan,
dosan2,
koutoku,
tokushima,
naruto,
seto,
between,
train_lang,
]).then((values) => {
let stationList = {};
[ [
stationList["予讃線(高松-松山間)[Y]"], stationList["予讃線(高松-松山間)[Y]"],
stationList["予讃線(松山-宇和島間)[U]"], stationList["予讃線(松山-宇和島間)[U]"],
@@ -117,16 +104,28 @@ export const getStationList = async () => {
stationList["瀬戸大橋線(児島-宇多津間)[M]"], stationList["瀬戸大橋線(児島-宇多津間)[M]"],
stationList["駅間リスト"], stationList["駅間リスト"],
stationList["日英対応表"], stationList["日英対応表"],
] = values; ] = [
yosan,
uwajima,
uwajima2,
dosan,
dosan2,
koutoku,
tokushima,
naruto,
seto,
between,
train_lang,
];
const concatBetweenStations = (eachRouteData) => { const concatBetweenStations = (eachRouteData) => {
let additional = []; let additional = [];
eachRouteData.forEach((routeData, routeIndex) => { eachRouteData.forEach((routeData, routeIndex) => {
try { try {
const currentStationID = parseInt( const currentStationID = parseInt(
routeData.StationNumber.replace(/[A-Z]/g, "") routeData.StationNumber.replace(/[A-Z]/g, ""),
); );
const nextStationID = parseInt( const nextStationID = parseInt(
eachRouteData[routeIndex + 1].StationNumber.replace(/[A-Z]/g, "") eachRouteData[routeIndex + 1].StationNumber.replace(/[A-Z]/g, ""),
); );
if (nextStationID - currentStationID != 1) { if (nextStationID - currentStationID != 1) {
stationList["駅間リスト"].forEach((betweenList) => { stationList["駅間リスト"].forEach((betweenList) => {
@@ -156,7 +155,7 @@ export const getStationList = async () => {
stationName = data.StationName; stationName = data.StationName;
data.Station_JP = data.StationName; data.Station_JP = data.StationName;
data.Station_EN = EnJpList.find( data.Station_EN = EnJpList.find(
(d) => d.Station_JP == data.Station_JP (d) => d.Station_JP == data.Station_JP,
).Station_EN; ).Station_EN;
} }
geoJson.features geoJson.features
@@ -174,50 +173,50 @@ export const getStationList = async () => {
stationList["予讃線(高松-松山間)[Y]"] = addStationPosition( stationList["予讃線(高松-松山間)[Y]"] = addStationPosition(
concatBetweenStations(stationList["予讃線(高松-松山間)[Y]"]), concatBetweenStations(stationList["予讃線(高松-松山間)[Y]"]),
, ,
stationList["日英対応表"] stationList["日英対応表"],
); );
stationList["予讃線(松山-宇和島間)[U]"] = addStationPosition( stationList["予讃線(松山-宇和島間)[U]"] = addStationPosition(
concatBetweenStations(stationList["予讃線(松山-宇和島間)[U]"]), concatBetweenStations(stationList["予讃線(松山-宇和島間)[U]"]),
, ,
stationList["日英対応表"] stationList["日英対応表"],
); );
stationList["予讃線(松山-宇和島間)[U]"] = addStationPosition( stationList["予讃線(松山-宇和島間)[U]"] = addStationPosition(
concatBetweenStations(stationList["予讃線(松山-宇和島間)[U]"]), concatBetweenStations(stationList["予讃線(松山-宇和島間)[U]"]),
, ,
stationList["日英対応表"] stationList["日英対応表"],
); );
stationList["予讃線/愛ある伊予灘線(向井原-伊予大洲間)[S]"] = stationList["予讃線/愛ある伊予灘線(向井原-伊予大洲間)[S]"] =
addStationPosition( addStationPosition(
concatBetweenStations( concatBetweenStations(
stationList["予讃線/愛ある伊予灘線(向井原-伊予大洲間)[S]"] stationList["予讃線/愛ある伊予灘線(向井原-伊予大洲間)[S]"],
), ),
, ,
stationList["日英対応表"] stationList["日英対応表"],
); );
stationList["土讃線(多度津-高知間)[D]"] = addStationPosition( stationList["土讃線(多度津-高知間)[D]"] = addStationPosition(
concatBetweenStations(stationList["土讃線(多度津-高知間)[D]"]), concatBetweenStations(stationList["土讃線(多度津-高知間)[D]"]),
, ,
stationList["日英対応表"] stationList["日英対応表"],
); );
stationList["土讃線(高知-窪川間)[K]"] = addStationPosition( stationList["土讃線(高知-窪川間)[K]"] = addStationPosition(
concatBetweenStations(stationList["土讃線(高知-窪川間)[K]"]), concatBetweenStations(stationList["土讃線(高知-窪川間)[K]"]),
, ,
stationList["日英対応表"] stationList["日英対応表"],
); );
stationList["高徳線(高松-徳島間)[T]"] = addStationPosition( stationList["高徳線(高松-徳島間)[T]"] = addStationPosition(
concatBetweenStations(stationList["高徳線(高松-徳島間)[T]"]), concatBetweenStations(stationList["高徳線(高松-徳島間)[T]"]),
, ,
stationList["日英対応表"] stationList["日英対応表"],
); );
stationList["鳴門線(池谷-鳴門間)[N]"] = addStationPosition( stationList["鳴門線(池谷-鳴門間)[N]"] = addStationPosition(
concatBetweenStations(stationList["鳴門線(池谷-鳴門間)[N]"]), concatBetweenStations(stationList["鳴門線(池谷-鳴門間)[N]"]),
, ,
stationList["日英対応表"] stationList["日英対応表"],
); );
stationList["徳島線(徳島-阿波池田間)[B]"] = addStationPosition( stationList["徳島線(徳島-阿波池田間)[B]"] = addStationPosition(
concatBetweenStations(stationList["徳島線(徳島-阿波池田間)[B]"]), concatBetweenStations(stationList["徳島線(徳島-阿波池田間)[B]"]),
, ,
stationList["日英対応表"] stationList["日英対応表"],
); );
stationList["徳島線(徳島-阿波池田間)[B]"].pop(); stationList["徳島線(徳島-阿波池田間)[B]"].pop();
stationList["瀬戸大橋線(児島-宇多津間)[M]"] = [ stationList["瀬戸大橋線(児島-宇多津間)[M]"] = [
@@ -268,5 +267,4 @@ export const getStationList = async () => {
stationList["観光スポット"] = spots; stationList["観光スポット"] = spots;
status = stationList; status = stationList;
return stationList; return stationList;
});
}; };
+3 -2
View File
@@ -1,4 +1,5 @@
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import dayjs from "dayjs";
import { Directory, File, Paths } from 'expo-file-system'; import { Directory, File, Paths } from 'expo-file-system';
import { AS } from '../../storageControl'; import { AS } from '../../storageControl';
@@ -306,7 +307,7 @@ export const buildRecordingExportText = async (id: string): Promise<string> => {
const payload: RecordingExportEnvelope = { const payload: RecordingExportEnvelope = {
format: RECORDING_EXPORT_FORMAT, format: RECORDING_EXPORT_FORMAT,
version: 1, version: 1,
exportedAt: new Date().toISOString(), exportedAt: dayjs().toISOString(),
recording, recording,
}; };
@@ -323,7 +324,7 @@ export const buildAllRecordingsExportText = async (): Promise<string> => {
const payload: RecordingsExportEnvelope = { const payload: RecordingsExportEnvelope = {
format: RECORDINGS_EXPORT_FORMAT, format: RECORDINGS_EXPORT_FORMAT,
version: 1, version: 1,
exportedAt: new Date().toISOString(), exportedAt: dayjs().toISOString(),
recordings, recordings,
}; };
@@ -2,6 +2,7 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
import { AppState, AppStateStatus, Platform } from "react-native"; import { AppState, AppStateStatus, Platform } from "react-native";
import * as Sentry from "@sentry/react-native"; import * as Sentry from "@sentry/react-native";
import * as Updates from "expo-updates"; import * as Updates from "expo-updates";
import dayjs from "dayjs";
import { lastObservedRootRouteRef } from "@/lib/rootNavigation"; import { lastObservedRootRouteRef } from "@/lib/rootNavigation";
const STORAGE_KEY = "@jrshikoku/app_crash_sentinel_v1"; const STORAGE_KEY = "@jrshikoku/app_crash_sentinel_v1";
@@ -41,7 +42,7 @@ let appStateSubscription: { remove: () => void } | null = null;
let latestNavigationSnapshot: RootNavigationSnapshot | null = null; let latestNavigationSnapshot: RootNavigationSnapshot | null = null;
const activeWebViews = new Set<string>(); const activeWebViews = new Set<string>();
const nowIso = () => new Date().toISOString(); const nowIso = () => dayjs().toISOString();
const safeJsonParse = <T>(value: string | null): T | null => { const safeJsonParse = <T>(value: string | null): T | null => {
if (!value) return null; if (!value) return null;
@@ -101,8 +102,8 @@ const shouldReportUnexpectedExit = (previous: CrashSentinelState, nowMs: number)
if (previous.normal_background) return false; if (previous.normal_background) return false;
if (previous.reportedUnexpectedExitForSessionStartedAt === previous.sessionStartedAt) return false; if (previous.reportedUnexpectedExitForSessionStartedAt === previous.sessionStartedAt) return false;
const heartbeatMs = Date.parse(previous.lastHeartbeatAt); const heartbeatMs = dayjs(previous.lastHeartbeatAt).valueOf();
const startedMs = Date.parse(previous.sessionStartedAt); const startedMs = dayjs(previous.sessionStartedAt).valueOf();
if (!Number.isFinite(heartbeatMs) || !Number.isFinite(startedMs)) return false; if (!Number.isFinite(heartbeatMs) || !Number.isFinite(startedMs)) return false;
const heartbeatAgeMs = nowMs - heartbeatMs; const heartbeatAgeMs = nowMs - heartbeatMs;
@@ -132,7 +133,7 @@ const reportUnexpectedExit = async (previous: CrashSentinelState, nativeScreensM
memory: getMemoryInfo(), memory: getMemoryInfo(),
expoUpdate: getExpoUpdateContext(), expoUpdate: getExpoUpdateContext(),
normalBackground: previous.normal_background, normalBackground: previous.normal_background,
heartbeatAgeMs: Date.now() - Date.parse(previous.lastHeartbeatAt), heartbeatAgeMs: Date.now() - dayjs(previous.lastHeartbeatAt).valueOf(),
}, },
}, },
fingerprint: ["app_lifecycle", "unexpected_exit", Platform.OS], fingerprint: ["app_lifecycle", "unexpected_exit", Platform.OS],
+2 -1
View File
@@ -26,7 +26,8 @@ export type DataFetchSource =
| "gas" | "gas"
| "backend_api" | "backend_api"
| "static_storage" | "static_storage"
| "webview_fetch"; | "webview_fetch"
| "r2";
export type FetchPriority = "high" | "medium" | "low"; export type FetchPriority = "high" | "medium" | "low";
+41
View File
@@ -0,0 +1,41 @@
import dayjs from "dayjs";
const SOURCE_UPDATE_INTERVAL_MS = 5 * 60 * 1000;
const FETCH_WINDOW_START_MS = 10 * 1000;
const FETCH_WINDOW_END_MS = 50 * 1000;
const STALE_RETRY_MS = 30 * 1000;
const MIN_TIMER_DELAY_MS = 1000;
export const OPERATION_INFO_STALE_RETRY_MS = STALE_RETRY_MS;
/**
* R2の最終更新時刻を基準に51050
* fetchedAtが進んでいない場合は
*/
export function getNextOperationInfoFetchDelay(
fetchedAt: string,
nowMs = Date.now(),
randomValue = Math.random()
): number {
const fetchedAtMs = dayjs(fetchedAt).valueOf();
if (!Number.isFinite(fetchedAtMs)) {
return STALE_RETRY_MS;
}
const nextExpectedUpdateMs = fetchedAtMs + SOURCE_UPDATE_INTERVAL_MS;
const fetchWindowEndMs = nextExpectedUpdateMs + FETCH_WINDOW_END_MS;
const fetchWindowStartMs = Math.max(
nextExpectedUpdateMs + FETCH_WINDOW_START_MS,
nowMs + MIN_TIMER_DELAY_MS
);
if (fetchWindowStartMs >= fetchWindowEndMs) {
return STALE_RETRY_MS;
}
const clampedRandomValue = Math.min(1, Math.max(0, randomValue));
const targetMs =
fetchWindowStartMs +
(fetchWindowEndMs - fetchWindowStartMs) * clampedRandomValue;
return Math.max(MIN_TIMER_DELAY_MS, Math.round(targetMs - nowMs));
}
+74
View File
@@ -0,0 +1,74 @@
import dayjs, { type Dayjs } from "dayjs";
import customParseFormat from "dayjs/plugin/customParseFormat";
dayjs.extend(customParseFormat);
const CLOCK_DATE = "2000-01-01";
const CLOCK_TIME_FORMATS = [
"YYYY-MM-DD H:mm",
"YYYY-MM-DD HH:mm",
];
/** Parse a timetable clock value strictly as H:mm or HH:mm. */
export const parseClockTime = (time: string): Dayjs | null => {
const value = time?.trim();
if (!value) return null;
const parsed = dayjs(`${CLOCK_DATE} ${value}`, CLOCK_TIME_FORMATS, true);
return parsed.isValid() ? parsed : null;
};
/** Normalize a timetable clock value to HH:mm, preserving invalid input. */
export const normalizeTime = (time: string) =>
parseClockTime(time)?.format("HH:mm") ?? time;
/** Set a clock value on a base date without applying service-day rollover. */
export const setClockTime = (base: Dayjs, time: string, delayMinutes = 0) => {
const parsed = parseClockTime(time);
if (!parsed) return null;
return base
.clone()
.hour(parsed.hour())
.minute(parsed.minute())
.second(0)
.millisecond(0)
.add(delayMinutes, "minute");
};
/** Set a timetable value on a base date, treating 00:00-03:59 as service-day late night. */
export const setServiceTime = (base: Dayjs, time: string, delayMinutes = 0) => {
const parsed = parseClockTime(time);
if (!parsed) return null;
return base
.clone()
.hour(parsed.hour() < 4 ? parsed.hour() + 24 : parsed.hour())
.minute(parsed.minute())
.second(0)
.millisecond(0)
.add(delayMinutes, "minute");
};
export const getServiceTimeDifference = (
base: Dayjs,
time: string,
delayMinutes = 0
) => {
const parsed = parseClockTime(time);
const target = setServiceTime(base, time, delayMinutes);
if (!parsed || !target) return null;
const adjustedTarget =
base.hour() < 4 && parsed.hour() < 4
? target.subtract(1, "day")
: target;
return adjustedTarget.diff(base, "minute");
};
export const getServiceMinute = (time: string) => {
const parsed = parseClockTime(time);
if (!parsed) return null;
return (parsed.hour() < 4 ? parsed.hour() + 24 : parsed.hour()) * 60 + parsed.minute();
};
+21 -13
View File
@@ -1,4 +1,12 @@
import dayjs from "dayjs"; import dayjs from "dayjs";
import {
normalizeTime,
parseClockTime,
setServiceTime,
} from "@/lib/timeUtils";
export { normalizeTime } from "@/lib/timeUtils";
import { checkDuplicateTrainData } from "@/lib/checkDuplicateTrainData"; import { checkDuplicateTrainData } from "@/lib/checkDuplicateTrainData";
import { trainDataType, trainPosition } from "@/lib/trainPositionTextArray"; import { trainDataType, trainPosition } from "@/lib/trainPositionTextArray";
import { eachTrainDiagramType, StationProps } from "@/lib/CommonTypes"; import { eachTrainDiagramType, StationProps } from "@/lib/CommonTypes";
@@ -16,9 +24,11 @@ export const trainTimeFiltering: (x: trainDataProps) => boolean = (props) => {
const currentTrainMatches = currentTrain.filter((t) => t.num == d.train); const currentTrainMatches = currentTrain.filter((t) => t.num == d.train);
if (currentTrainMatches.length == 0) { if (currentTrainMatches.length == 0) {
const date = now ? dayjs(now) : dayjs(); const date = now ? dayjs(now) : dayjs();
const parsedTime = parseClockTime(d.time);
if (!parsedTime) return false;
const trainTime = date const trainTime = date
.hour(parseInt(d.time.split(":")[0])) .hour(parsedTime.hour())
.minute(parseInt(d.time.split(":")[1])); .minute(parsedTime.minute());
if (date.isAfter(trainTime)) { if (date.isAfter(trainTime)) {
return false; return false;
@@ -71,12 +81,13 @@ export const trainTimeFiltering: (x: trainDataProps) => boolean = (props) => {
} }
break; break;
} }
const [h, m] = d.time.split(":");
const delayData = currentTrainData.delay; const delayData = currentTrainData.delay;
let delay = delayData === "入線" ? 0 : delayData; let delay = delayData === "入線" ? 0 : delayData;
const date = now ? dayjs(now) : dayjs(); const date = now ? dayjs(now) : dayjs();
const IntH = parseInt(h); const parsedTime = parseClockTime(d.time);
const IntM = parseInt(m); if (!parsedTime) return false;
const IntH = parsedTime.hour();
const IntM = parsedTime.minute();
const currentHour = date.hour(); const currentHour = date.hour();
// 0時~4時未満は、現在時刻が4時以上の場合のみ翌日として扱う // 0時~4時未満は、現在時刻が4時以上の場合のみ翌日として扱う
@@ -107,9 +118,9 @@ export const getTime: getTimeProps = (stationDiagram, station) => {
.split("#") .split("#")
.map((data) => { .map((data) => {
const [stationName, type, time, platformNum] = data.split(","); const [stationName, type, time, platformNum] = data.split(",");
return { stationName, type, time, platformNum }; return { stationName, type, time: normalizeTime(time), platformNum };
}) })
.filter((entry) => entry.stationName && entry.type && entry.time); .filter((entry) => entry.stationName && entry.type && parseClockTime(entry.time));
const firstTimedEntry = diagramEntries[0]; const firstTimedEntry = diagramEntries[0];
let trainData: eachTrainDiagramType = { let trainData: eachTrainDiagramType = {
@@ -166,13 +177,10 @@ export const getTime: getTimeProps = (stationDiagram, station) => {
}) })
.filter((d) => d.time); .filter((d) => d.time);
return returnData.sort((a, b) => { return returnData.sort((a, b) => {
let [aH, aM] = a.time.split(":");
let [bH, bM] = b.time.split(":");
if (parseInt(aH) < 4) aH = (parseInt(aH) + 24).toString();
if (parseInt(bH) < 4) bH = (parseInt(bH) + 24).toString();
const baseTime = dayjs(); const baseTime = dayjs();
const aTime = baseTime.hour(parseInt(aH)).minute(parseInt(aM)); const aTime = setServiceTime(baseTime, a.time);
const bTime = baseTime.hour(parseInt(bH)).minute(parseInt(bM)); const bTime = setServiceTime(baseTime, b.time);
if (!aTime || !bTime) return 0;
if (aTime.isBefore(bTime)) return -1; if (aTime.isBefore(bTime)) return -1;
if (aTime.isAfter(bTime)) return 1; if (aTime.isAfter(bTime)) return 1;
return 0; return 0;
+33 -4
View File
@@ -12,6 +12,11 @@ type WebViewRemountReason =
| "blank_detected"; | "blank_detected";
type WebViewRemountData = Record<string, string | number | boolean | null | undefined>; type WebViewRemountData = Record<string, string | number | boolean | null | undefined>;
type WebViewWatchdogReason = Extract<
WebViewRemountReason,
"loading_timeout" | "pong_timeout" | "blank_detected"
>;
type WebViewWatchdogMode = "remount" | "report_only";
type UseWebViewRemountOptions = { type UseWebViewRemountOptions = {
pingEnabled?: boolean; pingEnabled?: boolean;
@@ -19,7 +24,10 @@ type UseWebViewRemountOptions = {
isFocused?: boolean; isFocused?: boolean;
pauseWatchdogWhenUnfocused?: boolean; pauseWatchdogWhenUnfocused?: boolean;
ignoreProcessTerminationWhenUnfocused?: boolean; ignoreProcessTerminationWhenUnfocused?: boolean;
/** watchdog検知時にWebViewを再生成するか、観測だけにするか */
watchdogMode?: WebViewWatchdogMode;
onRemount?: (reason: WebViewRemountReason, data?: WebViewRemountData) => void; onRemount?: (reason: WebViewRemountReason, data?: WebViewRemountData) => void;
onWatchdog?: (reason: WebViewWatchdogReason, data?: WebViewRemountData) => void;
}; };
/** /**
@@ -39,7 +47,9 @@ export function useWebViewRemount(options?: UseWebViewRemountOptions) {
const isFocused = options?.isFocused ?? true; const isFocused = options?.isFocused ?? true;
const pauseWatchdogWhenUnfocused = options?.pauseWatchdogWhenUnfocused ?? false; const pauseWatchdogWhenUnfocused = options?.pauseWatchdogWhenUnfocused ?? false;
const ignoreProcessTerminationWhenUnfocused = options?.ignoreProcessTerminationWhenUnfocused ?? false; const ignoreProcessTerminationWhenUnfocused = options?.ignoreProcessTerminationWhenUnfocused ?? false;
const watchdogMode = options?.watchdogMode ?? "remount";
const onRemount = options?.onRemount; const onRemount = options?.onRemount;
const onWatchdog = options?.onWatchdog;
const [remountKey, setRemountKey] = useState(0); const [remountKey, setRemountKey] = useState(0);
const backgroundedAt = useRef<number | null>(null); const backgroundedAt = useRef<number | null>(null);
const webViewRef = useRef<WebView>(null); const webViewRef = useRef<WebView>(null);
@@ -51,6 +61,7 @@ export function useWebViewRemount(options?: UseWebViewRemountOptions) {
// ping watchdog 用 // ping watchdog 用
const lastPongAt = useRef(Date.now()); const lastPongAt = useRef(Date.now());
const isLoadingRef = useRef(true); const isLoadingRef = useRef(true);
const watchdogReportedRef = useRef(false);
const triggerRemount = useCallback((reason: WebViewRemountReason, data?: WebViewRemountData) => { const triggerRemount = useCallback((reason: WebViewRemountReason, data?: WebViewRemountData) => {
onRemount?.(reason, data); onRemount?.(reason, data);
@@ -63,6 +74,20 @@ export function useWebViewRemount(options?: UseWebViewRemountOptions) {
triggerRemount("manual"); triggerRemount("manual");
}, [triggerRemount]); }, [triggerRemount]);
const triggerWatchdog = useCallback((reason: WebViewWatchdogReason, data?: WebViewRemountData) => {
if (watchdogMode === "report_only") {
// 同じ異常状態を5秒ごとにSentryへ送り続けない。
// 正常なpong、または次のロード開始/完了で再び観測可能にする。
if (watchdogReportedRef.current) return;
watchdogReportedRef.current = true;
onWatchdog?.(reason, data);
// 次の監視周期で即座に同じタイムアウトを再報告しない。
lastPongAt.current = Date.now();
return;
}
triggerRemount(reason, data);
}, [onWatchdog, triggerRemount, watchdogMode]);
useEffect(() => { useEffect(() => {
focusedRef.current = isFocused; focusedRef.current = isFocused;
if (!isFocused) { if (!isFocused) {
@@ -112,12 +137,12 @@ export function useWebViewRemount(options?: UseWebViewRemountOptions) {
const elapsed = Date.now() - lastPongAt.current; const elapsed = Date.now() - lastPongAt.current;
if (isLoadingRef.current) { if (isLoadingRef.current) {
// ローディング中でも45秒超はレンダラー死亡と判定 // ローディング中でも45秒超はレンダラー死亡と判定
if (elapsed > 45_000) triggerRemount("loading_timeout", { elapsedMs: elapsed }); if (elapsed > 45_000) triggerWatchdog("loading_timeout", { elapsedMs: elapsed });
return; return;
} }
// ロード完了後30秒 pong 無応答 → レンダラー死亡 // ロード完了後30秒 pong 無応答 → レンダラー死亡
if (elapsed > 30_000) { if (elapsed > 30_000) {
triggerRemount("pong_timeout", { elapsedMs: elapsed }); triggerWatchdog("pong_timeout", { elapsedMs: elapsed });
return; return;
} }
webViewRef.current?.injectJavaScript( webViewRef.current?.injectJavaScript(
@@ -125,7 +150,7 @@ export function useWebViewRemount(options?: UseWebViewRemountOptions) {
); );
}, 5_000); }, 5_000);
return () => clearInterval(id); return () => clearInterval(id);
}, [isFocused, pauseWatchdogWhenUnfocused, pingEnabled, triggerRemount]); }, [isFocused, pauseWatchdogWhenUnfocused, pingEnabled, triggerWatchdog]);
const processHandlers = { const processHandlers = {
onRenderProcessGone: (event?: any) => { onRenderProcessGone: (event?: any) => {
@@ -158,6 +183,7 @@ export function useWebViewRemount(options?: UseWebViewRemountOptions) {
onLoadEnd: () => { onLoadEnd: () => {
isLoadingRef.current = false; isLoadingRef.current = false;
lastPongAt.current = Date.now(); lastPongAt.current = Date.now();
watchdogReportedRef.current = false;
maxTextLenRef.current = 0; maxTextLenRef.current = 0;
blankCountRef.current = 0; blankCountRef.current = 0;
// ロード完了3秒後に初回コンテンツチェック // ロード完了3秒後に初回コンテンツチェック
@@ -172,6 +198,7 @@ export function useWebViewRemount(options?: UseWebViewRemountOptions) {
onLoadStart: () => { onLoadStart: () => {
isLoadingRef.current = true; isLoadingRef.current = true;
lastPongAt.current = Date.now(); // ナビゲーション開始時にタイムアウトリセット lastPongAt.current = Date.now(); // ナビゲーション開始時にタイムアウトリセット
watchdogReportedRef.current = false;
maxTextLenRef.current = 0; maxTextLenRef.current = 0;
blankCountRef.current = 0; blankCountRef.current = 0;
}, },
@@ -181,6 +208,8 @@ export function useWebViewRemount(options?: UseWebViewRemountOptions) {
if (parsed.type === "__ping") { if (parsed.type === "__ping") {
lastPongAt.current = Date.now(); // 応答ごとにタイムアウトリセット lastPongAt.current = Date.now(); // 応答ごとにタイムアウトリセット
const len: number = parsed.len ?? 0; const len: number = parsed.len ?? 0;
// 本文が復帰したときだけ、次の異常を再び報告可能にする。
if (len >= 5) watchdogReportedRef.current = false;
if (len > maxTextLenRef.current) maxTextLenRef.current = len; if (len > maxTextLenRef.current) maxTextLenRef.current = len;
// 一度でも20文字超になったページが5文字未満になったら白画面と判定 // 一度でも20文字超になったページが5文字未満になったら白画面と判定
if (maxTextLenRef.current > 20 && len < 5) { if (maxTextLenRef.current > 20 && len < 5) {
@@ -190,7 +219,7 @@ export function useWebViewRemount(options?: UseWebViewRemountOptions) {
const maxTextLength = maxTextLenRef.current; const maxTextLength = maxTextLenRef.current;
blankCountRef.current = 0; blankCountRef.current = 0;
maxTextLenRef.current = 0; maxTextLenRef.current = 0;
triggerRemount("blank_detected", { triggerWatchdog("blank_detected", {
blankCount, blankCount,
textLength: len, textLength: len,
maxTextLength, maxTextLength,
+11 -17
View File
@@ -1,4 +1,5 @@
import dayjs from "dayjs"; import dayjs from "dayjs";
import { parseClockTime, setServiceTime } from "@/lib/timeUtils";
import { STORAGE_KEYS } from "@/constants"; import { STORAGE_KEYS } from "@/constants";
import type { CustomTrainData, StationProps, eachTrainDiagramType } from "@/lib/CommonTypes"; import type { CustomTrainData, StationProps, eachTrainDiagramType } from "@/lib/CommonTypes";
import { getTrainType } from "@/lib/getTrainType"; import { getTrainType } from "@/lib/getTrainType";
@@ -132,10 +133,9 @@ export const buildVoicepeakAnnouncementKey = (
train: eachTrainDiagramType, train: eachTrainDiagramType,
stage: VoicepeakAnnouncementStage stage: VoicepeakAnnouncementStage
) => { ) => {
const [hourText] = train.time.split(":"); const hour = parseClockTime(train.time)?.hour();
const hour = Number.parseInt(hourText, 10);
const serviceDate = dayjs() const serviceDate = dayjs()
.subtract(Number.isNaN(hour) ? 0 : hour < 4 ? 1 : 0, "day") .subtract(hour !== undefined && hour < 4 ? 1 : 0, "day")
.format("YYYY-MM-DD"); .format("YYYY-MM-DD");
return [ return [
@@ -149,17 +149,9 @@ export const buildVoicepeakAnnouncementKey = (
}; };
const getDepartureTiming = (timeText: string, delayMinutes = 0) => { const getDepartureTiming = (timeText: string, delayMinutes = 0) => {
const [hourText, minuteText] = timeText.split(":");
const hour = Number.parseInt(hourText, 10);
const minute = Number.parseInt(minuteText, 10);
if (Number.isNaN(hour) || Number.isNaN(minute)) return null;
const now = dayjs(); const now = dayjs();
const departureTime = now const departureTime = setServiceTime(now, timeText, delayMinutes);
.set("hour", hour < 4 ? hour + 24 : hour) if (!departureTime) return null;
.set("minute", minute + delayMinutes)
.set("second", 0)
.set("millisecond", 0);
return { return {
now, now,
@@ -343,10 +335,12 @@ export const buildVoicepeakAnnouncementText = ({
if (stage === "advance") { if (stage === "advance") {
const trainInfoText = currentTrainData.train_info?.trim(); const trainInfoText = currentTrainData.train_info?.trim();
const [hourText, minuteText] = train.time.split(":"); const parsedTime = parseClockTime(train.time);
const hour = parsedTime?.hour() ?? 0;
const minute = parsedTime?.minute() ?? 0;
const departureTimeText = [ const departureTimeText = [
`${Number.parseInt(hourText || "0", 10)}`, `${hour}`,
`${Number.parseInt(minuteText || "0", 10)}${ `${minute}${
isArrivalBasedAdvance ? "着" : "発" isArrivalBasedAdvance ? "着" : "発"
}`, }`,
]; ];
@@ -479,7 +473,7 @@ const getRetryAfterSeconds = (
const seconds = Number(retryAfter); const seconds = Number(retryAfter);
if (Number.isFinite(seconds) && seconds >= 0) return seconds; if (Number.isFinite(seconds) && seconds >= 0) return seconds;
const retryAt = Date.parse(retryAfter); const retryAt = dayjs(retryAfter).valueOf();
if (Number.isFinite(retryAt)) { if (Number.isFinite(retryAt)) {
return Math.max(0, Math.ceil((retryAt - Date.now()) / 1000)); return Math.max(0, Math.ceil((retryAt - Date.now()) / 1000));
} }
+4 -3
View File
@@ -1,5 +1,6 @@
import { Platform } from "react-native"; import { Platform } from "react-native";
import * as Updates from "expo-updates"; import * as Updates from "expo-updates";
import dayjs from "dayjs";
import Constants from "expo-constants"; import Constants from "expo-constants";
import { AS } from "@/storageControl"; import { AS } from "@/storageControl";
@@ -93,7 +94,7 @@ const readLogs = async (): Promise<VoicepeakDebugLogEntry[]> => {
const pruneLogs = (logs: VoicepeakDebugLogEntry[], now = Date.now()) => const pruneLogs = (logs: VoicepeakDebugLogEntry[], now = Date.now()) =>
logs logs
.filter((log) => { .filter((log) => {
const timestamp = Date.parse(log.createdAt); const timestamp = dayjs(log.createdAt).valueOf();
return Number.isFinite(timestamp) && now - timestamp < RETENTION_MILLISECONDS; return Number.isFinite(timestamp) && now - timestamp < RETENTION_MILLISECONDS;
}) })
.sort((a, b) => b.createdAt.localeCompare(a.createdAt)) .sort((a, b) => b.createdAt.localeCompare(a.createdAt))
@@ -111,7 +112,7 @@ const runTransaction = <T>(operation: () => Promise<T>): Promise<T> => {
export const createVoicepeakDebugLog = async ( export const createVoicepeakDebugLog = async (
input: NewVoicepeakDebugLog input: NewVoicepeakDebugLog
) => { ) => {
const now = new Date().toISOString(); const now = dayjs().toISOString();
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; const id = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
const entry: VoicepeakDebugLogEntry = { const entry: VoicepeakDebugLogEntry = {
id, id,
@@ -147,7 +148,7 @@ export const completeVoicepeakDebugLog = async (
) => { ) => {
await runTransaction(async () => { await runTransaction(async () => {
const logs = await readLogs(); const logs = await readLogs();
const updatedAt = new Date().toISOString(); const updatedAt = dayjs().toISOString();
const nextLogs = logs.map((log) => const nextLogs = logs.map((log) =>
log.id === id log.id === id
? { ? {
+2
View File
@@ -1108,6 +1108,7 @@ const setStrings = () =>{
const elements = document.querySelectorAll('#disp > div > div > div[onclick]'); const elements = document.querySelectorAll('#disp > div > div > div[onclick]');
const setNewTrainItemUI = ()=>{ const setNewTrainItemUI = ()=>{
const aaa = (x2,pos) => { const aaa = (x2,pos) => {
if (!x2) return;
x2.style.display = 'flex'; x2.style.display = 'flex';
x2.style.flexDirection = 'row'; x2.style.flexDirection = 'row';
if(pos == "right"){ if(pos == "right"){
@@ -1123,6 +1124,7 @@ const setStrings = () =>{
} }
const aaa2 = (x2) => { const aaa2 = (x2) => {
if (!x2) return;
x2.style.display = 'flex'; x2.style.display = 'flex';
x2.style.flexDirection = 'row'; x2.style.flexDirection = 'row';
x2.style.alignItems = 'center'; x2.style.alignItems = 'center';
@@ -29,10 +29,8 @@ class LiveActivityForegroundService : Service() {
const val NOTIFICATION_ID = 8001 const val NOTIFICATION_ID = 8001
private const val TAG = "LiveActivityService" private const val TAG = "LiveActivityService"
private const val POLL_INTERVAL_MS = 15_000L private const val POLL_INTERVAL_MS = 15_000L
private const val PRIMARY_API_URL = private const val POSITION_API_URL =
"https://n8n.haruk.in/webhook/c501550c-7d1b-4e50-927b-4429fe18931a" "https://jr-shikoku-api-data-storage.haruk.in/tmp/currentPositions.json"
private const val FALLBACK_API_URL =
"https://script.google.com/macros/s/AKfycby9Y2-Bm75J_WkbZimi7iS8v5r9wMa9wtzpdwES9sOGF4i6HIYEJOM60W6gM1gXzt1o/exec"
@Volatile @Volatile
var isRunning = false var isRunning = false
@@ -223,9 +221,9 @@ class LiveActivityForegroundService : Service() {
private fun pollTrainPosition() { private fun pollTrainPosition() {
if (trainNumber.isEmpty()) return if (trainNumber.isEmpty()) return
try { try {
val json = fetchApi(PRIMARY_API_URL) ?: fetchApi(FALLBACK_API_URL) val json = fetchApi(POSITION_API_URL)
if (json == null) { if (json == null) {
Log.w(TAG, "Both APIs failed") Log.w(TAG, "Position API failed")
return return
} }
@@ -330,7 +328,7 @@ class LiveActivityForegroundService : Service() {
*/ */
private fun pollStationTrains() { private fun pollStationTrains() {
try { try {
val json = fetchApi(PRIMARY_API_URL) ?: fetchApi(FALLBACK_API_URL) ?: return val json = fetchApi(POSITION_API_URL) ?: return
val allTrains = parseAllTrains(json) val allTrains = parseAllTrains(json)
if (trainsJson == "[]" || trainsJson.isEmpty()) return if (trainsJson == "[]" || trainsJson.isEmpty()) return
val trains = try { JSONArray(trainsJson) } catch (_: Exception) { return } val trains = try { JSONArray(trainsJson) } catch (_: Exception) { return }
+4 -1
View File
@@ -16,6 +16,9 @@ import {
BACKEND_API_BASE_URLS, BACKEND_API_BASE_URLS,
} from "@/lib/jrDataSystemEnvironment"; } from "@/lib/jrDataSystemEnvironment";
import { observedFetchJson } from "@/lib/observability/network/observedFetch"; import { observedFetchJson } from "@/lib/observability/network/observedFetch";
type TimetableDiagramData = { [_: string]: string[] }[];
const initialState = { const initialState = {
allTrainDiagram: {}, allTrainDiagram: {},
setAllTrainDiagram: (e) => {}, setAllTrainDiagram: (e) => {},
@@ -69,7 +72,7 @@ export const AllTrainDiagramProvider: FC<Props> = ({ children }) => {
}, []); }, []);
const getTrainDiagram = () => { const getTrainDiagram = () => {
observedFetchJson<any[]>(diagramTodayUrl, { observedFetchJson<TimetableDiagramData>(diagramTodayUrl, {
endpoint: "timetable_today", endpoint: "timetable_today",
source: "static_storage", source: "static_storage",
userVisible: false, userVisible: false,
+92 -49
View File
@@ -6,9 +6,14 @@ import React, {
useRef, useRef,
FC, FC,
} from "react"; } from "react";
import { InteractionManager } from "react-native"; import { AppState, InteractionManager } from "react-native";
import useInterval from "../lib/useInterval"; import { observedFetchJson } from "@/lib/observability/network/observedFetch";
import { observedFetchJson, observedFetchText } from "@/lib/observability/network/observedFetch"; import { API_ENDPOINTS } from "@/constants";
import {
getNextOperationInfoFetchDelay,
OPERATION_INFO_STALE_RETRY_MS,
} from "@/lib/operationInfoSchedule";
import type { OperationInfoSnapshot } from "@/types";
const setoStationID = [ const setoStationID = [
"Y00", "Y00",
@@ -362,98 +367,136 @@ type props = { children: React.ReactNode };
export const AreaInfoProvider: FC<props> = ({ children }) => { export const AreaInfoProvider: FC<props> = ({ children }) => {
const [areaInfo, setAreaInfo] = useState(""); const [areaInfo, setAreaInfo] = useState("");
const [areaIconBadgeText, setAreaIconBadgeText] = useState(""); const [areaIconBadgeText, setAreaIconBadgeText] = useState("");
const [areaStationID, setAreaStationID] = useState([]); const [areaStationID, setAreaStationID] = useState<string[]>([]);
const [isInfo, setIsInfo] = useState(false); const [isInfo, setIsInfo] = useState(false);
const areaDescriptionTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const initialFetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const initialFetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const fetchAreaDescription = () => { const nextFetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
observedFetchText( const getAreaDataRef = useRef<() => void>(() => {});
"https://script.google.com/macros/s/AKfycbz80LcaEUrhnlEsLkJy0LG2IRO3DBVQhfNmN1d_0f_HvtsujNQpxM90SrV9yKWH_JG1Ww/exec", const isFetchingRef = useRef(false);
{ const isMountedRef = useRef(false);
endpoint: "operation_info_text", const isActiveRef = useRef(true);
source: "gas",
userVisible: true, const clearNextFetchTimeout = () => {
preload: false, if (nextFetchTimeoutRef.current) {
fetchPriority: "medium", clearTimeout(nextFetchTimeoutRef.current);
expectedContentType: "text", nextFetchTimeoutRef.current = null;
timeoutMs: 15000,
retry: false,
urlPathTemplate: "/macros/s/AKfy.../exec",
} }
)
.then((d) => setAreaInfo(d))
.catch(() => {});
}; };
const scheduleAreaDescriptionFetch = () => { const scheduleNextFetch = (delayMs: number) => {
if (areaDescriptionTimeoutRef.current) { if (!isMountedRef.current || !isActiveRef.current) return;
clearTimeout(areaDescriptionTimeoutRef.current); clearNextFetchTimeout();
} nextFetchTimeoutRef.current = setTimeout(() => {
areaDescriptionTimeoutRef.current = setTimeout(() => { nextFetchTimeoutRef.current = null;
areaDescriptionTimeoutRef.current = null; getAreaDataRef.current();
fetchAreaDescription(); }, delayMs);
}, 800);
}; };
const getAreaData = () => { const getAreaData = () => {
observedFetchJson<any>("https://n8n.haruk.in/webhook/jr-shikoku-trainfo-flag", { if (isFetchingRef.current || !isActiveRef.current) return;
endpoint: "operation_info_flag", isFetchingRef.current = true;
source: "n8n",
observedFetchJson<OperationInfoSnapshot>(API_ENDPOINTS.OPERATION_INFO, {
endpoint: "operation_info",
source: "static_storage",
userVisible: true, userVisible: true,
preload: true, preload: true,
fetchPriority: "medium", fetchPriority: "medium",
timeoutMs: 10000, timeoutMs: 10000,
retry: true, retry: true,
urlPathTemplate: "/webhook/jr-shikoku-trainfo-flag", cache: "no-store",
urlPathTemplate: "/operation-info/jr-shikoku/latest.json",
}) })
.then((d) => { .then((d) => {
if (!d.data) return; scheduleNextFetch(getNextOperationInfoFetchDelay(d.fetchedAt));
const lineInfo = d.data.filter((e) => e.area != "genelic"); const areaData = d.compatibility?.areaInfo;
const genelicInfo = d.data.filter((e) => e.area == "genelic"); if (!Array.isArray(areaData)) return;
const activeLineInfo = lineInfo.filter((e) => e.status); const lineInfo = areaData.filter((e) => e.area !== "genelic");
const generalInfo = areaData.find((e) => e.area === "genelic");
const activeLineInfo = lineInfo.filter(
(e) => Boolean(e.status) && e.area in areaStationPair
);
const text = activeLineInfo.map((e) => { const text = activeLineInfo.map((e) => {
return `${areaStationPair[e.area].id}`; return areaStationPair[e.area as keyof typeof areaStationPair].id;
}); });
let stationIDList = []; let stationIDList: string[] = [];
activeLineInfo.forEach((e) => { activeLineInfo.forEach((e) => {
stationIDList = stationIDList.concat( stationIDList = stationIDList.concat(
areaStationPair[e.area].stationID areaStationPair[e.area as keyof typeof areaStationPair].stationID
); );
}); });
const info = genelicInfo[0].status.includes("nodelay") ? true : false; const info =
typeof generalInfo?.status === "string" &&
generalInfo.status.includes("nodelay");
setIsInfo(info); setIsInfo(info);
setAreaStationID(stationIDList); setAreaStationID(stationIDList);
setAreaIconBadgeText( setAreaIconBadgeText(
text.length == 0 ? (info ? "i" : "!") : text.join(",") text.length == 0 ? (info ? "i" : "!") : text.join(",")
); );
if (stationIDList.length > 0) { if (stationIDList.length > 0) {
scheduleAreaDescriptionFetch(); setAreaInfo(d.compatibility.operationInfoText);
} else { } else {
setAreaInfo(""); setAreaInfo("");
} }
}) })
.catch(() => {}); .catch(() => {
scheduleNextFetch(OPERATION_INFO_STALE_RETRY_MS);
})
.finally(() => {
isFetchingRef.current = false;
});
}; };
getAreaDataRef.current = getAreaData;
useEffect(() => { useEffect(() => {
isMountedRef.current = true;
isActiveRef.current =
AppState.currentState !== "background" &&
AppState.currentState !== "inactive";
const task = InteractionManager.runAfterInteractions(() => { const task = InteractionManager.runAfterInteractions(() => {
if (!isActiveRef.current) return;
initialFetchTimeoutRef.current = setTimeout(() => { initialFetchTimeoutRef.current = setTimeout(() => {
initialFetchTimeoutRef.current = null; initialFetchTimeoutRef.current = null;
getAreaData(); getAreaDataRef.current();
}, 1200); }, 1200);
}); });
return () => {
const subscription = AppState.addEventListener("change", (nextState) => {
task.cancel?.(); task.cancel?.();
if (initialFetchTimeoutRef.current) { if (initialFetchTimeoutRef.current) {
clearTimeout(initialFetchTimeoutRef.current); clearTimeout(initialFetchTimeoutRef.current);
initialFetchTimeoutRef.current = null; initialFetchTimeoutRef.current = null;
} }
if (areaDescriptionTimeoutRef.current) {
clearTimeout(areaDescriptionTimeoutRef.current); if (nextState === "active") {
areaDescriptionTimeoutRef.current = null; isActiveRef.current = true;
clearNextFetchTimeout();
getAreaDataRef.current();
return;
} }
isActiveRef.current = false;
clearNextFetchTimeout();
if (initialFetchTimeoutRef.current) {
clearTimeout(initialFetchTimeoutRef.current);
initialFetchTimeoutRef.current = null;
}
});
return () => {
isMountedRef.current = false;
isActiveRef.current = false;
task.cancel?.();
subscription.remove();
if (initialFetchTimeoutRef.current) {
clearTimeout(initialFetchTimeoutRef.current);
initialFetchTimeoutRef.current = null;
}
clearNextFetchTimeout();
}; };
}, []); }, []);
useInterval(getAreaData, 60000); //60秒毎に全在線列車取得
return ( return (
<AreaInfoContext.Provider <AreaInfoContext.Provider
value={{ value={{
+31 -6
View File
@@ -20,6 +20,32 @@ import { fetchMockTrainPositions } from "@/lib/mockApi/positionMasters";
import WebView from "react-native-webview"; import WebView from "react-native-webview";
import { StationProps } from "@/lib/CommonTypes"; import { StationProps } from "@/lib/CommonTypes";
import { observedFetchJson } from "@/lib/observability/network/observedFetch"; import { observedFetchJson } from "@/lib/observability/network/observedFetch";
import { API_ENDPOINTS } from "@/constants/api";
interface R2CurrentTrainDB {
schemaVersion: string;
source: string;
fetchedAt: string;
data: R2CurrentTrainData[];
}
interface R2CurrentTrainData {
Index?: number;
TrainNum?: string;
delay?: number | "入線";
Pos?: string;
PosNum?: number;
Direction?: number;
Type?: string;
Line?: string;
}
interface GasFallbackCurrentTrainData {
TrainNum?: string;
delay?: number | "入線";
Pos?: string;
}
type loading = "loading" | "success" | "error"; type loading = "loading" | "success" | "error";
const initialState = { const initialState = {
webview: undefined, webview: undefined,
@@ -311,19 +337,18 @@ export const CurrentTrainProvider: FC<props> = ({ children }) => {
.finally(() => clearTimeout(timeoutId)); .finally(() => clearTimeout(timeoutId));
return; return;
} }
observedFetchJson<any>("https://n8n.haruk.in/webhook/c501550c-7d1b-4e50-927b-4429fe18931a", { observedFetchJson<R2CurrentTrainDB>(API_ENDPOINTS.CURRENT_POSITIONS, {
endpoint: "positions_current", endpoint: "positions_current",
source: "n8n", source: "r2",
userVisible: true, userVisible: true,
preload: false, preload: false,
fetchPriority: "high", fetchPriority: "high",
timeoutMs: 8000, timeoutMs: 8000,
retry: true, retry: true,
urlPathTemplate: "/webhook/c501550c-7d1b-4e50-927b-4429fe18931a", urlPathTemplate: "/tmp/currentPositions.json",
}) })
.then((d) => d.data)
.then((d) => .then((d) =>
d.map((x) => ({ d.data.filter((x): x is R2CurrentTrainData => "TrainNum" in x).map((x) => ({
Index: x.Index, Index: x.Index,
num: x.TrainNum, num: x.TrainNum,
delay: x.delay, delay: x.delay,
@@ -350,7 +375,7 @@ export const CurrentTrainProvider: FC<props> = ({ children }) => {
}))); })));
}) })
.catch(() => { .catch(() => {
observedFetchJson<any[]>( observedFetchJson<GasFallbackCurrentTrainData[]>(
"https://script.google.com/macros/s/AKfycby9Y2-Bm75J_WkbZimi7iS8v5r9wMa9wtzpdwES9sOGF4i6HIYEJOM60W6gM1gXzt1o/exec", "https://script.google.com/macros/s/AKfycby9Y2-Bm75J_WkbZimi7iS8v5r9wMa9wtzpdwES9sOGF4i6HIYEJOM60W6gM1gXzt1o/exec",
{ {
...HeaderConfig, ...HeaderConfig,
+127 -50
View File
@@ -1,62 +1,139 @@
import React, { import React, { createContext, useCallback, useContext, useEffect, useRef, useState, FC } from "react";
createContext, import * as Sentry from "@sentry/react-native";
useContext,
useState,
useEffect,
useLayoutEffect,
FC,
} from "react";
import { AS } from "@/storageControl"; import { AS } from "@/storageControl";
import { useStationList } from "@/stateBox/useStationList"; import { useStationList } from "@/stateBox/useStationList";
import { StationProps } from "@/lib/CommonTypes"; import type { StationProps } from "@/lib/CommonTypes";
import { STORAGE_KEYS } from "@/constants"; import { STORAGE_KEYS } from "@/constants";
import { logger } from "@/utils/logger"; import { logger } from "@/utils/logger";
const initialState = { import { getFavoriteStationKey, hasSameFavoriteStations, normalizeFavoriteStations, validateAndRebuildFavoriteStationOrder } from "@/lib/favoriteStationUtils";
favoriteStation: [], import type { FavoriteStationGroup } from "@/lib/favoriteStationUtils";
setFavoriteStation: () => {},
lodAddMigration: () => {}, type FavoriteStationContextType = {
favoriteStation: FavoriteStationGroup[];
isFavoriteStation: (stationGroup: StationProps[]) => boolean;
addFavoriteStation: (stationGroup: StationProps[]) => void;
removeFavoriteStation: (stationGroup: StationProps[]) => void;
toggleFavoriteStation: (stationGroup: StationProps[]) => void;
reorderFavoriteStations: (orderedGroups: StationProps[][]) => void;
}; };
type initialStateType = { const initialState: FavoriteStationContextType = {
favoriteStation: StationProps[][]; favoriteStation: [], isFavoriteStation: () => false,
setFavoriteStation: (d: StationProps[][]) => void; addFavoriteStation: () => {}, removeFavoriteStation: () => {}, toggleFavoriteStation: () => {}, reorderFavoriteStations: () => {},
lodAddMigration: () => void;
}; };
const FavoriteStationContext = createContext<initialStateType>(initialState); const FavoriteStationContext = createContext<FavoriteStationContextType>(initialState);
export const useFavoriteStation = () => useContext(FavoriteStationContext);
export const useFavoriteStation = () => { export const FavoriteStationProvider: FC<{ children: React.ReactNode }> = ({ children }) => {
return useContext(FavoriteStationContext); const [favoriteStation, setFavoriteStation] = useState<FavoriteStationGroup[]>([]);
}; const [isReady, setIsReady] = useState(false);
type Props = { const { originalStationList } = useStationList();
children: React.ReactNode; const favoriteStationRef = useRef<FavoriteStationGroup[]>([]);
}; const persistQueueRef = useRef(Promise.resolve());
export const FavoriteStationProvider: FC<Props> = ({ children }) => { const persist = useCallback((next: FavoriteStationGroup[]) => {
const [favoriteStation, setFavoriteStation] = useState<StationProps[][]>([]); const serialized = JSON.stringify(next);
const { getStationDataFromName } = useStationList(); const favoriteCount = next.length;
const lodAddMigration = () => { persistQueueRef.current = persistQueueRef.current
const migration = favoriteStation.map((d) => { .catch(() => undefined)
return getStationDataFromName(d[0].Station_JP); .then(() => AS.setItem(STORAGE_KEYS.FAVORITE_STATION, serialized))
});
setFavoriteStation(migration);
};
useEffect(() => {
AS.getItem(STORAGE_KEYS.FAVORITE_STATION)
.then((d) => {
const returnData: StationProps[][] = JSON.parse(d);
setFavoriteStation(returnData);
})
.catch((error) => { .catch((error) => {
// エラーログを記録(開発時のみ) if (__DEV__) logger.warn("お気に入り駅の保存に失敗しました:", error);
if (__DEV__) { Sentry.captureException(error, {
logger.warn("お気に入り駅の読み込みに失敗しました:", error); tags: { area: "favorite_station_persist" },
} extra: { favoriteCount },
});
}); });
}, []); }, []);
return ( const updateFavoriteStations = useCallback((next: FavoriteStationGroup[]) => {
<FavoriteStationContext.Provider favoriteStationRef.current = next;
value={{ favoriteStation, setFavoriteStation, lodAddMigration }} setFavoriteStation(next);
> persist(next);
{children} }, [persist]);
</FavoriteStationContext.Provider>
); useEffect(() => {
if (Object.keys(originalStationList).length === 0) return;
let cancelled = false;
setIsReady(false);
AS.getItem(STORAGE_KEYS.FAVORITE_STATION).then((stored) => {
let parsed: unknown = [];
let isStoredValueValid = true;
try { parsed = typeof stored === "string" ? JSON.parse(stored) : stored; }
catch (error) {
isStoredValueValid = false;
if (__DEV__) logger.warn("お気に入り駅の形式が不正です:", error);
}
if (!Array.isArray(parsed)) isStoredValueValid = false;
const next = normalizeFavoriteStations(parsed, originalStationList);
if (cancelled) return;
favoriteStationRef.current = next;
setFavoriteStation(next);
const storedCount = Array.isArray(parsed) ? parsed.length : 0;
const wouldDropAll = storedCount > 0 && next.length === 0;
if (wouldDropAll) {
Sentry.captureMessage("favorite station normalization rejected an all-item drop", {
level: "warning",
extra: { beforeCount: storedCount, afterCount: next.length },
});
} else if (!isStoredValueValid || !hasSameFavoriteStations(parsed, next)) {
persist(next);
}
setIsReady(true);
}).catch((error) => {
if (__DEV__) logger.warn("お気に入り駅の読み込みに失敗しました:", error);
if (cancelled) return;
if (String(error).includes("Not Found!")) {
favoriteStationRef.current = [];
setFavoriteStation([]);
setIsReady(true);
return;
}
Sentry.captureException(error, { tags: { area: "favorite_station_load" } });
});
return () => { cancelled = true; };
}, [originalStationList, persist]);
const reorderFavoriteStations = useCallback((orderedGroups: StationProps[][]) => {
if (!isReady) return;
const current = favoriteStationRef.current;
const result = validateAndRebuildFavoriteStationOrder(current, orderedGroups);
if ("reason" in result) {
Sentry.captureMessage("favorite station reorder rejected", {
level: "warning",
extra: {
reason: result.reason,
beforeCount: current.length,
afterCount: Array.isArray(orderedGroups) ? orderedGroups.length : null,
},
});
return;
}
if (hasSameFavoriteStations(current, result.groups)) return;
updateFavoriteStations(result.groups);
}, [isReady, updateFavoriteStations]);
const isFavoriteStation = useCallback((stationGroup: StationProps[]) => {
const key = getFavoriteStationKey(stationGroup);
return !!key && favoriteStation.some((favorite) => getFavoriteStationKey(favorite) === key);
}, [favoriteStation]);
const addFavoriteStation = useCallback((stationGroup: StationProps[]) => {
if (!isReady) return;
const key = getFavoriteStationKey(stationGroup);
if (!key) return;
const current = favoriteStationRef.current;
const next = normalizeFavoriteStations([...current, stationGroup], originalStationList);
if (!hasSameFavoriteStations(current, next)) updateFavoriteStations(next);
}, [isReady, originalStationList, updateFavoriteStations]);
const removeFavoriteStation = useCallback((stationGroup: StationProps[]) => {
if (!isReady) return;
const key = getFavoriteStationKey(stationGroup);
if (!key) return;
const current = favoriteStationRef.current;
const next = current.filter((favorite) => getFavoriteStationKey(favorite) !== key);
if (!hasSameFavoriteStations(current, next)) updateFavoriteStations(next);
}, [isReady, updateFavoriteStations]);
const toggleFavoriteStation = useCallback((stationGroup: StationProps[]) => {
if (isFavoriteStation(stationGroup)) removeFavoriteStation(stationGroup);
else addFavoriteStation(stationGroup);
}, [addFavoriteStation, isFavoriteStation, removeFavoriteStation]);
return <FavoriteStationContext.Provider value={{ favoriteStation, isFavoriteStation, addFavoriteStation, removeFavoriteStation, toggleFavoriteStation, reorderFavoriteStations }}>{children}</FavoriteStationContext.Provider>;
}; };
+2 -1
View File
@@ -56,7 +56,8 @@ export const StationListProvider: FC<Props> = ({ children }) => {
const [originalStationList, setOriginalStationList] = const [originalStationList, setOriginalStationList] =
useState<OriginalStationList>({}); useState<OriginalStationList>({});
useEffect(() => { useEffect(() => {
getStationList().then(setOriginalStationList); const data = getStationList();
setOriginalStationList(data);
}, []); }, []);
const getStationDataFromId: (id: string) => StationProps[] = (id) => { const getStationDataFromId: (id: string) => StationProps[] = (id) => {
let returnArray: StationProps[] = []; let returnArray: StationProps[] = [];
+2 -1
View File
@@ -35,7 +35,8 @@ export const TopMenuProvider: FC<Props> = ({ children }) => {
const [originalStationList, setOriginalStationList] = const [originalStationList, setOriginalStationList] =
useState<OriginalStationList>({}); useState<OriginalStationList>({});
useEffect(() => { useEffect(() => {
getStationList().then(setOriginalStationList); const data = getStationList();
setOriginalStationList(data);
}, []); }, []);
const getStationData: (name: string) => StationProps[] = (name) => { const getStationData: (name: string) => StationProps[] = (name) => {
const returnArray: StationProps[] = []; const returnArray: StationProps[] = [];
+2 -3
View File
@@ -1,4 +1,5 @@
import React, { createContext, FC, useContext, useEffect, useState } from "react"; import React, { createContext, FC, useContext, useEffect, useState } from "react";
import { API_ENDPOINTS } from "@/constants";
const initialState = { const initialState = {
getTime: new Date(), getTime: new Date(),
setGetTime: (e) => {}, setGetTime: (e) => {},
@@ -21,9 +22,7 @@ export const TrainDelayDataProvider:FC<props> = ({ children }) => {
const [getTime, setGetTime] = useState(new Date()); const [getTime, setGetTime] = useState(new Date());
const [loadingDelayData, setLoadingDelayData] = useState(true); const [loadingDelayData, setLoadingDelayData] = useState(true);
useEffect(() => { useEffect(() => {
fetch( fetch(API_ENDPOINTS.DELAY_INFO_LEGACY)
"https://script.google.com/macros/s/AKfycbw-0RDLAu8EQAEWA860tk4KVW6VOr3iIU900AcWEfqIP16gtNUG1XO_A3oBfAGiNeCf/exec"
)
.then((response) => response.text()) .then((response) => response.text())
.then((data) => setDelayData(data !== "" ? data.split("^") : null)) .then((data) => setDelayData(data !== "" ? data.split("^") : null))
.then(() => setGetTime(new Date())) .then(() => setGetTime(new Date()))
+4 -6
View File
@@ -8,6 +8,7 @@ import React, {
FC, FC,
} from "react"; } from "react";
import { Platform } from "react-native"; import { Platform } from "react-native";
import dayjs from "dayjs";
import * as DocumentPicker from "expo-document-picker"; import * as DocumentPicker from "expo-document-picker";
import { File, Paths } from "expo-file-system"; import { File, Paths } from "expo-file-system";
import Share from "react-native-share"; import Share from "react-native-share";
@@ -269,10 +270,7 @@ export const TrainMenuProvider: FC<props> = ({ children }) => {
const [playbackIndex, setPlaybackIndex] = useState(0); const [playbackIndex, setPlaybackIndex] = useState(0);
const playbackCurrentTimeIso = const playbackCurrentTimeIso =
recorderState === 'playing' && activeRecording && activeRecording.snapshots.length > 0 recorderState === 'playing' && activeRecording && activeRecording.snapshots.length > 0
? new Date( ? dayjs(activeRecording.recordedAt).add(activeRecording.snapshots[playbackIndex]?.t ?? 0, "millisecond").toISOString()
new Date(activeRecording.recordedAt).getTime() +
(activeRecording.snapshots[playbackIndex]?.t ?? 0)
).toISOString()
: null; : null;
const [playbackPaused, setPlaybackPaused] = useState(false); const [playbackPaused, setPlaybackPaused] = useState(false);
const recordingStartTimeRef = useRef<number>(0); const recordingStartTimeRef = useRef<number>(0);
@@ -315,7 +313,7 @@ export const TrainMenuProvider: FC<props> = ({ children }) => {
const stopRecording = async () => { const stopRecording = async () => {
const snaps = recordingSnapshotsRef.current; const snaps = recordingSnapshotsRef.current;
if (snaps.length > 0) { if (snaps.length > 0) {
const recordedAt = new Date(recordingStartTimeRef.current).toISOString(); const recordedAt = dayjs(recordingStartTimeRef.current).toISOString();
const recording: TrainRecording = { const recording: TrainRecording = {
id: generateRecordingId(recordedAt), id: generateRecordingId(recordedAt),
recordedAt, recordedAt,
@@ -420,7 +418,7 @@ export const TrainMenuProvider: FC<props> = ({ children }) => {
const exportAllRecordingsFile = async () => { const exportAllRecordingsFile = async () => {
const content = await buildAllRecordingsExportTextCore(); const content = await buildAllRecordingsExportTextCore();
const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const timestamp = dayjs().toISOString().replace(/[:.]/g, '-');
await shareJsonFile(`jrshikoku-recordings-${timestamp}.json`, content); await shareJsonFile(`jrshikoku-recordings-${timestamp}.json`, content);
}; };
+1 -1
View File
@@ -17,7 +17,7 @@ struct DelayItem: Identifiable {
} }
struct DelayInfoProvider: TimelineProvider { struct DelayInfoProvider: TimelineProvider {
private let endpoint = "https://script.google.com/macros/s/AKfycbw-0RDLAu8EQAEWA860tk4KVW6VOr3iIU900AcWEfqIP16gtNUG1XO_A3oBfAGiNeCf/exec" private let endpoint = delayInfoLegacyURL
func placeholder(in context: Context) -> DelayEntry { func placeholder(in context: Context) -> DelayEntry {
DelayEntry(date: Date(), items: [], isLoading: true) DelayEntry(date: Date(), items: [], isLoading: true)
+13 -14
View File
@@ -10,8 +10,6 @@ struct OperationEntry: TimelineEntry {
} }
struct OperationInfoProvider: TimelineProvider { struct OperationInfoProvider: TimelineProvider {
private let endpoint = "https://script.google.com/macros/s/AKfycbz80LcaEUrhnlEsLkJy0LG2IRO3DBVQhfNmN1d_0f_HvtsujNQpxM90SrV9yKWH_JG1Ww/exec"
func placeholder(in context: Context) -> OperationEntry { func placeholder(in context: Context) -> OperationEntry {
OperationEntry(date: Date(), text: "読み込み中…", isLoading: true) OperationEntry(date: Date(), text: "読み込み中…", isLoading: true)
} }
@@ -32,20 +30,21 @@ struct OperationInfoProvider: TimelineProvider {
} }
private func fetchData(completion: @escaping (OperationEntry) -> Void) { private func fetchData(completion: @escaping (OperationEntry) -> Void) {
guard let url = URL(string: endpoint) else { fetchOperationInfoSnapshot { result in
completion(OperationEntry(date: Date(), text: "通常運行中です。", isLoading: false)) let operationInfoText: String
return
switch result {
case .success(let snapshot):
operationInfoText = snapshot.compatibility.operationInfoText
case .failure:
operationInfoText = ""
} }
URLSession.shared.dataTask(with: url) { data, _, error in
guard let data = data, error == nil, let displayText = operationInfoText.isEmpty
let text = String(data: data, encoding: .utf8), ? "通常運行中です。"
!text.isEmpty else { : operationInfoText.replacingOccurrences(of: "^", with: "\n")
completion(OperationEntry(date: Date(), text: "通常運行中です。", isLoading: false))
return
}
let displayText = text.replacingOccurrences(of: "^", with: "\n")
completion(OperationEntry(date: Date(), text: displayText, isLoading: false)) completion(OperationEntry(date: Date(), text: displayText, isLoading: false))
}.resume() }
} }
} }
+48
View File
@@ -1,6 +1,10 @@
import Foundation
import WidgetKit import WidgetKit
import SwiftUI import SwiftUI
let operationInfoSnapshotURL = "https://jr-shikoku-api-data-storage.haruk.in/operation-info/jr-shikoku/latest.json"
let delayInfoLegacyURL = "https://jr-shikoku-api-data-storage.haruk.in/legacy/trainfo-ex.txt"
/// App Group ID shared between the main app and widget extension. /// App Group ID shared between the main app and widget extension.
let appGroupID = "group.jrshikokuinfo.xprocess.hrkn" let appGroupID = "group.jrshikokuinfo.xprocess.hrkn"
@@ -13,6 +17,50 @@ struct FelicaSnapshot: Codable {
let scannedAt: String let scannedAt: String
} }
struct OperationInfoCompatibility: Decodable {
let operationInfoText: String
let hasOperationInfo: Bool
}
struct OperationInfoSnapshot: Decodable {
let compatibility: OperationInfoCompatibility
}
enum OperationInfoFetchError: Error {
case invalidURL
case invalidResponse
}
func fetchOperationInfoSnapshot(completion: @escaping (Result<OperationInfoSnapshot, Error>) -> Void) {
guard let url = URL(string: operationInfoSnapshotURL) else {
completion(.failure(OperationInfoFetchError.invalidURL))
return
}
var request = URLRequest(
url: url,
cachePolicy: .reloadIgnoringLocalCacheData,
timeoutInterval: 15
)
request.setValue("no-cache", forHTTPHeaderField: "Cache-Control")
URLSession.shared.dataTask(with: request) { data, response, error in
guard error == nil,
let response = response as? HTTPURLResponse,
(200..<300).contains(response.statusCode),
let data = data else {
completion(.failure(error ?? OperationInfoFetchError.invalidResponse))
return
}
do {
completion(.success(try JSONDecoder().decode(OperationInfoSnapshot.self, from: data)))
} catch {
completion(.failure(error))
}
}.resume()
}
func sharedDefaults() -> UserDefaults { func sharedDefaults() -> UserDefaults {
UserDefaults(suiteName: appGroupID) ?? .standard UserDefaults(suiteName: appGroupID) ?? .standard
} }
+4 -11
View File
@@ -13,8 +13,7 @@ struct ShortcutEntry: TimelineEntry {
} }
struct ShortcutProvider: TimelineProvider { struct ShortcutProvider: TimelineProvider {
private let delayEndpoint = "https://script.google.com/macros/s/AKfycbw-0RDLAu8EQAEWA860tk4KVW6VOr3iIU900AcWEfqIP16gtNUG1XO_A3oBfAGiNeCf/exec" private let delayEndpoint = delayInfoLegacyURL
private let operationEndpoint = "https://script.google.com/macros/s/AKfycbz80LcaEUrhnlEsLkJy0LG2IRO3DBVQhfNmN1d_0f_HvtsujNQpxM90SrV9yKWH_JG1Ww/exec"
func placeholder(in context: Context) -> ShortcutEntry { func placeholder(in context: Context) -> ShortcutEntry {
ShortcutEntry(date: Date(), delayCount: 0, hasInfo: false, amountText: "未読取") ShortcutEntry(date: Date(), delayCount: 0, hasInfo: false, amountText: "未読取")
@@ -59,17 +58,11 @@ struct ShortcutProvider: TimelineProvider {
// //
group.enter() group.enter()
if let url = URL(string: operationEndpoint) { fetchOperationInfoSnapshot { result in
URLSession.shared.dataTask(with: url) { data, _, _ in
defer { group.leave() } defer { group.leave() }
if let data = data, if case .success(let snapshot) = result {
let text = String(data: data, encoding: .utf8), hasInfo = snapshot.compatibility.hasOperationInfo
!text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
hasInfo = true
} }
}.resume()
} else {
group.leave()
} }
// Felica // Felica
+2
View File
@@ -3,6 +3,8 @@
* 使 * 使
*/ */
export * from "./operationInfo";
/** /**
* *
*/ */
+15
View File
@@ -0,0 +1,15 @@
export type OperationInfoAreaState = {
area: string;
status: boolean | string;
};
export type OperationInfoSnapshot = {
schemaVersion: 1;
status: "normal" | "disrupted";
fetchedAt: string;
compatibility: {
operationInfoText: string;
hasOperationInfo: boolean;
areaInfo: OperationInfoAreaState[];
};
};
+1 -1
View File
@@ -10,7 +10,7 @@ import { useState, useEffect } from "react";
const pr = PixelRatio.get(); const pr = PixelRatio.get();
export const IS_LOW_DENSITY = pr < 1.5; export const IS_LOW_DENSITY = pr < 1.5;
export const DEX_SCALE = IS_LOW_DENSITY ? Math.min(1.3, 1.5 / pr) : 1; export const DEX_SCALE =1;
// オリジナル関数の参照を保存 // オリジナル関数の参照を保存
const originalGet = Dimensions.get.bind(Dimensions); const originalGet = Dimensions.get.bind(Dimensions);
+2 -1
View File
@@ -4,6 +4,7 @@
*/ */
import Constants from 'expo-constants'; import Constants from 'expo-constants';
import dayjs from 'dayjs';
const isDevelopment = __DEV__; const isDevelopment = __DEV__;
@@ -21,7 +22,7 @@ export enum LogLevel {
* *
*/ */
const formatLog = (level: LogLevel, message: string, ...args: any[]): string => { const formatLog = (level: LogLevel, message: string, ...args: any[]): string => {
const timestamp = new Date().toISOString(); const timestamp = dayjs().toISOString();
return `[${timestamp}] [${level}] ${message}`; return `[${timestamp}] [${level}] ${message}`;
}; };