Compare commits

..
11 changed files with 278 additions and 95 deletions
+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
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',
@@ -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追加を再検討する。
+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));
}
@@ -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={{
+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[];
};
};