From 98c7f54ccf9609d56d1c3cb12ccf4dca618e63d5 Mon Sep 17 00:00:00 2001 From: Felix Lee Date: Fri, 19 Jun 2026 23:26:56 -0700 Subject: [PATCH 1/4] Add resume session button --- src/components/Element.js | 40 +++++-- src/context/trackerContext.js | 214 +++++++++++++++++++++++++++++++--- src/scenes/TrackerChecks.js | 8 ++ src/scenes/TrackerLauncher.js | 86 +++++++++++--- src/scenes/TrackerLayout.js | 4 +- 5 files changed, 309 insertions(+), 43 deletions(-) diff --git a/src/components/Element.js b/src/components/Element.js index 4fe1174..c7ddb43 100644 --- a/src/components/Element.js +++ b/src/components/Element.js @@ -34,11 +34,15 @@ const Element = props => { hidden = false } = props; - const { markCounter, markItem, startingIndex: trackerContextStartingIndex, startingItem } = useItems(items, id); + const { + markCounter, markItem, startingIndex: trackerContextStartingIndex, startingItem, savedIndex, savedCounter, savedLabelValue, + } = useItems(items, id, name); useElement(id, startingItem); const labelSelect = useLabelSelect(); - const [selected, setSelected] = useState(trackerContextStartingIndex || selectedStartingIndex); + const resolvedStartingIndex = savedIndex !== null ? savedIndex : trackerContextStartingIndex; + + const [selected, setSelected] = useState(resolvedStartingIndex || selectedStartingIndex); const [counter, setCounter] = useState(0); const [iconHash, setIconHash] = useState(null); const [draggedIcon, setDraggedIcon] = useState(null); @@ -51,23 +55,30 @@ const Element = props => { return (acc += cv); }, ""); - if (iconHash !== null && hash !== iconHash && trackerContextStartingIndex === 0) { + if (iconHash !== null && hash !== iconHash && resolvedStartingIndex === 0) { setSelected(0); } setIconHash(hash); - }, [icons, iconHash, name, trackerContextStartingIndex]); + }, [icons, iconHash, name, resolvedStartingIndex]); - // Sync selected state when starting items change + // Sync selected state when the restored/starting item index changes useEffect(() => { - if (trackerContextStartingIndex > 0) { - // This element should claim the starting item - setSelected(trackerContextStartingIndex); + if (resolvedStartingIndex > 0) { + // This element should claim the restored or starting item + setSelected(resolvedStartingIndex); } else if (!hasUserInteracted.current) { // Another element claimed the item and user hasn't interacted - reset to uncollected setSelected(0); } - }, [trackerContextStartingIndex]); + }, [resolvedStartingIndex]); + + // Restore a saved counter value when one is present + useEffect(() => { + if (savedCounter !== null) { + setCounter(savedCounter); + } + }, [savedCounter]); const icon = useMemo(() => { return icons[selected]; @@ -176,6 +187,7 @@ const Element = props => { label={label} labelStartingIndex={labelStartingIndex} labelBackgroundColor={labelBackgroundColor} + savedValue={savedLabelValue} onLabelChange={(value) => labelSelect(id, name, value)} /> )} @@ -194,9 +206,17 @@ const Element = props => { ); }; -const ElementLabel = ({ label, labelStartingIndex, labelBackgroundColor, onLabelChange }) => { +const ElementLabel = ({ label, labelStartingIndex, labelBackgroundColor, savedValue, onLabelChange }) => { const [index, setIndex] = useState(labelStartingIndex); + // Restore a saved label selection by resolving its value back to an index + useEffect(() => { + if (savedValue !== null && Array.isArray(label)) { + const idx = label.indexOf(savedValue); + if (idx >= 0) { setIndex(idx); } + } + }, [savedValue, label]); + const display = useMemo(() => { if (Array.isArray(label)) { return label[index]; diff --git a/src/context/trackerContext.js b/src/context/trackerContext.js index 5fedebd..14a0847 100644 --- a/src/context/trackerContext.js +++ b/src/context/trackerContext.js @@ -1,9 +1,10 @@ import _ from "lodash"; -import { createContext, useCallback, useContext, useEffect, useMemo, useReducer } from "react"; +import { createContext, useCallback, useContext, useEffect, useMemo, useReducer, useRef } from "react"; import COMBO_ITEMS from "../data/combo-items.json"; import COUNTER_TO_ITEM from "../data/counter-to-item.json"; import DEFAULT_ITEMS from "../data/default-items.json"; +import DUNGEONS from "../data/dungeons.json"; import ITEMS_JSON from "../data/items.json"; import UUID_TO_ITEM from "../data/uuid-to-item.json"; import { getEFKSkipRegions, getSelectedEFKDungeons, isEFK, isEFKLabel } from "../utils/efk"; @@ -134,6 +135,71 @@ function setGeneratorVersionCache(version) { localStorage.setItem("generator_version", version); } +// localStorage key for a persisted tracker session (single slot). +const SESSION_KEY = "tracker_session"; + +/** + * Builds a serializable snapshot of user progress from the tracker state. + * @param {object} state - The current tracker state. + * @returns {object} A snapshot suitable for JSON serialization. + */ +function buildSnapshot(state) { + const checkedLocations = {}; + _.forEach(state.locations, (locations, regionName) => { + const checkedNames = _.keys(_.pickBy(locations, location => location.isChecked)); + if (checkedNames.length) { + checkedLocations[regionName] = checkedNames; + } + }); + + return { + // Whether this was a check-tracking session, so Resume opens the right route/size. + checksEnabled: !_.isEmpty(state.locations), + // The layout active at save time, used to detect layout changes before resuming. + layout: localStorage.getItem("layout"), + // MQ/shortcut toggles live in the settings singletons, not in reducer state. + mq_dungeons_specific: SettingsHelper.settings?.mq_dungeons_specific || [], + dungeon_shortcuts: SettingsHelper.settings?.dungeon_shortcuts || [], + settings_string: state.settings_string, + generator_version: state.generator_version, + items_list: state.items_list, + counters: state.counters, + labelSelections: state.labelSelections, + starting_item_claims: state.starting_item_claims, + unchanged_starting_inventory: state.unchanged_starting_inventory, + checkedLocations, + }; +} + +/** + * Persists a snapshot of the tracker state to localStorage. + * @param {object} state - The current tracker state. + */ +function saveSession(state) { + try { + localStorage.setItem(SESSION_KEY, JSON.stringify(buildSnapshot(state))); + } catch (err) { + console.warn("Failed to save tracker session:", err); + } +} + +/** + * Loads the persisted session snapshot, if one exists and is parseable. + * @returns {object|null} The snapshot, or null when absent/corrupt. + */ +function loadSession() { + try { + const raw = localStorage.getItem(SESSION_KEY); + if (!raw) { return null; } + const snapshot = JSON.parse(raw); + if (!snapshot || typeof snapshot !== "object") { return null; } + return snapshot; + } catch (err) { + console.warn("Failed to load tracker session:", err); + return null; + } +} + /** * Tracker context reducer handling all state mutations. * @param {object} state - The current tracker state. @@ -178,10 +244,12 @@ function reducer(state, action) { const isChecked = locations[regionName][locationName].isChecked; _.set(locations, [regionName, locationName, "isChecked"], !isChecked); - return { + const newState = { ...state, locations, }; + saveSession(newState); + return newState; } } case "MQ_TOGGLE": { @@ -218,10 +286,12 @@ function reducer(state, action) { parseItems(state.items_list, state.counters, state.unchanged_starting_inventory), ); - return { + const newState = { ...state, locations: validatedLocations, }; + saveSession(newState); + return newState; } case "SHORTCUT_TOGGLE": { // payload = regionName @@ -244,10 +314,12 @@ function reducer(state, action) { parseItems(state.items_list, state.counters, state.unchanged_starting_inventory), ); - return { + const newState = { ...state, locations: validatedLocations, }; + saveSession(newState); + return newState; } case "REGION_TOGGLE": { // payload = regionName @@ -260,10 +332,12 @@ function reducer(state, action) { _.set(locationData, "isChecked", !setTo); }); - return { + const newState = { ...state, locations, }; + saveSession(newState); + return newState; } case "ITEMS_UPDATE_FROM_LOGIC": { const settings = payload; @@ -332,12 +406,14 @@ function reducer(state, action) { ? state.locations : validateLocations(state.locations, parsedItems, getEFKSkipRegions(state.settings_string, state.labelSelections)); - return { + const newState = { ...state, locations, items: parsedItems, counters, }; + saveSession(newState); + return newState; } case "ITEM_MARK": { const { item, parentID } = payload; @@ -357,12 +433,14 @@ function reducer(state, action) { ? state.locations : validateLocations(state.locations, parsedItems, getEFKSkipRegions(state.settings_string, state.labelSelections)); - return { + const newState = { ...state, locations, items: parsedItems, items_list, }; + saveSession(newState); + return newState; } case "STRING_SET": { setSettingsStringCache(payload); @@ -382,6 +460,7 @@ function reducer(state, action) { const { elementId, name, value } = payload; const newLabelSelections = { ...state.labelSelections, [elementId]: { name, value } }; + let newState = { ...state, labelSelections: newLabelSelections }; if (isEFKLabel(name) && isEFK(state.settings_string)) { // Accessible dungeons changed; revalidate locations against the updated skip regions. const locations = validateLocations( @@ -389,10 +468,11 @@ function reducer(state, action) { state.items, getEFKSkipRegions(state.settings_string, newLabelSelections), ); - return { ...state, labelSelections: newLabelSelections, locations }; + newState = { ...newState, locations }; } - return { ...state, labelSelections: newLabelSelections }; + saveSession(newState); + return newState; } case "ELEMENT_REGISTER": { const { id, startingItem } = payload; @@ -407,7 +487,7 @@ function reducer(state, action) { let newUnchangedStartingInventory = [...state.unchanged_starting_inventory]; const newStartingItemClaims = { ...state.starting_item_claims }; - if (startingItem !== null) { + if (startingItem !== null && newItemsList[id] === undefined) { newItemsList[id] = startingItem; // Note that starting item appears on the tracker layout @@ -431,6 +511,66 @@ function reducer(state, action) { starting_item_claims: newStartingItemClaims, }; } + case "SESSION_RESTORE": { + const snapshot = payload; + if (!snapshot) { return state; } + + const items_list = snapshot.items_list || {}; + const counters = snapshot.counters || {}; + const labelSelections = snapshot.labelSelections || {}; + const starting_item_claims = snapshot.starting_item_claims || {}; + const unchanged_starting_inventory = snapshot.unchanged_starting_inventory || []; + + if (snapshot.mq_dungeons_specific) { + _.set(LogicHelper.settings, "mq_dungeons_specific", snapshot.mq_dungeons_specific); + SettingsHelper.settings["mq_dungeons_specific"] = snapshot.mq_dungeons_specific; + } + if (snapshot.dungeon_shortcuts) { + _.set(LogicHelper.settings, "dungeon_shortcuts", snapshot.dungeon_shortcuts); + SettingsHelper.settings["dungeon_shortcuts"] = snapshot.dungeon_shortcuts; + } + SettingsHelper.invalidateCachedSets(); + + const locations = _.cloneDeep(state.locations); + + // Rebuild each dungeon's location list to match the restored MQ setting. + _.forEach(_.keys(locations), regionName => { + if (!_.includes(DUNGEONS, regionName)) { return; } + const locationKey = SettingsHelper.isMQDungeon(regionName) ? "dungeon_mq" : "dungeon"; + _.set(locations, regionName, {}); + _.forEach(Locations.locations[locationKey][regionName], (locationData, locationName) => { + if (Locations.isProgressLocation(locationData)) { + _.set(locations, [regionName, locationName], { isAvailable: false, isChecked: false }); + } + }); + }); + + _.forEach(snapshot.checkedLocations || {}, (locationNames, regionName) => { + if (!locations[regionName]) { return; } + locationNames.forEach(locationName => { + if (locations[regionName][locationName]) { + _.set(locations, [regionName, locationName, "isChecked"], true); + } + }); + }); + + const settingsString = snapshot.settings_string || state.settings_string; + const skipRegions = isEFK(settingsString) ? getEFKSkipRegions(settingsString, labelSelections) : new Set(); + + const parsedItems = parseItems(items_list, counters, unchanged_starting_inventory); + const validatedLocations = validateLocations(locations, parsedItems, skipRegions); + + return { + ...state, + locations: validatedLocations, + items: parsedItems, + items_list, + counters, + labelSelections, + starting_item_claims, + unchanged_starting_inventory, + }; + } default: throw new Error(); } @@ -458,7 +598,6 @@ function TrackerProvider(props) { const [state, dispatch] = useReducer(reducer, initialState); - // Implementar local storage? return ; } @@ -507,7 +646,7 @@ const useLocation = () => { return [actions]; }; -const useItems = (items, elementId = null) => { +const useItems = (items, elementId = null, name = null) => { const { state, dispatch } = useTracker(); const actions = useMemo( @@ -562,7 +701,27 @@ const useItems = (items, elementId = null) => { return itemID; }, [items, state.unchanged_starting_inventory, state.starting_item_claims, elementId]); - return { ...actions, startingIndex, startingItem }; + const savedIndex = useMemo(() => { + if (elementId === null || !items || !items.length) { return null; } + const savedItem = state.items_list[elementId]; + if (!savedItem) { return null; } + const idx = items.indexOf(savedItem); + return idx >= 0 ? idx : null; + }, [items, elementId, state.items_list]); + + const savedCounter = useMemo(() => { + if (name === null) { return null; } + const value = state.counters[name]; + return value === undefined ? null : value; + }, [name, state.counters]); + + const savedLabelValue = useMemo(() => { + if (elementId === null) { return null; } + const selection = state.labelSelections[elementId]; + return selection ? selection.value : null; + }, [elementId, state.labelSelections]); + + return { ...actions, startingIndex, startingItem, savedIndex, savedCounter, savedLabelValue }; }; const useLabelSelect = () => { @@ -596,9 +755,32 @@ const useSettingsString = () => { return { ...actions, settings_string, generator_version }; }; +/** + * Restores a saved session once the tracker structure is ready, when the + * window was opened with `?resume=1`. Runs at most once. + * @param {boolean} isReady - True when locations/items have finished building. + */ +const useSessionRestore = isReady => { + const { dispatch } = useTracker(); + const restoredRef = useRef(false); + + useEffect(() => { + if (!isReady || restoredRef.current) { return; } + + const params = new URLSearchParams(window.location.search); + if (params.get("resume") !== "1") { return; } + + const snapshot = loadSession(); + if (snapshot) { + dispatch({ type: "SESSION_RESTORE", payload: snapshot }); + } + restoredRef.current = true; + }, [isReady, dispatch]); +}; + export { - getGeneratorVersionCache, getSettingsStringCache, TrackerProvider, useChecks, - useElement, useItems, useLabelSelect, useLocation, useSelectedEFKDungeons, - useSettingsString, useTracker + getGeneratorVersionCache, getSettingsStringCache, loadSession, TrackerProvider, + useChecks, useElement, useItems, useLabelSelect, useLocation, + useSelectedEFKDungeons, useSessionRestore, useSettingsString, useTracker }; diff --git a/src/scenes/TrackerChecks.js b/src/scenes/TrackerChecks.js index 8badc79..0cd2441 100644 --- a/src/scenes/TrackerChecks.js +++ b/src/scenes/TrackerChecks.js @@ -1,10 +1,18 @@ +import _ from "lodash"; + import frog from "../assets/icons/hashfrogsping.gif"; +import { useSessionRestore, useTracker } from "../context/trackerContext"; import useLogicInitialization from "../hooks/useLogicInitialization"; import Checks from "./Checks"; import Layout from "./Layout"; const TrackerChecks = () => { const { isLoading } = useLogicInitialization(); + const { state } = useTracker(); + + // In checks mode the location structure is built by Checks.js after logic + // loads; only then is it safe to overlay saved progress. + useSessionRestore(!isLoading && !_.isEmpty(state.locations)); if (isLoading) { return ( diff --git a/src/scenes/TrackerLauncher.js b/src/scenes/TrackerLauncher.js index 8aa0e46..50506a0 100644 --- a/src/scenes/TrackerLauncher.js +++ b/src/scenes/TrackerLauncher.js @@ -9,7 +9,7 @@ import Form from "react-bootstrap/Form"; import InputGroup from "react-bootstrap/InputGroup"; import LayoutSelector from "../components/LayoutSelector"; import { useLayout } from "../context/layoutContext"; -import { useSettingsString } from "../context/trackerContext"; +import { loadSession, useSettingsString } from "../context/trackerContext"; import SettingStringsJSON from "../data/setting-strings.json"; import useDebounce from "../hooks/useDebounce"; @@ -43,19 +43,6 @@ const TrackerLauncher = () => { }; }, [checks, layout]); - const launchTracker = useCallback(() => { - let url = `${baseURL}/tracker`; - if (checks) { url = `${baseURL}/tracker/checks`; } - - const { width, height } = layoutSize; - - window.open( - url, - "HashFrog Tracker", - `toolbar=0,location=0,status=0,menubar=0,scrollbars=0,resizable=0,width=${width},height=${height}` - ); - }, [checks, layoutSize]); - const { setString: setSettingsStringCache, settings_string: cachedSettingsString, @@ -94,6 +81,62 @@ const TrackerLauncher = () => { setGeneratorVersionCache(debouncedVersion); }, [debouncedVersion, setGeneratorVersionCache]); + const launchTracker = useCallback(() => { + let url = `${baseURL}/tracker`; + if (checks) { url = `${baseURL}/tracker/checks`; } + + // Launch with exactly what the launcher currently displays, in case a + // prior resumeSession overwrote the cached config in localStorage. + localStorage.setItem("layout", JSON.stringify(layout)); + localStorage.setItem("settings_string", checks ? settingsString : ""); + localStorage.setItem("generator_version", generatorVersion); + + const { width, height } = layoutSize; + + window.open( + url, + "HashFrog Tracker", + `toolbar=0,location=0,status=0,menubar=0,scrollbars=0,resizable=0,width=${width},height=${height}` + ); + }, [checks, layout, settingsString, generatorVersion, layoutSize]); + + // Track whether a saved session exists so the Resume button reacts when one + // is created in a popup window; refresh on focus when returning to the launcher. + const [savedSession, setSavedSession] = useState(loadSession); + useEffect(() => { + const refresh = () => setSavedSession(loadSession()); + window.addEventListener("focus", refresh); + return () => window.removeEventListener("focus", refresh); + }, []); + + const resumeSession = useCallback(() => { + // Re-read fresh: another window may have saved a newer session since the + // launcher last rendered, leaving the render-time `savedSession` stale. + const session = loadSession(); + if (!session) { return; } + + // Force the resumed window to reproduce the saved session's config. + localStorage.setItem("layout", session.layout); + localStorage.setItem("settings_string", session.settings_string); + localStorage.setItem("generator_version", session.generator_version); + + const resumeChecks = !!session.checksEnabled; + let url = resumeChecks ? `${baseURL}/tracker/checks` : `${baseURL}/tracker`; + url += "?resume=1"; + + const { + layoutConfig: { width, height }, + } = JSON.parse(session.layout); + const windowWidth = width + (resumeChecks ? 285 : 0); + const windowHeight = height + 25; + + window.open( + url, + "HashFrog Tracker", + `toolbar=0,location=0,status=0,menubar=0,scrollbars=0,resizable=0,width=${windowWidth},height=${windowHeight}` + ); + }, []); + const updateString = (preset) => { if (preset.settingsString) { @@ -132,7 +175,7 @@ const TrackerLauncher = () => { Tracker Settings -
+
+
+ +
+
diff --git a/src/scenes/TrackerLayout.js b/src/scenes/TrackerLayout.js index 9c4398e..9576500 100644 --- a/src/scenes/TrackerLayout.js +++ b/src/scenes/TrackerLayout.js @@ -1,9 +1,11 @@ import frog from "../assets/icons/hashfrogsping.gif"; +import { useSessionRestore } from "../context/trackerContext"; import useLogicInitialization from "../hooks/useLogicInitialization"; import Layout from "./Layout"; const TrackerLayout = () => { - const { isLoading } = useLogicInitialization(); + const { isLoading, isInitialized } = useLogicInitialization(); + useSessionRestore(isInitialized); if (isLoading) { return ( From 2675413f755d8716cddc5efc1e26e130b543a13e Mon Sep 17 00:00:00 2001 From: Felix Lee Date: Wed, 24 Jun 2026 18:29:48 -0700 Subject: [PATCH 2/4] Persist hints on resume --- src/components/CustomReactSelect.js | 27 +++++++++++++++++++++- src/components/HintsTable.js | 2 ++ src/context/trackerContext.js | 35 ++++++++++++++++++++++++++++- 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/src/components/CustomReactSelect.js b/src/components/CustomReactSelect.js index 81041ee..8ee28a8 100644 --- a/src/components/CustomReactSelect.js +++ b/src/components/CustomReactSelect.js @@ -1,6 +1,8 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import Select from "react-select"; +import { useHintEntry } from "../context/trackerContext"; + const CustomReactSelect = props => { const { id = "960b29a364ca444abb5969c97580d973", @@ -15,6 +17,10 @@ const CustomReactSelect = props => { const [inputValue, setInputValue] = useState(""); const [hueRotate, setHueRotate] = useState(0); + const { setHintEntry, savedHintEntry } = useHintEntry(id); + const hintRestoredRef = useRef(false); + const isMountRef = useRef(true); + const customStyles = useMemo(() => { return { control: provided => ({ @@ -101,6 +107,25 @@ const CustomReactSelect = props => { onValueCallback(value); }, [onValueCallback, value]); + // Persist hint changes. Skip the initial mount so an empty input doesn't + // clobber a saved entry before the resume restore below can apply it. + useEffect(() => { + if (isMountRef.current) { + isMountRef.current = false; + return; + } + setHintEntry(value ? value.value : null); + }, [value, setHintEntry]); + + // Restore a saved hint once, after a resumed session populates it. + useEffect(() => { + if (hintRestoredRef.current) { return; } + if (savedHintEntry !== null) { + setValue({ label: savedHintEntry, value: savedHintEntry }); + hintRestoredRef.current = true; + } + }, [savedHintEntry]); + return (