feat: track prices by source
This commit is contained in:
@@ -17,6 +17,7 @@ node server.js
|
|||||||
- **5 categories** — Hotels, Golf, Nightlife, Excursions, Full Itineraries
|
- **5 categories** — Hotels, Golf, Nightlife, Excursions, Full Itineraries
|
||||||
- **Budget planner tab** — quick compare for 8, 10, and 12 guys across Budget, Balanced, and Splurge tracks
|
- **Budget planner tab** — quick compare for 8, 10, and 12 guys across Budget, Balanced, and Splurge tracks
|
||||||
- **Price trend graphs** — each option shows a live line graph from price-watch automation runs
|
- **Price trend graphs** — each option shows a live line graph from price-watch automation runs
|
||||||
|
- **Source-selectable price tracking** — switch each option between Apple, Costco, KAYAK, and other tracked sources
|
||||||
- **Decision detail cards** — automation-enriched pricing, features, amenities, and tradeoffs appear on each option
|
- **Decision detail cards** — automation-enriched pricing, features, amenities, and tradeoffs appear on each option
|
||||||
- **Add suggestions** — anyone can propose new venues
|
- **Add suggestions** — anyone can propose new venues
|
||||||
- **Admin approval** — pending options require approval before going live
|
- **Admin approval** — pending options require approval before going live
|
||||||
|
|||||||
@@ -619,6 +619,28 @@
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
.option-source-select {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 4px;
|
||||||
|
background: rgba(8, 11, 17, 0.9);
|
||||||
|
border: 1px solid rgba(0, 212, 255, 0.18);
|
||||||
|
color: #e6f7ff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 7px 8px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
outline: none;
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
.option-source-select:focus {
|
||||||
|
border-color: rgba(0, 212, 255, 0.48);
|
||||||
|
box-shadow: 0 0 0 2px rgba(0, 212, 255, 0.08);
|
||||||
|
}
|
||||||
|
.option-source-sub {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 0.63rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
.option-fact-note {
|
.option-fact-note {
|
||||||
font-size: 0.68rem;
|
font-size: 0.68rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
@@ -1347,6 +1369,13 @@
|
|||||||
budgetScenarios: [],
|
budgetScenarios: [],
|
||||||
priceUpdatedAt: '',
|
priceUpdatedAt: '',
|
||||||
priceHistoryRunCount: 0,
|
priceHistoryRunCount: 0,
|
||||||
|
priceSourceSelections: (() => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem('cabo_price_source_selections') || '{}');
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
})(),
|
||||||
pollsOpen: true,
|
pollsOpen: true,
|
||||||
totalVoters: 0,
|
totalVoters: 0,
|
||||||
wsConnected: false,
|
wsConnected: false,
|
||||||
@@ -1553,6 +1582,72 @@
|
|||||||
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeSourceKey(value) {
|
||||||
|
return String(value || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '') || 'unknown-source';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAvailableSources(opt) {
|
||||||
|
if (Array.isArray(opt.availableSources) && opt.availableSources.length) {
|
||||||
|
return opt.availableSources.map(source => ({
|
||||||
|
...source,
|
||||||
|
sourceKey: normalizeSourceKey(source.sourceKey || source.sourceLabel || source.source),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultKey = normalizeSourceKey(opt.currentSourceKey || opt.automationInsights?.source || 'unknown-source');
|
||||||
|
const defaultLabel = opt.automationInsights?.source || 'Unknown source';
|
||||||
|
return [{
|
||||||
|
sourceKey: defaultKey,
|
||||||
|
sourceLabel: defaultLabel,
|
||||||
|
sourceUrl: opt.automationInsights?.sourceUrl || null,
|
||||||
|
pointCount: Array.isArray(opt.priceHistory) ? opt.priceHistory.length : 0,
|
||||||
|
latestCheckedAt: opt.latestPricePoint?.checkedAt || null,
|
||||||
|
latestPrice: opt.latestPricePoint?.price ?? null,
|
||||||
|
latestDisplayPrice: opt.latestPricePoint?.displayPrice || null,
|
||||||
|
currency: opt.latestPricePoint?.currency || 'USD',
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOptionSelectedSourceKey(opt) {
|
||||||
|
const availableSources = getAvailableSources(opt);
|
||||||
|
const stored = state.priceSourceSelections?.[opt.id];
|
||||||
|
const normalizedStored = stored ? normalizeSourceKey(stored) : '';
|
||||||
|
if (normalizedStored && availableSources.some(source => source.sourceKey === normalizedStored)) {
|
||||||
|
return normalizedStored;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultKey = normalizeSourceKey(opt.currentSourceKey || opt.defaultSourceKey || availableSources[0]?.sourceKey);
|
||||||
|
return availableSources.some(source => source.sourceKey === defaultKey)
|
||||||
|
? defaultKey
|
||||||
|
: availableSources[0]?.sourceKey || 'unknown-source';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOptionSourceSeries(opt, sourceKey = getOptionSelectedSourceKey(opt)) {
|
||||||
|
const normalizedKey = normalizeSourceKey(sourceKey);
|
||||||
|
if (opt.priceHistoryBySource && Array.isArray(opt.priceHistoryBySource[normalizedKey])) {
|
||||||
|
return opt.priceHistoryBySource[normalizedKey];
|
||||||
|
}
|
||||||
|
return Array.isArray(opt.priceHistory) ? opt.priceHistory : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOptionSourceMeta(opt, sourceKey = getOptionSelectedSourceKey(opt)) {
|
||||||
|
const normalizedKey = normalizeSourceKey(sourceKey);
|
||||||
|
return getAvailableSources(opt).find(source => source.sourceKey === normalizedKey) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setOptionSource(optionId, sourceKey) {
|
||||||
|
state.priceSourceSelections = {
|
||||||
|
...state.priceSourceSelections,
|
||||||
|
[optionId]: normalizeSourceKey(sourceKey),
|
||||||
|
};
|
||||||
|
localStorage.setItem('cabo_price_source_selections', JSON.stringify(state.priceSourceSelections));
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHtml(value) {
|
function escapeHtml(value) {
|
||||||
return String(value ?? '')
|
return String(value ?? '')
|
||||||
.replace(/&/g, '&')
|
.replace(/&/g, '&')
|
||||||
@@ -1593,18 +1688,64 @@
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderSourceSelect(opt) {
|
||||||
|
const sources = getAvailableSources(opt);
|
||||||
|
if (sources.length <= 1) {
|
||||||
|
const source = sources[0] || null;
|
||||||
|
const sourceLabel = source?.sourceLabel || opt.automationInsights?.source || 'Unknown source';
|
||||||
|
const meta = source ? [
|
||||||
|
source.latestDisplayPrice || (typeof source.latestPrice === 'number' ? formatCurrency(source.latestPrice, source.currency || 'USD') : ''),
|
||||||
|
source.pointCount ? `${source.pointCount} point${source.pointCount === 1 ? '' : 's'}` : '',
|
||||||
|
].filter(Boolean).join(' · ') : '';
|
||||||
|
return `
|
||||||
|
<div class="option-source-sub">${escapeHtml(sourceLabel)}${meta ? ` · ${escapeHtml(meta)}` : ''}</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedSourceKey = getOptionSelectedSourceKey(opt);
|
||||||
|
return `
|
||||||
|
<select class="option-source-select" onchange="setOptionSource('${opt.id}', this.value); event.stopPropagation()">
|
||||||
|
${sources.map(source => {
|
||||||
|
const labelParts = [
|
||||||
|
source.sourceLabel || 'Unknown source',
|
||||||
|
source.latestDisplayPrice || (typeof source.latestPrice === 'number' ? formatCurrency(source.latestPrice, source.currency || 'USD') : ''),
|
||||||
|
source.pointCount ? `${source.pointCount} pt${source.pointCount === 1 ? '' : 's'}` : '',
|
||||||
|
].filter(Boolean);
|
||||||
|
return `<option value="${escapeHtml(source.sourceKey)}"${source.sourceKey === selectedSourceKey ? ' selected' : ''}>${escapeHtml(labelParts.join(' · '))}</option>`;
|
||||||
|
}).join('')}
|
||||||
|
</select>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
function renderOptionFacts(opt) {
|
function renderOptionFacts(opt) {
|
||||||
const insights = opt.automationInsights || {};
|
const availableSources = getAvailableSources(opt);
|
||||||
const currentPrice = typeof opt.currentPrice === 'number' ? formatCurrency(opt.currentPrice, insights.currency || 'USD') : '';
|
const selectedSourceKey = getOptionSelectedSourceKey(opt);
|
||||||
const priceLabel = insights.displayPrice || currentPrice || 'Not yet tracked';
|
const selectedSeries = getOptionSourceSeries(opt, selectedSourceKey);
|
||||||
const sourceLabel = insights.source || 'Automation feed';
|
const selectedMeta = getOptionSourceMeta(opt, selectedSourceKey);
|
||||||
const statusLabel = insights.availability || insights.decisionNote || 'Matched from live search';
|
const selectedPoint = selectedSeries.at(-1) || opt.latestPricePoint || null;
|
||||||
|
const insights = selectedPoint ? {
|
||||||
|
source: selectedMeta?.sourceLabel || selectedPoint.source || 'Automation feed',
|
||||||
|
sourceUrl: selectedMeta?.sourceUrl || selectedPoint.sourceUrl || null,
|
||||||
|
availability: selectedPoint.availability || null,
|
||||||
|
decisionNote: selectedPoint.decisionNote || null,
|
||||||
|
displayPrice: selectedPoint.displayPrice || null,
|
||||||
|
currency: selectedPoint.currency || 'USD',
|
||||||
|
} : (opt.automationInsights || {});
|
||||||
|
const currentPrice = typeof selectedPoint?.price === 'number' ? formatCurrency(selectedPoint.price, insights.currency || 'USD') : '';
|
||||||
|
const priceLabel = selectedPoint?.displayPrice || insights.displayPrice || currentPrice || 'Not yet tracked';
|
||||||
|
const sourceLabel = selectedMeta?.sourceLabel || insights.source || 'Automation feed';
|
||||||
|
const statusLabel = selectedPoint?.availability || selectedPoint?.decisionNote || insights.availability || insights.decisionNote || 'Matched from live search';
|
||||||
const overviewItems = normalizeTextList(opt.details);
|
const overviewItems = normalizeTextList(opt.details);
|
||||||
const autoHighlights = normalizeTextList(insights.highlights);
|
const autoHighlights = normalizeTextList(selectedPoint?.highlights || opt.automationInsights?.highlights);
|
||||||
const features = normalizeTextList(insights.features);
|
const features = normalizeTextList(selectedPoint?.features || opt.automationInsights?.features);
|
||||||
const amenities = normalizeTextList(insights.amenities);
|
const amenities = normalizeTextList(selectedPoint?.amenities || opt.automationInsights?.amenities);
|
||||||
const inclusions = normalizeTextList(insights.inclusions);
|
const inclusions = normalizeTextList(selectedPoint?.inclusions || opt.automationInsights?.inclusions);
|
||||||
const limitations = normalizeTextList(insights.limitations);
|
const limitations = normalizeTextList(selectedPoint?.limitations || opt.automationInsights?.limitations);
|
||||||
|
const sourceMetaLine = selectedMeta
|
||||||
|
? [selectedMeta.latestDisplayPrice || priceLabel, selectedMeta.pointCount ? `${selectedMeta.pointCount} point${selectedMeta.pointCount === 1 ? '' : 's'}` : '']
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')
|
||||||
|
: '';
|
||||||
|
|
||||||
const sections = [];
|
const sections = [];
|
||||||
|
|
||||||
@@ -1616,7 +1757,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="option-fact">
|
<div class="option-fact">
|
||||||
<span class="option-fact-label">Source</span>
|
<span class="option-fact-label">Source</span>
|
||||||
<div class="option-fact-value">${escapeHtml(sourceLabel)}</div>
|
${renderSourceSelect(opt)}
|
||||||
|
${availableSources.length > 1 && sourceMetaLine ? `<div class="option-source-sub">${escapeHtml(sourceLabel)}${sourceMetaLine ? ` · ${escapeHtml(sourceMetaLine)}` : ''}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="option-fact">
|
<div class="option-fact">
|
||||||
<span class="option-fact-label">Status</span>
|
<span class="option-fact-label">Status</span>
|
||||||
@@ -1683,14 +1825,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderPriceTrend(opt) {
|
function renderPriceTrend(opt) {
|
||||||
const series = Array.isArray(opt.priceHistory)
|
const selectedSourceKey = getOptionSelectedSourceKey(opt);
|
||||||
? opt.priceHistory.filter(point => typeof point.price === 'number' && !Number.isNaN(point.price))
|
const series = getOptionSourceSeries(opt, selectedSourceKey)
|
||||||
: [];
|
.filter(point => typeof point.price === 'number' && !Number.isNaN(point.price));
|
||||||
|
const selectedMeta = getOptionSourceMeta(opt, selectedSourceKey);
|
||||||
|
|
||||||
if (series.length === 0) {
|
if (series.length === 0) {
|
||||||
return `
|
return `
|
||||||
<div class="price-trend-empty">
|
<div class="price-trend-empty">
|
||||||
Price tracking appears after the next automation run.
|
${selectedMeta?.sourceLabel ? `${escapeHtml(selectedMeta.sourceLabel)} price tracking appears after the next automation run.` : 'Price tracking appears after the next automation run.'}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -1733,13 +1876,14 @@
|
|||||||
? 'flat from first check'
|
? 'flat from first check'
|
||||||
: `${delta > 0 ? '+' : '−'}${formatCurrency(Math.abs(delta), latest.currency)}`;
|
: `${delta > 0 ? '+' : '−'}${formatCurrency(Math.abs(delta), latest.currency)}`;
|
||||||
const checkedLabel = latest.checkedAt ? formatTrackedDate(latest.checkedAt) : '';
|
const checkedLabel = latest.checkedAt ? formatTrackedDate(latest.checkedAt) : '';
|
||||||
|
const sourceLabel = selectedMeta?.sourceLabel || latest.source || 'Tracked source';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="price-trend">
|
<div class="price-trend">
|
||||||
<div class="price-trend-header">
|
<div class="price-trend-header">
|
||||||
<div>
|
<div>
|
||||||
<div class="price-trend-label">Automation price trail</div>
|
<div class="price-trend-label">Automation price trail</div>
|
||||||
<div class="price-trend-value">${latest.displayPrice || formatCurrency(latest.price, latest.currency) || 'Tracked price'}</div>
|
<div class="price-trend-value">${escapeHtml(sourceLabel)} · ${escapeHtml(latest.displayPrice || formatCurrency(latest.price, latest.currency) || 'Tracked price')}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="price-trend-sub">${series.length} point${series.length === 1 ? '' : 's'}${checkedLabel ? ` · ${checkedLabel}` : ''}</div>
|
<div class="price-trend-sub">${series.length} point${series.length === 1 ? '' : 's'}${checkedLabel ? ` · ${checkedLabel}` : ''}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
74
server.js
74
server.js
@@ -63,6 +63,10 @@ function normalizeKey(value) {
|
|||||||
.replace(/^-+|-+$/g, '');
|
.replace(/^-+|-+$/g, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeSourceLabel(value) {
|
||||||
|
return String(value || 'Unknown source').trim() || 'Unknown source';
|
||||||
|
}
|
||||||
|
|
||||||
const HISTORY_KEY_ALIASES = {
|
const HISTORY_KEY_ALIASES = {
|
||||||
'costco-breathless': 'hotel-breathless',
|
'costco-breathless': 'hotel-breathless',
|
||||||
'costco-grand-fiesta': 'hotel-grand-fiesta',
|
'costco-grand-fiesta': 'hotel-grand-fiesta',
|
||||||
@@ -194,7 +198,8 @@ function loadPriceHistoryState() {
|
|||||||
price,
|
price,
|
||||||
currency: point.currency || 'USD',
|
currency: point.currency || 'USD',
|
||||||
displayPrice: point.displayPrice || point.priceLabel || point.label || null,
|
displayPrice: point.displayPrice || point.priceLabel || point.label || null,
|
||||||
source: point.source || null,
|
source: normalizeSourceLabel(point.source || point.sourceLabel || point.vendor || null),
|
||||||
|
sourceKey: normalizeKey(point.sourceKey || point.sourceId || point.source || point.sourceLabel || point.vendor || 'unknown-source'),
|
||||||
sourceUrl: point.sourceUrl || point.url || null,
|
sourceUrl: point.sourceUrl || point.url || null,
|
||||||
note: point.note || point.description || null,
|
note: point.note || point.description || null,
|
||||||
availability: point.availability || point.status || null,
|
availability: point.availability || point.status || null,
|
||||||
@@ -223,6 +228,61 @@ function loadPriceHistoryState() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildPriceHistoryBySource(priceHistory) {
|
||||||
|
const grouped = new Map();
|
||||||
|
|
||||||
|
priceHistory.forEach((point) => {
|
||||||
|
const sourceKey = normalizeKey(point.sourceKey || point.source || 'unknown-source');
|
||||||
|
const sourceLabel = normalizeSourceLabel(point.source || point.sourceLabel || sourceKey);
|
||||||
|
if (!grouped.has(sourceKey)) {
|
||||||
|
grouped.set(sourceKey, {
|
||||||
|
sourceKey,
|
||||||
|
sourceLabel,
|
||||||
|
sourceUrl: point.sourceUrl || null,
|
||||||
|
points: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const bucket = grouped.get(sourceKey);
|
||||||
|
bucket.points.push({
|
||||||
|
...point,
|
||||||
|
sourceKey,
|
||||||
|
source: sourceLabel,
|
||||||
|
});
|
||||||
|
if (!bucket.sourceUrl && point.sourceUrl) bucket.sourceUrl = point.sourceUrl;
|
||||||
|
if (bucket.sourceLabel === 'Unknown source' && sourceLabel) bucket.sourceLabel = sourceLabel;
|
||||||
|
});
|
||||||
|
|
||||||
|
const seriesBySource = {};
|
||||||
|
const sourceSummaries = [...grouped.values()].map((bucket) => {
|
||||||
|
bucket.points.sort((a, b) => a.runIndex - b.runIndex);
|
||||||
|
seriesBySource[bucket.sourceKey] = bucket.points;
|
||||||
|
const latestPoint = bucket.points.at(-1) || null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
sourceKey: bucket.sourceKey,
|
||||||
|
sourceLabel: bucket.sourceLabel,
|
||||||
|
sourceUrl: bucket.sourceUrl,
|
||||||
|
pointCount: bucket.points.length,
|
||||||
|
latestCheckedAt: latestPoint?.checkedAt || null,
|
||||||
|
latestCheckedAtMs: latestPoint?.checkedAtMs || 0,
|
||||||
|
latestPrice: latestPoint?.price ?? null,
|
||||||
|
latestDisplayPrice: latestPoint?.displayPrice || null,
|
||||||
|
currency: latestPoint?.currency || 'USD',
|
||||||
|
};
|
||||||
|
}).sort((a, b) => {
|
||||||
|
const aMs = a.latestCheckedAtMs || 0;
|
||||||
|
const bMs = b.latestCheckedAtMs || 0;
|
||||||
|
if (aMs !== bMs) return bMs - aMs;
|
||||||
|
return a.sourceLabel.localeCompare(b.sourceLabel);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
seriesBySource,
|
||||||
|
sourceSummaries,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function getPriceHistoryForOption(option, priceHistoryState) {
|
function getPriceHistoryForOption(option, priceHistoryState) {
|
||||||
const optionKeys = getOptionHistoryKeys(option);
|
const optionKeys = getOptionHistoryKeys(option);
|
||||||
for (const key of optionKeys) {
|
for (const key of optionKeys) {
|
||||||
@@ -237,7 +297,11 @@ function getPriceHistoryForOption(option, priceHistoryState) {
|
|||||||
|
|
||||||
function decorateOptionWithPriceHistory(option, priceHistoryState) {
|
function decorateOptionWithPriceHistory(option, priceHistoryState) {
|
||||||
const priceHistory = getPriceHistoryForOption(option, priceHistoryState);
|
const priceHistory = getPriceHistoryForOption(option, priceHistoryState);
|
||||||
const latestPricePoint = priceHistory.at(-1) || null;
|
const { seriesBySource, sourceSummaries } = buildPriceHistoryBySource(priceHistory);
|
||||||
|
const defaultSourceSummary = sourceSummaries[0] || null;
|
||||||
|
const defaultSourceKey = defaultSourceSummary?.sourceKey || null;
|
||||||
|
const defaultPriceHistory = defaultSourceKey ? seriesBySource[defaultSourceKey] || [] : priceHistory;
|
||||||
|
const latestPricePoint = defaultPriceHistory.at(-1) || null;
|
||||||
const optionDetails = toTextList(option.details);
|
const optionDetails = toTextList(option.details);
|
||||||
const automationHighlights = toTextList(latestPricePoint?.highlights);
|
const automationHighlights = toTextList(latestPricePoint?.highlights);
|
||||||
const automationFeatures = toTextList(latestPricePoint?.features);
|
const automationFeatures = toTextList(latestPricePoint?.features);
|
||||||
@@ -255,7 +319,11 @@ function decorateOptionWithPriceHistory(option, priceHistoryState) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...option,
|
...option,
|
||||||
priceHistory,
|
priceHistory: defaultPriceHistory,
|
||||||
|
priceHistoryBySource: seriesBySource,
|
||||||
|
availableSources: sourceSummaries,
|
||||||
|
defaultSourceKey,
|
||||||
|
currentSourceKey: defaultSourceKey,
|
||||||
latestPricePoint,
|
latestPricePoint,
|
||||||
currentPrice: latestPricePoint?.price ?? null,
|
currentPrice: latestPricePoint?.price ?? null,
|
||||||
decisionDetails: [...new Set(decisionDetails)],
|
decisionDetails: [...new Set(decisionDetails)],
|
||||||
|
|||||||
Reference in New Issue
Block a user