Author SHA1 Message Date
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
27 changed files with 918 additions and 316 deletions
+1
View File
@@ -61,3 +61,4 @@ ios/
*.apk
*.aab
.env.local
.agents-note
-18
View File
@@ -15,7 +15,6 @@ import HowTo from "@/howto";
import { Menu } from "@/menu";
import News from "@/components/news";
import Setting from "@/components/Settings/settings";
import { useFavoriteStation } from "@/stateBox/useFavoriteStation";
import { optionData } from "@/lib/stackOption";
import { AllTrainDiagramView } from "@/components/AllTrainDiagramView";
import { useNavigation, useIsFocused } from "@react-navigation/native";
@@ -27,7 +26,6 @@ import * as Sentry from "@sentry/react-native";
const Stack = createStackNavigator();
export function MenuPage() {
const { favoriteStation, setFavoriteStation } = useFavoriteStation();
const { height, width } = useWindowDimensions();
const { verticalScale } = useResponsive();
const tabBarHeight = useBottomTabBarHeight();
@@ -92,7 +90,6 @@ export function MenuPage() {
}, [height, isFocused, mapMode, width]);
const [mapHeight, setMapHeight] = useState(0);
const mapHeightRef = useRef(0);
const favoriteStationRef = useRef(favoriteStation);
useEffect(() => {
const MapHeight =
height -
@@ -103,9 +100,6 @@ export function MenuPage() {
setMapHeight(MapHeight);
mapHeightRef.current = MapHeight;
}, [height, tabBarHeight, width]);
useEffect(() => {
favoriteStationRef.current = favoriteStation;
}, [favoriteStation]);
const [MapFullHeight, setMapFullHeight] = useState(0);
useEffect(() => {
const MapFullHeight =
@@ -138,18 +132,6 @@ export function MenuPage() {
animated: true,
});
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;
+1 -1
View File
@@ -43,4 +43,4 @@ export default [
{"Station_JP" : "伊予和気", "Station_EN" : "Iyo-Wake", "MyStation" : "40", "StationNumber" : "Y53", "DispNum" : "2", "StationTimeTable" : "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/iyo-wake.pdf", "StationMap":"https://www.google.co.jp/maps/place/33.889798,132.740708", "JrHpUrl" : ""},
{"Station_JP" : "三津浜", "Station_EN" : "Mitsuhama", "MyStation" : "41", "StationNumber" : "Y54", "DispNum" : "2", "StationTimeTable" : "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/mitsuhama.pdf", "StationMap":"https://www.google.co.jp/maps/place/33.861165,132.728612", "JrHpUrl" : ""},
{"Station_JP" : "松山", "Station_EN" : "Matsuyama", "MyStation" : "42", "StationNumber" : "Y55", "DispNum" : "3", "StationTimeTable" : "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/matsuyama.pdf", "StationMap":"https://www.google.co.jp/maps/place/33.84039,132.75139" , "JrHpUrl" : "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/matsuyama/"}
]
]
@@ -29,6 +29,7 @@ import { useSheetMaxHeight } from "./useSheetMaxHeight";
export const StationDeteilView = (props) => {
if (!props.payload) return <></>;
const { currentStation, navigate, onExit, goTo, useShow } = props.payload;
const station = currentStation?.[0];
const { width } = useWindowDimensions();
const { verticalScale, moderateScale } = useResponsive();
const { busAndTrainData } = useBusAndTrainData();
@@ -36,15 +37,15 @@ export const StationDeteilView = (props) => {
const { colors } = useThemeColors();
useEffect(() => {
if (!currentStation) return () => {};
if (!station) return;
const data = busAndTrainData.filter(
(d) => d.name === currentStation[0].Station_JP
(d) => d.name === station.Station_JP
);
if (data.length == 0) {
setTrainBus(undefined);
}
setTrainBus(data[0]);
}, [currentStation, busAndTrainData]);
}, [station, busAndTrainData]);
const [usePDFView, setUsePDFView] = useState(undefined);
useEffect(() => {
@@ -52,13 +53,12 @@ export const StationDeteilView = (props) => {
.then(setUsePDFView)
.catch(() => setUsePDFView("false"));
}, []);
const info =
currentStation &&
(currentStation[0].StationTimeTable.match(".pdf")
? getPDFViewURL(currentStation[0].StationTimeTable)
: currentStation[0].StationTimeTable);
const info = station && (station.StationTimeTable.match(".pdf")
? getPDFViewURL(station.StationTimeTable)
: station.StationTimeTable);
const insets = useSafeAreaInsets();
const maxHeight = useSheetMaxHeight();
if (!station) return null;
return (
<ActionSheet
gestureEnabled
@@ -96,14 +96,14 @@ export const StationDeteilView = (props) => {
/>
</View>
<View>
{currentStation && (
{(
<>
<View style={{ margin: 10, marginHorizontal: width * 0.1 }}>
<Sign
stationID={currentStation[0].StationNumber}
stationID={station.StationNumber}
oP={() => {
usePDFView == "true"
? Linking.openURL(currentStation[0].StationTimeTable)
? Linking.openURL(station.StationTimeTable)
: navigate("howto", {
info,
goTo,
@@ -112,32 +112,32 @@ export const StationDeteilView = (props) => {
onExit();
}}
oLP={() =>
Linking.openURL(currentStation[0].StationTimeTable)
Linking.openURL(station.StationTimeTable)
}
/>
</View>
<View style={{ flexDirection: "row" }}>
<StationTrainPositionButton
stationNumber={currentStation[0].StationNumber}
stationNumber={station.StationNumber}
onExit={onExit}
navigate={navigate}
/>
{currentStation[0].JrHpUrl &&
currentStation[0].StationNumber != "M12" && (
{station.JrHpUrl &&
station.StationNumber != "M12" && (
<駅構内図 //児島例外/
navigate={navigate}
goTo={goTo}
useShow={useShow}
address={currentStation[0].JrHpUrl}
address={station.JrHpUrl}
onExit={onExit}
/>
)}
</View>
<View style={{ flexDirection: "row" }}>
{!currentStation[0].JrHpUrl || (
{!station.JrHpUrl || (
<WebSiteButton
navigate={navigate}
info={currentStation[0].JrHpUrl}
info={station.JrHpUrl}
goTo={goTo}
useShow={useShow}
onExit={onExit}
@@ -148,10 +148,10 @@ export const StationDeteilView = (props) => {
onExit={onExit}
currentStation={currentStation}
/>
{!currentStation[0].StationTimeTable || (
{!station.StationTimeTable || (
<StationTimeTableButton
info={info}
address={currentStation[0].StationTimeTable}
address={station.StationTimeTable}
usePDFView={usePDFView}
navigate={navigate}
onExit={onExit}
@@ -161,8 +161,8 @@ export const StationDeteilView = (props) => {
)}
<StationMapButton
stationMap={
currentStation[0].StationMap ||
`https://www.google.co.jp/maps/place/${currentStation[0].lat},${currentStation[0].lng}`
station.StationMap ||
`https://www.google.co.jp/maps/place/${station.lat},${station.lng}`
}
/>
{!trainBus || (
+15 -12
View File
@@ -6,21 +6,24 @@ import {
} from "react-native-android-widget";
import dayjs from "dayjs";
import { WidgetColors, widgetLightColors } from "./widget-theme";
import { API_ENDPOINTS } from "@/constants";
import type { OperationInfoSnapshot } from "@/types";
export const getInfoString = async () => {
// Fetch data from the server
const time = dayjs().format("HH:mm");
const text = await fetch(
"https://script.google.com/macros/s/AKfycbz80LcaEUrhnlEsLkJy0LG2IRO3DBVQhfNmN1d_0f_HvtsujNQpxM90SrV9yKWH_JG1Ww/exec"
)
.then((response) => response.text())
.then((data) => {
if (data !== "") {
return data.split("^");
}
return null;
});
//ToastAndroid.show(`${text}`, ToastAndroid.SHORT);
const response = await fetch(API_ENDPOINTS.OPERATION_INFO, {
cache: "no-store",
});
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 };
};
+3 -3
View File
@@ -321,9 +321,9 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo, onInitialLoadReady
if (!stationData) return () => {};
if (!originalStationList) return () => {};
if (favoriteStation.length < 1) return () => {};
const string = getInjectJavascriptAddress(
favoriteStation[0][0].StationNumber
);
const firstFavorite = favoriteStation.find((station) => station?.[0]?.StationNumber);
if (!firstFavorite) return () => {};
const string = getInjectJavascriptAddress(firstFavorite[0].StationNumber);
if (!string) return () => {};
if (loadEndTimeoutRef.current) {
clearTimeout(loadEndTimeoutRef.current);
+1 -1
View File
@@ -41,7 +41,7 @@ export const FavoriteList: FC = () => {
</Text>
<ScrollView style={{ height: "100%", backgroundColor: colors.background }}>
{favoriteStation
{favoriteStation.filter((currentStation) => currentStation.length > 0 && !!currentStation[0].StationNumber)
.map((currentStation) => {
return (
<FavoriteListItem
+17 -1
View File
@@ -28,8 +28,24 @@ import Animated, {
} from "react-native-reanimated";
import { useSortMode } from "./useSortMode";
import { StationSource } from "@/types";
import { getFavoriteStationKey } from "@/lib/favoriteStationUtils";
import { StationProps } from "@/lib/CommonTypes";
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) =>
String(error).includes("Not Found!");
@@ -380,7 +396,7 @@ export const CarouselBox = ({
rowGap={gridGap}
data={listUpStation}
renderItem={sortGridRenderItem}
keyExtractor={(item) => item[0].StationNumber ?? item[0].Station_JP}
keyExtractor={(item: StationProps[]) => getSortGridItemKey(item)}
onDragEnd={onSortDragEnd}
sortEnabled={stationSource.type === "favorite"}
/>
+8 -13
View File
@@ -1,12 +1,12 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { AS } from "@/storageControl";
import { STORAGE_KEYS } from "@/constants";
import { useFavoriteStation } from "@/stateBox/useFavoriteStation";
import { SortGridCard } from "./SortGridCard";
import { CarouselUIMode, StationSource } from "@/types";
import { SortableGridDragEndParams, SortableGridRenderItemInfo } from "react-native-sortables";
import { StationProps } from "@/lib/CommonTypes";
type SortModeConfig = {
listUpStation: any[][];
listUpStation: StationProps[][];
setListIndex: (i: number) => void;
width: number;
origW: number;
@@ -35,7 +35,7 @@ export function useSortMode({
carouselHeight,
stationSource,
}: SortModeConfig) {
const { setFavoriteStation } = useFavoriteStation();
const { reorderFavoriteStations } = useFavoriteStation();
// "carousel" | "sort" | "sort-exiting" の 3 値で UI モードを管理
const [uiMode, setUiMode] = useState<CarouselUIMode>("carousel");
// ソート開始時のカルーセル位置を保存(setListIndex(-1) される前の値)
@@ -69,7 +69,7 @@ export function useSortMode({
/** Sortable.Grid の renderItemuseCallback でメモ化) */
const sortGridRenderItem = useCallback(
({ item, index }: { item: any; index: number }) => {
({ item, index }: SortableGridRenderItemInfo<StationProps[]>) => {
const col = index % cols;
const row = Math.floor(index / cols);
const carouselCardCenterX =
@@ -85,7 +85,6 @@ export function useSortMode({
const exitY = carouselCardCenterY - cellCenterY;
return (
<SortGridCard
key={item[0].StationNumber}
item={item}
cellW={cellW}
cellH={cellH}
@@ -110,16 +109,12 @@ export function useSortMode({
/** Sortable.Grid の onDragEnd */
const onSortDragEnd = useCallback(
(newOrder: { indexToKey: string[] }) => {
(newOrder: SortableGridDragEndParams<StationProps[]>) => {
// お気に入りモード以外はデータを書き換えない(安全策)
if (stationSource.type !== "favorite") return;
const newList = newOrder.indexToKey.map(
(key) => listUpStation.find((s) => s[0].StationNumber === key) ?? []
);
setFavoriteStation(newList);
AS.setItem(STORAGE_KEYS.FAVORITE_STATION, JSON.stringify(newList));
reorderFavoriteStations(newOrder.data);
},
[listUpStation, setFavoriteStation, stationSource]
[reorderFavoriteStations, stationSource]
);
return {
+24 -20
View File
@@ -6,19 +6,33 @@ import Sortable from "react-native-sortables";
import { useFavoriteStation } from "../../stateBox/useFavoriteStation";
import { FavoriteSettingsItem } from "./FavoliteSettings/FavoiliteSettingsItem";
import { SheetHeaderItem } from "@/components/atom/SheetHeaderItem";
import { AS } from "@/storageControl";
import { STORAGE_KEYS } from "@/constants";
import { getFavoriteStationKey } from "@/lib/favoriteStationUtils";
import { StationProps } from "@/lib/CommonTypes";
import { SortableGridDragEndParams, SortableGridRenderItemInfo } from "react-native-sortables";
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 = () => {
const { favoriteStation, setFavoriteStation } = useFavoriteStation();
const { favoriteStation, reorderFavoriteStations } = useFavoriteStation();
const scrollableRef = useAnimatedRef();
const { goBack } = useNavigation();
const { colors, fixed } = useThemeColors();
const renderItem = useCallback((props) => {
const { item, index } = props;
const renderItem = useCallback(({ item }: SortableGridRenderItemInfo<StationProps[]>) => {
return (
<FavoriteSettingsItem currentStation={item} key={item[0].StationNumber} />
<FavoriteSettingsItem currentStation={item} />
);
}, []);
return (
@@ -40,20 +54,10 @@ export const FavoriteSettings = () => {
rowGap={0}
scrollableRef={scrollableRef} // required for auto scroll
snapOffsetY={0}
onDragEnd={(newOrder) => {
const newFavoriteStation = newOrder.indexToKey.map(
(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));
onDragEnd={(newOrder: SortableGridDragEndParams<StationProps[]>) => {
reorderFavoriteStations(newOrder.data);
}}
keyExtractor={(item) => item[0].StationNumber}
keyExtractor={(item: StationProps[]) => getFavoriteSettingsItemKey(item)}
/>
</Animated.ScrollView>
<Text
@@ -63,7 +67,7 @@ export const FavoriteSettings = () => {
borderStyle: "solid",
}}
>
()
</Text>
</View>
);
+25 -40
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 { objectIsEmpty } from "@/lib/objectIsEmpty";
import { useCurrentTrain } from "@/stateBox/useCurrentTrain";
import { useAreaInfo } from "@/stateBox/useAreaInfo";
import { AS } from "@/storageControl";
@@ -10,7 +9,7 @@ import { EachData } from "@/components/発車時刻表/EachData";
import { useAllTrainDiagram } from "@/stateBox/useAllTrainDiagram";
import { AreaDescription } from "@/components/発車時刻表/LED_inside_Component/AreaDescription";
import { getTime, trainTimeFiltering } from "@/lib/trainTimeFiltering";
import { eachTrainDiagramType, StationProps } from "@/lib/CommonTypes";
import type { eachTrainDiagramType, StationProps } from "@/lib/CommonTypes";
import { useNavigation } from "@react-navigation/native";
import { useThemeColors } from "@/lib/theme";
import { getCurrentTrainData } from "@/lib/getCurrentTrainData";
@@ -112,9 +111,6 @@ export const LED_vision: FC<props> = (props) => {
const { currentTrain } = useCurrentTrain();
const { stationList } = useStationList();
const { playbackCurrentTimeIso } = useTrainMenu();
const [stationDiagram, setStationDiagram] = useState<{
[key: string]: string;
}>({}); //当該駅の全時刻表
const [finalSwitch, setFinalSwitch] = useState(false);
const [trainIDSwitch, setTrainIDSwitch] = useState(false);
const [trainDescriptionSwitch, setTrainDescriptionSwitch] = useState(false);
@@ -190,48 +186,37 @@ export const LED_vision: FC<props> = (props) => {
}, [addListener, refreshVoicepeakSettings]);
useEffect(() => {
// 現在の駅に停車するダイヤを作成する副作用[列車ダイヤと現在駅情報]
if (!allTrainDiagram) {
setStationDiagram({});
return;
}
let returnData = {};
Object.keys(allTrainDiagram).forEach((key) => {
if (allTrainDiagram[key].match(station[0].Station_JP + ",")) {
returnData[key] = allTrainDiagram[key];
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];
}
});
setStationDiagram(returnData);
return result;
}, {} as { [key: string]: string });
}, [allTrainDiagram, currentStation]);
useEffect(() => {
setIsInfoArea(station.some((s) => areaStationID.includes(s.StationNumber)));
}, [allTrainDiagram, station]);
}, [areaStationID, station]);
/*
{lastStation: "当駅止", time: "12:34", train: "1234M"}
*/
const [trainTimeAndNumber, setTrainTimeAndNumber] = useState<
eachTrainDiagramType[]
>([]);
useEffect(() => {
//現在の駅に停車する列車から時刻を切り出してLEDベースにフォーマット
if (objectIsEmpty(stationDiagram)) return () => {};
const getTimeData = getTime(stationDiagram, station[0]);
setTrainTimeAndNumber(getTimeData);
}, [stationDiagram]);
const [selectedTrain, setSelectedTrain] = useState<eachTrainDiagramType[]>(
[]
const trainTimeAndNumber = useMemo(
() => (currentStation ? getTime(stationDiagram, currentStation) : []),
[currentStation, stationDiagram]
);
useEffect(() => {
if (!trainTimeAndNumber) return () => {};
if (!currentTrain) return () => {};
const data = trainTimeAndNumber
.filter((d) => currentTrain.map((m) => m.num).includes(d.train)) //現在の列車に絞る[ToDo]
const selectedTrain = useMemo(() => {
if (!currentStation || !currentTrain) return [];
const currentTrainNumbers = new Set(currentTrain.map((train) => train.num));
return trainTimeAndNumber
.filter((d) => currentTrainNumbers.has(d.train)) //現在の列車に絞る[ToDo]
.filter((d) => trainTimeFiltering({ d, currentTrain, station, stationList, now: playbackCurrentTimeIso })) //時間フィルター
.filter((d) => !!finalSwitch || d.lastStation != station[0].Station_JP); //最終列車表示設定
setSelectedTrain(data);
}, [trainTimeAndNumber, currentTrain, finalSwitch, stationList, playbackCurrentTimeIso]);
.filter((d) => !!finalSwitch || d.lastStation != currentStation.Station_JP); //最終列車表示設定
}, [currentStation, currentTrain, finalSwitch, playbackCurrentTimeIso, station, stationList, trainTimeAndNumber]);
const getVoicepeakCandidates = useCallback(() => {
if (!currentTrain?.length || !allCustomTrainData) return [];
+2 -5
View File
@@ -1,6 +1,5 @@
import React, { FC, useEffect } from "react";
import { Platform, Text } from "react-native";
import { useFavoriteStation } from "../../stateBox/useFavoriteStation";
import { StationProps } from "@/lib/CommonTypes";
import { lightColors } from "@/lib/theme";
type Props = {
@@ -9,10 +8,10 @@ type Props = {
};
export const AddressText: FC<Props> = (props) => {
const { currentStation, isMatsuyama } = props;
const {lodAddMigration} = useFavoriteStation();
const [stationAddress, setStationAddress] = React.useState("");
useEffect(() => {
if (!!currentStation[0].jslodApi) {
setStationAddress("");
if (!!currentStation[0]?.jslodApi) {
fetch(`${currentStation[0].jslodApi}.json`)
.then((response) => response.json())
.then((data) => {
@@ -22,8 +21,6 @@ export const AddressText: FC<Props> = (props) => {
][0]["value"];
setStationAddress(c);
});
}else{
lodAddMigration();
}
}, [currentStation]);
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 {
View,
Text,
@@ -8,8 +8,6 @@ import {
import { MaterialCommunityIcons } from "@expo/vector-icons";
import LottieView from "lottie-react-native";
import { useInterval } from "../../lib/useInterval";
import { AS } from "../../storageControl";
import { STORAGE_KEYS } from "@/constants";
import { useFavoriteStation } from "../../stateBox/useFavoriteStation";
import { StationNameArea } from "./StationNameArea";
@@ -24,38 +22,22 @@ export default function Sign(props) {
const { oP, oLP, isCurrentStation = false, stationID } = props;
const { width, height } = useWindowDimensions();
const { getStationDataFromId } = useStationList();
const { getStationDataFromId, originalStationList } = useStationList();
const { fixed } = useThemeColors();
if (!stationID) {
return <></>;
}
const [currentStationData] = useState(getStationDataFromId(stationID));
const { favoriteStation, setFavoriteStation } = useFavoriteStation();
// 駅マスター読込後にも再解決する。Hooks は stationID の有無にかかわらず同順で呼ぶ。
const currentStationData = useMemo(
() => (stationID ? getStationDataFromId(stationID) : []),
[stationID, originalStationList]
);
const { isFavoriteStation, toggleFavoriteStation } = useFavoriteStation();
const [nexPrePosition, setNexPrePosition] = useState(0);
const { originalStationList } = useStationList();
const [preStation, setPreStation] = useState();
const [nexStation, setNexStation] = useState();
const [testButtonStatus, setTestButtonStatus] = useState(false);
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]);
const testButtonStatus = isFavoriteStation(currentStationData);
useInterval(() => {
if (currentStationData.length == 1) {
if (currentStationData.length <= 1) {
setNexPrePosition(0);
return () => {};
}
@@ -66,10 +48,15 @@ export default function Sign(props) {
useEffect(() => {
setNexPrePosition(0);
if (!currentStationData.length) {
setPreStation(undefined);
setNexStation(undefined);
return;
}
getPreNextStation(currentStationData[0]);
if (currentStationData.length == 1) return () => {};
getPreNextStation(currentStationData[1]);
}, [currentStationData]);
}, [currentStationData, originalStationList]);
useEffect(() => {
if (!currentStationData[nexPrePosition]) return () => {};
@@ -89,16 +76,17 @@ export default function Sign(props) {
];
let returnData;
lineList.forEach((d) => {
let cache = originalStationList[d].findIndex(
let cache = originalStationList[d]?.findIndex(
(data) => data.StationNumber == now.StationNumber
);
if (cache != -1) {
if (cache != null && cache != -1) {
returnData = [
originalStationList[d][cache - 1],
originalStationList[d][cache + 1],
];
}
});
if (!returnData) return;
if (now.Station_JP == "宇多津" && now.StationNumber == null) {
if (returnData[1]) setPreStation(returnData[1]);
if (returnData[0]) setNexStation(returnData[0]);
@@ -107,26 +95,14 @@ export default function Sign(props) {
if (returnData[1]) setNexStation(returnData[1]);
}
};
const isMatsuyama = currentStationData[0].StationNumber == "Y55";
const isMatsuyama = currentStationData[0]?.StationNumber == "Y55";
//const isMatsuyama = true;
const favoliteChanger = () => {
if (testButtonStatus) {
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);
toggleFavoriteStation(currentStationData);
};
if (!stationID || currentStationData.length === 0) return null;
const styleSheet = {
: {
width: width * 0.8,
+3
View File
@@ -10,6 +10,9 @@ export const API_ENDPOINTS = {
/** 本日のダイアグラムデータ(experimental環境用) */
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',
+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]));
});
+40
View File
@@ -0,0 +1,40 @@
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の最終更新時刻を基準に、次の5分更新後10〜50秒の間へ取得を分散する。
* 更新予定時刻を過ぎてもfetchedAtが進んでいない場合は、短い間隔で再確認する。
*/
export function getNextOperationInfoFetchDelay(
fetchedAt: string,
nowMs = Date.now(),
randomValue = Math.random()
): number {
const fetchedAtMs = Date.parse(fetchedAt);
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));
}
+2
View File
@@ -1108,6 +1108,7 @@ const setStrings = () =>{
const elements = document.querySelectorAll('#disp > div > div > div[onclick]');
const setNewTrainItemUI = ()=>{
const aaa = (x2,pos) => {
if (!x2) return;
x2.style.display = 'flex';
x2.style.flexDirection = 'row';
if(pos == "right"){
@@ -1123,6 +1124,7 @@ const setStrings = () =>{
}
const aaa2 = (x2) => {
if (!x2) return;
x2.style.display = 'flex';
x2.style.flexDirection = 'row';
x2.style.alignItems = 'center';
@@ -29,10 +29,8 @@ class LiveActivityForegroundService : Service() {
const val NOTIFICATION_ID = 8001
private const val TAG = "LiveActivityService"
private const val POLL_INTERVAL_MS = 15_000L
private const val PRIMARY_API_URL =
"https://n8n.haruk.in/webhook/c501550c-7d1b-4e50-927b-4429fe18931a"
private const val FALLBACK_API_URL =
"https://script.google.com/macros/s/AKfycby9Y2-Bm75J_WkbZimi7iS8v5r9wMa9wtzpdwES9sOGF4i6HIYEJOM60W6gM1gXzt1o/exec"
private const val POSITION_API_URL =
"https://jr-shikoku-api-data-storage.haruk.in/tmp/currentPositions.json"
@Volatile
var isRunning = false
@@ -223,9 +221,9 @@ class LiveActivityForegroundService : Service() {
private fun pollTrainPosition() {
if (trainNumber.isEmpty()) return
try {
val json = fetchApi(PRIMARY_API_URL) ?: fetchApi(FALLBACK_API_URL)
val json = fetchApi(POSITION_API_URL)
if (json == null) {
Log.w(TAG, "Both APIs failed")
Log.w(TAG, "Position API failed")
return
}
@@ -330,7 +328,7 @@ class LiveActivityForegroundService : Service() {
*/
private fun pollStationTrains() {
try {
val json = fetchApi(PRIMARY_API_URL) ?: fetchApi(FALLBACK_API_URL) ?: return
val json = fetchApi(POSITION_API_URL) ?: return
val allTrains = parseAllTrains(json)
if (trainsJson == "[]" || trainsJson.isEmpty()) return
val trains = try { JSONArray(trainsJson) } catch (_: Exception) { return }
+93 -50
View File
@@ -6,9 +6,14 @@ import React, {
useRef,
FC,
} from "react";
import { InteractionManager } from "react-native";
import useInterval from "../lib/useInterval";
import { observedFetchJson, observedFetchText } from "@/lib/observability/network/observedFetch";
import { AppState, InteractionManager } from "react-native";
import { observedFetchJson } 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 = [
"Y00",
@@ -362,98 +367,136 @@ type props = { children: React.ReactNode };
export const AreaInfoProvider: FC<props> = ({ children }) => {
const [areaInfo, setAreaInfo] = useState("");
const [areaIconBadgeText, setAreaIconBadgeText] = useState("");
const [areaStationID, setAreaStationID] = useState([]);
const [areaStationID, setAreaStationID] = useState<string[]>([]);
const [isInfo, setIsInfo] = useState(false);
const areaDescriptionTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const initialFetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const fetchAreaDescription = () => {
observedFetchText(
"https://script.google.com/macros/s/AKfycbz80LcaEUrhnlEsLkJy0LG2IRO3DBVQhfNmN1d_0f_HvtsujNQpxM90SrV9yKWH_JG1Ww/exec",
{
endpoint: "operation_info_text",
source: "gas",
userVisible: true,
preload: false,
fetchPriority: "medium",
expectedContentType: "text",
timeoutMs: 15000,
retry: false,
urlPathTemplate: "/macros/s/AKfy.../exec",
}
)
.then((d) => setAreaInfo(d))
.catch(() => {});
const nextFetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const getAreaDataRef = useRef<() => void>(() => {});
const isFetchingRef = useRef(false);
const isMountedRef = useRef(false);
const isActiveRef = useRef(true);
const clearNextFetchTimeout = () => {
if (nextFetchTimeoutRef.current) {
clearTimeout(nextFetchTimeoutRef.current);
nextFetchTimeoutRef.current = null;
}
};
const scheduleAreaDescriptionFetch = () => {
if (areaDescriptionTimeoutRef.current) {
clearTimeout(areaDescriptionTimeoutRef.current);
}
areaDescriptionTimeoutRef.current = setTimeout(() => {
areaDescriptionTimeoutRef.current = null;
fetchAreaDescription();
}, 800);
const scheduleNextFetch = (delayMs: number) => {
if (!isMountedRef.current || !isActiveRef.current) return;
clearNextFetchTimeout();
nextFetchTimeoutRef.current = setTimeout(() => {
nextFetchTimeoutRef.current = null;
getAreaDataRef.current();
}, delayMs);
};
const getAreaData = () => {
observedFetchJson<any>("https://n8n.haruk.in/webhook/jr-shikoku-trainfo-flag", {
endpoint: "operation_info_flag",
source: "n8n",
if (isFetchingRef.current || !isActiveRef.current) return;
isFetchingRef.current = true;
observedFetchJson<OperationInfoSnapshot>(API_ENDPOINTS.OPERATION_INFO, {
endpoint: "operation_info",
source: "static_storage",
userVisible: true,
preload: true,
fetchPriority: "medium",
timeoutMs: 10000,
retry: true,
urlPathTemplate: "/webhook/jr-shikoku-trainfo-flag",
cache: "no-store",
urlPathTemplate: "/operation-info/jr-shikoku/latest.json",
})
.then((d) => {
if (!d.data) return;
const lineInfo = d.data.filter((e) => e.area != "genelic");
const genelicInfo = d.data.filter((e) => e.area == "genelic");
const activeLineInfo = lineInfo.filter((e) => e.status);
scheduleNextFetch(getNextOperationInfoFetchDelay(d.fetchedAt));
const areaData = d.compatibility?.areaInfo;
if (!Array.isArray(areaData)) return;
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) => {
return `${areaStationPair[e.area].id}`;
return areaStationPair[e.area as keyof typeof areaStationPair].id;
});
let stationIDList = [];
let stationIDList: string[] = [];
activeLineInfo.forEach((e) => {
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);
setAreaStationID(stationIDList);
setAreaIconBadgeText(
text.length == 0 ? (info ? "i" : "!") : text.join(",")
);
if (stationIDList.length > 0) {
scheduleAreaDescriptionFetch();
setAreaInfo(d.compatibility.operationInfoText);
} else {
setAreaInfo("");
}
})
.catch(() => {});
.catch(() => {
scheduleNextFetch(OPERATION_INFO_STALE_RETRY_MS);
})
.finally(() => {
isFetchingRef.current = false;
});
};
getAreaDataRef.current = getAreaData;
useEffect(() => {
isMountedRef.current = true;
isActiveRef.current =
AppState.currentState !== "background" &&
AppState.currentState !== "inactive";
const task = InteractionManager.runAfterInteractions(() => {
if (!isActiveRef.current) return;
initialFetchTimeoutRef.current = setTimeout(() => {
initialFetchTimeoutRef.current = null;
getAreaData();
getAreaDataRef.current();
}, 1200);
});
return () => {
const subscription = AppState.addEventListener("change", (nextState) => {
task.cancel?.();
if (initialFetchTimeoutRef.current) {
clearTimeout(initialFetchTimeoutRef.current);
initialFetchTimeoutRef.current = null;
}
if (areaDescriptionTimeoutRef.current) {
clearTimeout(areaDescriptionTimeoutRef.current);
areaDescriptionTimeoutRef.current = null;
if (nextState === "active") {
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 (
<AreaInfoContext.Provider
value={{
+127 -50
View File
@@ -1,62 +1,139 @@
import React, {
createContext,
useContext,
useState,
useEffect,
useLayoutEffect,
FC,
} from "react";
import React, { createContext, useCallback, useContext, useEffect, useRef, useState, FC } from "react";
import * as Sentry from "@sentry/react-native";
import { AS } from "@/storageControl";
import { useStationList } from "@/stateBox/useStationList";
import { StationProps } from "@/lib/CommonTypes";
import type { StationProps } from "@/lib/CommonTypes";
import { STORAGE_KEYS } from "@/constants";
import { logger } from "@/utils/logger";
const initialState = {
favoriteStation: [],
setFavoriteStation: () => {},
lodAddMigration: () => {},
import { getFavoriteStationKey, hasSameFavoriteStations, normalizeFavoriteStations, validateAndRebuildFavoriteStationOrder } from "@/lib/favoriteStationUtils";
import type { FavoriteStationGroup } from "@/lib/favoriteStationUtils";
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 = {
favoriteStation: StationProps[][];
setFavoriteStation: (d: StationProps[][]) => void;
lodAddMigration: () => void;
const initialState: FavoriteStationContextType = {
favoriteStation: [], isFavoriteStation: () => false,
addFavoriteStation: () => {}, removeFavoriteStation: () => {}, toggleFavoriteStation: () => {}, reorderFavoriteStations: () => {},
};
const FavoriteStationContext = createContext<initialStateType>(initialState);
const FavoriteStationContext = createContext<FavoriteStationContextType>(initialState);
export const useFavoriteStation = () => useContext(FavoriteStationContext);
export const useFavoriteStation = () => {
return useContext(FavoriteStationContext);
};
type Props = {
children: React.ReactNode;
};
export const FavoriteStationProvider: FC<Props> = ({ children }) => {
const [favoriteStation, setFavoriteStation] = useState<StationProps[][]>([]);
const { getStationDataFromName } = useStationList();
const lodAddMigration = () => {
const migration = favoriteStation.map((d) => {
return getStationDataFromName(d[0].Station_JP);
});
setFavoriteStation(migration);
};
useEffect(() => {
AS.getItem(STORAGE_KEYS.FAVORITE_STATION)
.then((d) => {
const returnData: StationProps[][] = JSON.parse(d);
setFavoriteStation(returnData);
})
export const FavoriteStationProvider: FC<{ children: React.ReactNode }> = ({ children }) => {
const [favoriteStation, setFavoriteStation] = useState<FavoriteStationGroup[]>([]);
const [isReady, setIsReady] = useState(false);
const { originalStationList } = useStationList();
const favoriteStationRef = useRef<FavoriteStationGroup[]>([]);
const persistQueueRef = useRef(Promise.resolve());
const persist = useCallback((next: FavoriteStationGroup[]) => {
const serialized = JSON.stringify(next);
const favoriteCount = next.length;
persistQueueRef.current = persistQueueRef.current
.catch(() => undefined)
.then(() => AS.setItem(STORAGE_KEYS.FAVORITE_STATION, serialized))
.catch((error) => {
// エラーログを記録(開発時のみ)
if (__DEV__) {
logger.warn("お気に入り駅の読み込みに失敗しました:", error);
}
if (__DEV__) logger.warn("お気に入り駅の保存に失敗しました:", error);
Sentry.captureException(error, {
tags: { area: "favorite_station_persist" },
extra: { favoriteCount },
});
});
}, []);
return (
<FavoriteStationContext.Provider
value={{ favoriteStation, setFavoriteStation, lodAddMigration }}
>
{children}
</FavoriteStationContext.Provider>
);
const updateFavoriteStations = useCallback((next: FavoriteStationGroup[]) => {
favoriteStationRef.current = next;
setFavoriteStation(next);
persist(next);
}, [persist]);
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>;
};
+13 -14
View File
@@ -10,8 +10,6 @@ struct OperationEntry: TimelineEntry {
}
struct OperationInfoProvider: TimelineProvider {
private let endpoint = "https://script.google.com/macros/s/AKfycbz80LcaEUrhnlEsLkJy0LG2IRO3DBVQhfNmN1d_0f_HvtsujNQpxM90SrV9yKWH_JG1Ww/exec"
func placeholder(in context: Context) -> OperationEntry {
OperationEntry(date: Date(), text: "読み込み中…", isLoading: true)
}
@@ -32,20 +30,21 @@ struct OperationInfoProvider: TimelineProvider {
}
private func fetchData(completion: @escaping (OperationEntry) -> Void) {
guard let url = URL(string: endpoint) else {
completion(OperationEntry(date: Date(), text: "通常運行中です。", isLoading: false))
return
}
URLSession.shared.dataTask(with: url) { data, _, error in
guard let data = data, error == nil,
let text = String(data: data, encoding: .utf8),
!text.isEmpty else {
completion(OperationEntry(date: Date(), text: "通常運行中です。", isLoading: false))
return
fetchOperationInfoSnapshot { result in
let operationInfoText: String
switch result {
case .success(let snapshot):
operationInfoText = snapshot.compatibility.operationInfoText
case .failure:
operationInfoText = ""
}
let displayText = text.replacingOccurrences(of: "^", with: "\n")
let displayText = operationInfoText.isEmpty
? "通常運行中です。"
: operationInfoText.replacingOccurrences(of: "^", with: "\n")
completion(OperationEntry(date: Date(), text: displayText, isLoading: false))
}.resume()
}
}
}
+47
View File
@@ -1,6 +1,9 @@
import Foundation
import WidgetKit
import SwiftUI
let operationInfoSnapshotURL = "https://jr-shikoku-api-data-storage.haruk.in/operation-info/jr-shikoku/latest.json"
/// App Group ID shared between the main app and widget extension.
let appGroupID = "group.jrshikokuinfo.xprocess.hrkn"
@@ -13,6 +16,50 @@ struct FelicaSnapshot: Codable {
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 {
UserDefaults(suiteName: appGroupID) ?? .standard
}
+5 -12
View File
@@ -14,7 +14,6 @@ struct ShortcutEntry: TimelineEntry {
struct ShortcutProvider: TimelineProvider {
private let delayEndpoint = "https://script.google.com/macros/s/AKfycbw-0RDLAu8EQAEWA860tk4KVW6VOr3iIU900AcWEfqIP16gtNUG1XO_A3oBfAGiNeCf/exec"
private let operationEndpoint = "https://script.google.com/macros/s/AKfycbz80LcaEUrhnlEsLkJy0LG2IRO3DBVQhfNmN1d_0f_HvtsujNQpxM90SrV9yKWH_JG1Ww/exec"
func placeholder(in context: Context) -> ShortcutEntry {
ShortcutEntry(date: Date(), delayCount: 0, hasInfo: false, amountText: "未読取")
@@ -59,17 +58,11 @@ struct ShortcutProvider: TimelineProvider {
//
group.enter()
if let url = URL(string: operationEndpoint) {
URLSession.shared.dataTask(with: url) { data, _, _ in
defer { group.leave() }
if let data = data,
let text = String(data: data, encoding: .utf8),
!text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
hasInfo = true
}
}.resume()
} else {
group.leave()
fetchOperationInfoSnapshot { result in
defer { group.leave() }
if case .success(let snapshot) = result {
hasInfo = snapshot.compatibility.hasOperationInfo
}
}
// 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[];
};
};