From f526fb5b085e5fb8a78a642ad332d1fad13fb64f Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 6 Apr 2026 15:26:11 +0000 Subject: [PATCH] feat: add dev flag and geojson hash check - Add devMode state to MetaContext via `?dev=true` or `?dev=1` query parameter - Skip precompiled IndexedDB caching and bypass Tutorial UI when devMode is enabled - Update `AppLayout.tsx` manifest download logic to parse and compare precise file hashes to decide when local GeoJSON updates are needed Co-authored-by: OsakaLOOP <68284076+OsakaLOOP@users.noreply.github.com> --- src/AppLayout.tsx | 64 +++++++++++++++++++++++++------------ src/GlobalProvider.jsx | 16 +++++++++- src/components/Tutorial.jsx | 8 +++-- src/contexts.ts | 1 + 4 files changed, 64 insertions(+), 25 deletions(-) diff --git a/src/AppLayout.tsx b/src/AppLayout.tsx index 30b7459..45e8062 100644 --- a/src/AppLayout.tsx +++ b/src/AppLayout.tsx @@ -29,6 +29,7 @@ import { api } from './services/api'; import { useShallow } from 'zustand/react/shallow'; import { Toaster, toast } from 'react-hot-toast'; import DistanceWorker from './workers/distance.worker.js?worker'; +import { useMeta } from './contexts'; const CURRENT_VERSION = meta["currentVersion"]; @@ -64,6 +65,7 @@ export const AppLayout: React.FC = () => { const { loadUserData, saveData } = useUserData(); const [stationMenu, setStationMenu] = useState(null); const [isExportingKML, setIsExportingKML] = useState(false); + const { devMode } = useMeta() as any; const isDraggingRef = useRef(false); const workerRef = useRef(null); const distanceWorkerRef = useRef(null); @@ -376,22 +378,26 @@ export const AppLayout: React.FC = () => { // Attempt to read fully precompiled geoData & railwayData structures directly (FAST PATH) let precompiledGeoData: any = null; let precompiledRailwayData: any = null; - try { - const txGeo = dbInstance.transaction(db.STORE_FILES, 'readonly'); - const storeGeo = txGeo.objectStore(db.STORE_FILES); - - const reqGeo = storeGeo.get('__precompiled_geodata'); - precompiledGeoData = await new Promise((resolve) => { - reqGeo.onsuccess = () => resolve(reqGeo.result || null); - reqGeo.onerror = () => resolve(null); - }); + if (!devMode) { + try { + const txGeo = dbInstance.transaction(db.STORE_FILES, 'readonly'); + const storeGeo = txGeo.objectStore(db.STORE_FILES); - const reqRail = storeGeo.get('__precompiled_railwaydata'); - precompiledRailwayData = await new Promise((resolve) => { - reqRail.onsuccess = () => resolve(reqRail.result || null); - reqRail.onerror = () => resolve(null); - }); - } catch(e) {} + const reqGeo = storeGeo.get('__precompiled_geodata'); + precompiledGeoData = await new Promise((resolve) => { + reqGeo.onsuccess = () => resolve(reqGeo.result || null); + reqGeo.onerror = () => resolve(null); + }); + + const reqRail = storeGeo.get('__precompiled_railwaydata'); + precompiledRailwayData = await new Promise((resolve) => { + reqRail.onsuccess = () => resolve(reqRail.result || null); + reqRail.onerror = () => resolve(null); + }); + } catch(e) {} + } else { + console.log('[Autoload] Dev mode enabled: skipping __precompiled_geodata cache.'); + } // Exclude caches from cachedFiles list used for manifest comparison realFiles = cachedFiles.filter(f => f.fileName && !f.fileName.startsWith('__precompiled_') && !f.fileName.startsWith('zustand_')); @@ -501,15 +507,31 @@ export const AppLayout: React.FC = () => { const manifestRes = await fetch(`/geojson_manifest.json?v=${Date.now()}`).catch(() => null); if (manifestRes && manifestRes.ok) { const manifest = await manifestRes.json(); - const geojsonFiles = manifest.files || []; - - const cachedFileNames = new Set(realFiles.map(f => f.fileName)); - const missingFiles = geojsonFiles.filter((f: string) => !cachedFileNames.has(f.replace(/\.(geojson|json)$/i, ''))); + let missingFiles: { fileName: string; hash?: string }[] = []; + + if (Array.isArray(manifest.files)) { + // Legacy array format: fallback to checking existence only + const cachedFileNames = new Set(realFiles.map(f => f.fileName)); + missingFiles = manifest.files + .filter((f: string) => !cachedFileNames.has(f.replace(/\.(geojson|json)$/i, ''))) + .map((f: string) => ({ fileName: f })); + } else if (manifest.files && typeof manifest.files === 'object') { + // New hash-based format: { "JR-East.geojson": "hash123", ... } + const cachedFilesMap = new Map(realFiles.map(f => [f.fileName, f.hash])); + missingFiles = Object.entries(manifest.files) + .filter(([fileName, hash]) => { + const localFileName = fileName.replace(/\.(geojson|json)$/i, ''); + const localHash = cachedFilesMap.get(localFileName); + return localHash !== hash; // Also covers missing files (localHash is undefined) + }) + .map(([fileName, hash]) => ({ fileName, hash: hash as string })); + } if (missingFiles.length > 0) { let downloadedCount = 0; const totalToDownload = missingFiles.length; - const downloadTasks = missingFiles.map(async (fileName: string) => { + const downloadTasks = missingFiles.map(async (fileInfo: { fileName: string, hash?: string }) => { + const { fileName, hash } = fileInfo; try { const res = await fetch(`/geojson/${fileName.includes('.geojson') ? fileName : `${fileName}.geojson`}?v=${Date.now()}`); downloadedCount++; @@ -531,7 +553,7 @@ export const AppLayout: React.FC = () => { const json = await res.json(); const rawCompanyName = fileName.replace(/\.(geojson|json)$/i, ''); const matchedCompany = findBestCompanyKey(rawCompanyName, companyIndex); - const dataItem = { json, company: matchedCompany, fileName: rawCompanyName }; + const dataItem = { json, company: matchedCompany, fileName: rawCompanyName, hash }; db.set(db.STORE_FILES, rawCompanyName, dataItem).catch(e => console.warn('Cache write failed', e)); return dataItem; } catch (e: any) { return null; } diff --git a/src/GlobalProvider.jsx b/src/GlobalProvider.jsx index 86f6a4d..006d9cb 100644 --- a/src/GlobalProvider.jsx +++ b/src/GlobalProvider.jsx @@ -53,9 +53,23 @@ export const GlobalProvider = ({ children }) => { return () => clearInterval(timer); }, []); + const [devMode, setDevMode] = useState(false); + + useEffect(() => { + const urlParams = new URLSearchParams(window.location.search); + if (urlParams.get('dev') === '1' || urlParams.get('dev') === 'true') { + setDevMode(true); + } + }, []); + + const metaContextValue = { + ...meta, + devMode + }; + return ( - + {children} diff --git a/src/components/Tutorial.jsx b/src/components/Tutorial.jsx index fadbe7d..6229e08 100644 --- a/src/components/Tutorial.jsx +++ b/src/components/Tutorial.jsx @@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef, useLayoutEffect, useCallback } from import { createPortal } from 'react-dom'; import { X, ChevronRight, CheckCircle2, ArrowRight } from 'lucide-react'; import { DefaultCitySelector } from './modals/DefaultCitySelector'; +import { useMeta } from '../contexts'; const STEPS = [ { @@ -146,6 +147,7 @@ const Tutorial = ({ pinMode, editorMode }) => { + const { devMode } = useMeta(); const [step, setStep] = useState(-1); // -1: Loading/Check, 0+: Steps, -2: Skipped, -3: City Selector const [rect, setRect] = useState(null); const [isVisible, setIsVisible] = useState(false); @@ -201,8 +203,8 @@ const Tutorial = ({ const skipped = localStorage.getItem('rail_tutorial_skipped'); const citySelectorDone = localStorage.getItem('rail_city_selector_done'); - if (skipped === 'true' || user) { - if (!citySelectorDone) { + if (devMode || skipped === 'true' || user) { + if (!citySelectorDone && !devMode) { setStep(-3); // Show City Selector if skipped/logged in but not done } else { setStep(-2); // Completely skipped @@ -211,7 +213,7 @@ const Tutorial = ({ } setStep(0); setIsVisible(true); - }, [user]); + }, [user, devMode]); const handleCitySelectorComplete = useCallback(() => { localStorage.setItem('rail_city_selector_done', 'true'); diff --git a/src/contexts.ts b/src/contexts.ts index 65cf502..300d8aa 100644 --- a/src/contexts.ts +++ b/src/contexts.ts @@ -8,6 +8,7 @@ export const MetaContext = createContext({ thememode: 'light', area: 'JP', locale: 'zh-CN', + devMode: false, }); export const useMeta = () => useContext(MetaContext);