diff --git a/frontend/surface.html b/frontend/surface.html index 33a8252..b2cb9e5 100644 --- a/frontend/surface.html +++ b/frontend/surface.html @@ -758,24 +758,101 @@ termChartInstance: null, async init() { - // restore last loaded surface (survives navigating away & back) + // Restore last symbol + expirations list (survives navigating away & back). + // Analytics itself comes from the per-symbol cache below, not ViewState. const vs = ViewState.load('surface'); if (vs) { - this.symbol = vs.symbol ?? this.symbol; + this.symbol = vs.symbol ?? this.symbol; this.expirations = vs.expirations ?? []; - this.expiry = vs.expiry ?? ''; - if (vs.analytics) { - this.analytics = vs.analytics; - this._processAnalytics(vs.analytics); - } + } + if (!this.symbol) return; + + // Always target the 3rd Friday of next calendar month. + const target = this._nextMonthThirdFriday(); + + // Cache hit for *today* — show instantly, skip the network. + const cached = this._getCached(this.symbol, target); + if (cached) { + this.expiry = target; + this.analytics = cached; + this._processAnalytics(cached); + this._persist(); + return; + } + + // Cache miss or stale — fetch expirations and load the target expiry. + await this.fetchExpirations(); + if (this.expiry) await this._loadForTargetExpiry(); + }, + + _persist() { + ViewState.save('surface', { + symbol: this.symbol, expirations: this.expirations, expiry: this.expiry, + }); + }, + + // ---- Per-symbol surface cache (localStorage, today-only) ---- + _todayStr() { + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; + }, + _nextMonthThirdFriday() { + const now = new Date(); + const first = new Date(now.getFullYear(), now.getMonth() + 1, 1); + const daysToFri = (5 - first.getDay() + 7) % 7; + const day = 1 + daysToFri + 14; // 3rd Friday = 1st Friday + 2 weeks + return `${first.getFullYear()}-${String(first.getMonth()+1).padStart(2,'0')}-${String(day).padStart(2,'0')}`; + }, + _pickTargetExpiry(expirations) { + if (!expirations || expirations.length === 0) return ''; + const target = this._nextMonthThirdFriday(); + if (expirations.includes(target)) return target; + const tMs = new Date(target + 'T00:00:00').getTime(); + return expirations.reduce((best, e) => + Math.abs(new Date(e + 'T00:00:00').getTime() - tMs) < + Math.abs(new Date(best + 'T00:00:00').getTime() - tMs) ? e : best + ); + }, + _readCacheStore() { + try { return JSON.parse(localStorage.getItem('optionsPricer:surfaceCache') || '{}'); } + catch { return {}; } + }, + _getCached(symbol, expiry) { + const store = this._readCacheStore(); + const entry = store[`${symbol}:${expiry}`]; + if (!entry || entry.date !== this._todayStr()) return null; + return entry.analytics; + }, + _writeCache(symbol, expiry, analytics) { + const store = this._readCacheStore(); + store[`${symbol}:${expiry}`] = { date: this._todayStr(), analytics }; + // Keep storage bounded — drop oldest beyond 50 entries + const keys = Object.keys(store); + if (keys.length > 50) { + const ordered = keys + .map(k => [k, store[k].date || '']) + .sort((a, b) => b[1].localeCompare(a[1])) + .slice(0, 50) + .map(([k]) => k); + const trimmed = {}; + for (const k of ordered) trimmed[k] = store[k]; + try { localStorage.setItem('optionsPricer:surfaceCache', JSON.stringify(trimmed)); } catch {} + } else { + try { localStorage.setItem('optionsPricer:surfaceCache', JSON.stringify(store)); } catch {} } }, - _persist(includeAnalytics) { - ViewState.save('surface', { - symbol: this.symbol, expirations: this.expirations, expiry: this.expiry, - analytics: includeAnalytics ? this.analytics : null, - }); + // Use cache if it's fresh (today), otherwise hit the API. Assumes symbol+expiry are set. + async _loadForTargetExpiry() { + if (!this.symbol || !this.expiry) return; + const cached = this._getCached(this.symbol, this.expiry); + if (cached) { + this.analytics = cached; + this._processAnalytics(cached); + this._persist(); + return; + } + await this.loadSurface(); }, async fetchExpirations() { @@ -792,8 +869,11 @@ const env = await res.json(); const data = env.data ?? env; this.expirations = data.expirations || (Array.isArray(data) ? data : []); - if (this.expirations.length > 0) this.expiry = this.expirations[0]; - this._persist(false); + // Auto-select the 3rd Friday of next calendar month (nearest available match). + this.expiry = this._pickTargetExpiry(this.expirations); + this._persist(); + // Auto-load the surface for the target expiry (cache-aware). + if (this.expiry) await this._loadForTargetExpiry(); } catch (err) { this.errorMsg = 'Failed to look up symbol: ' + err.message; } finally { @@ -816,7 +896,8 @@ const data = env.data ?? env; this.analytics = data; this._processAnalytics(data); - this._persist(true); + this._writeCache(this.symbol, this.expiry, data); + this._persist(); } catch (err) { this.errorMsg = 'Failed to load surface: ' + err.message; } finally {