Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26090ff486 | ||
|
|
11cbb586be | ||
|
|
a3707f2970 |
@@ -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 };
|
||||
};
|
||||
|
||||
|
||||
@@ -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追加を再検討する。
|
||||
@@ -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));
|
||||
}
|
||||
+5
-7
@@ -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
@@ -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={{
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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残高取得
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
* プロジェクト全体で使用する型を集約
|
||||
*/
|
||||
|
||||
export * from "./operationInfo";
|
||||
|
||||
/**
|
||||
* バス停・駅データの種別
|
||||
*/
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user