feat: Integrate account_metrics collection with monthly refresh calendar

- Add account_metrics collection schema script
- Implement accountMetricsService with upsertAccountMetric, fetchAccountMetricsForMonth, calculateSalesBucket
- Extend accountsService with getLastSuccessfulAccountMetric
- Update AccountsPage to track daily metrics and display in calendar
- Calculate sales difference from last successful refresh
- Display refresh status and sales buckets in monthly calendar view
- Remove account_refresh_events dependency (use account_metrics only)
This commit is contained in:
KNSONWS
2026-01-27 20:36:47 +01:00
parent 75aef1941e
commit a1201d572e
11 changed files with 2641 additions and 49 deletions

View File

@@ -3,8 +3,10 @@ const BACKEND_URL = "http://localhost:5173"; // TODO: Backend URL konfigurieren
const PARSE_TIMEOUT_MS = 15000; // 15 seconds
const SCAN_TIMEOUT_MS = 45000; // 45 seconds (listing with _ipg=240 can be slow)
const ACCOUNT_EXTENDED_TIMEOUT_MS = 15000; // 15 seconds per sub-scan
const activeParseRequests = new Map(); // Map<tabId, { timeout, originalSender, resolve }>
const activeScanRequests = new Map(); // Map<tabId, { timeout, sendResponse }>;
const activeExtendedParseRequests = new Map(); // Map<requestId, { sendResponse, results }>
/** Aktueller Scan-Fortschritt für GET_SCAN_PROGRESS (Polling durch Web-App) */
let currentScanProgress = null;
@@ -68,6 +70,11 @@ chrome.runtime.onMessageExternal.addListener((msg, sender, sendResponse) => {
return true; // async
}
if (msg?.action === "PARSE_ACCOUNT_EXTENDED" && msg.url) {
handleParseAccountExtendedRequest(msg.url, sendResponse);
return true; // async
}
if (msg?.action === "GET_SCAN_PROGRESS") {
sendResponse(currentScanProgress ?? { percent: 0, phase: "idle", total: 0, current: 0, complete: false });
return false;
@@ -676,6 +683,284 @@ async function cleanupScanRequest(tabId, data, error) {
}
/**
* Handles extended account parsing request
* Visits multiple URLs (base, feedback, about, store) and collects partial results
*/
async function handleParseAccountExtendedRequest(url, sendResponse) {
try {
// Validate URL
if (!url || typeof url !== 'string' || !url.toLowerCase().includes('ebay.')) {
sendResponse({ ok: false, error: "Invalid eBay URL" });
return;
}
console.log("[BACKGROUND] Starting extended account parse for:", url);
const requestId = `extended_${Date.now()}_${Math.random()}`;
const results = {
base: { ok: false, data: null, error: null },
feedback: { ok: false, data: null, error: null },
about: { ok: false, data: null, error: null },
store: { ok: false, data: null, error: null }
};
activeExtendedParseRequests.set(requestId, {
sendResponse: sendResponse,
results: results
});
// Step 1: Parse base URL (like normal parse)
try {
const baseData = await parseSingleTab(url, "PARSE_EBAY", ACCOUNT_EXTENDED_TIMEOUT_MS);
results.base = { ok: true, data: baseData, error: null };
} catch (error) {
console.warn("[BACKGROUND] Base parse failed:", error);
results.base = { ok: false, data: null, error: error.message || "Base parse failed" };
}
// Step 2: Parse feedback tab
try {
const feedbackUrl = appendQueryParam(url, "_tab=feedback");
const feedbackData = await parseSingleTab(feedbackUrl, "PARSE_FEEDBACK", ACCOUNT_EXTENDED_TIMEOUT_MS);
results.feedback = { ok: true, data: feedbackData, error: null };
} catch (error) {
console.warn("[BACKGROUND] Feedback parse failed:", error);
results.feedback = { ok: false, data: null, error: error.message || "Feedback parse failed" };
}
// Step 3: Parse about tab
try {
const aboutUrl = appendQueryParam(url, "_tab=about");
const aboutData = await parseSingleTab(aboutUrl, "PARSE_ABOUT", ACCOUNT_EXTENDED_TIMEOUT_MS);
results.about = { ok: true, data: aboutData, error: null };
} catch (error) {
console.warn("[BACKGROUND] About parse failed:", error);
results.about = { ok: false, data: null, error: error.message || "About parse failed" };
}
// Step 4: Find and parse store URL
try {
let storeUrl = null;
// First, try to find store link on base page
if (results.base.ok) {
try {
// Re-open base page tab to search for store link
const baseTab = await chrome.tabs.create({
url: url,
active: false
});
await new Promise((resolve) => {
const checkLoaded = (tabId, changeInfo) => {
if (tabId === baseTab.id && changeInfo.status === 'complete') {
chrome.tabs.onUpdated.removeListener(checkLoaded);
setTimeout(resolve, 2000);
}
};
chrome.tabs.onUpdated.addListener(checkLoaded);
setTimeout(resolve, 5000); // Max wait
});
const injected = await ensureContentScriptInjected(baseTab.id);
if (injected) {
try {
const linkResponse = await chrome.tabs.sendMessage(baseTab.id, { action: "FIND_STORE_LINK" });
if (linkResponse && linkResponse.ok && linkResponse.data && linkResponse.data.storeUrl) {
storeUrl = linkResponse.data.storeUrl;
}
} catch (e) {
console.warn("[BACKGROUND] Could not find store link via content script:", e);
}
}
await chrome.tabs.remove(baseTab.id);
} catch (e) {
console.warn("[BACKGROUND] Error searching for store link:", e);
}
}
// Fallback: heuristical derivation
if (!storeUrl) {
const sellerId = results.base.data?.sellerId || null;
storeUrl = deriveStoreUrl(url, sellerId);
}
if (storeUrl) {
const storeData = await parseSingleTab(storeUrl, "PARSE_STORE", ACCOUNT_EXTENDED_TIMEOUT_MS);
results.store = { ok: true, data: storeData, error: null };
} else {
results.store = { ok: false, data: null, error: "Could not determine store URL" };
}
} catch (error) {
console.warn("[BACKGROUND] Store parse failed:", error);
results.store = { ok: false, data: null, error: error.message || "Store parse failed" };
}
// Combine all results
const combinedData = {
// Base data (always present if base.ok)
sellerId: results.base.data?.sellerId || "",
shopName: results.base.data?.shopName || "",
market: results.base.data?.market || "US",
status: results.base.data?.status || "unknown",
stats: results.base.data?.stats || {},
// Extended data (can be null)
responseTimeHours: results.about.data?.responseTimeHours || null,
followers: results.store.data?.followers || null,
feedbackTotal: results.feedback.data?.feedbackTotal || null,
feedback12mPositive: results.feedback.data?.feedback12mPositive || null,
feedback12mNeutral: results.feedback.data?.feedback12mNeutral || null,
feedback12mNegative: results.feedback.data?.feedback12mNegative || null,
// Partial results status
partialResults: {
base: { ok: results.base.ok, error: results.base.error },
feedback: { ok: results.feedback.ok, error: results.feedback.error },
about: { ok: results.about.ok, error: results.about.error },
store: { ok: results.store.ok, error: results.store.error }
}
};
activeExtendedParseRequests.delete(requestId);
sendResponse({ ok: true, data: combinedData });
} catch (error) {
console.error("Error in handleParseAccountExtendedRequest:", error);
sendResponse({ ok: false, error: error.message || "Unknown error" });
}
}
/**
* Parses a single tab with given action and timeout
* @param {string} url - URL to parse
* @param {string} action - Action to send to content script (PARSE_EBAY, PARSE_FEEDBACK, etc.)
* @param {number} timeoutMs - Timeout in milliseconds
* @returns {Promise<object>} Parsed data
*/
async function parseSingleTab(url, action, timeoutMs) {
return new Promise(async (resolve, reject) => {
let tabId = null;
let timeoutId = null;
try {
// Create hidden tab
const tab = await chrome.tabs.create({
url: url,
active: false
});
tabId = tab.id;
// Set up timeout
timeoutId = setTimeout(() => {
cleanupSingleTab(tabId);
reject(new Error("timeout"));
}, timeoutMs);
// Wait for tab to load
const checkTabLoaded = (updatedTabId, changeInfo) => {
if (updatedTabId !== tabId) return;
if (changeInfo.status === 'complete') {
chrome.tabs.onUpdated.removeListener(checkTabLoaded);
setTimeout(async () => {
try {
const injected = await ensureContentScriptInjected(tabId);
if (!injected) {
cleanupSingleTab(tabId);
reject(new Error("Could not inject content script"));
return;
}
const response = await chrome.tabs.sendMessage(tabId, { action: action });
cleanupSingleTab(tabId);
if (response && response.ok && response.data) {
resolve(response.data);
} else {
reject(new Error(response?.error || "Parsing failed"));
}
} catch (err) {
cleanupSingleTab(tabId);
reject(err);
}
}, 2000); // 2 second delay for DOM ready
}
};
chrome.tabs.onUpdated.addListener(checkTabLoaded);
} catch (error) {
if (timeoutId) clearTimeout(timeoutId);
if (tabId) cleanupSingleTab(tabId);
reject(error);
}
});
}
/**
* Cleans up a single tab
*/
async function cleanupSingleTab(tabId) {
try {
await chrome.tabs.remove(tabId);
} catch (err) {
// Tab might already be closed
console.warn("[BACKGROUND] Could not close tab:", err);
}
}
/**
* Appends a query parameter to a URL
* @param {string} url - Base URL
* @param {string} param - Query parameter (e.g. "_tab=feedback")
* @returns {string} URL with appended parameter
*/
function appendQueryParam(url, param) {
try {
const urlObj = new URL(url);
urlObj.search += (urlObj.search ? '&' : '?') + param;
return urlObj.href;
} catch (e) {
// Fallback: simple string append
return url + (url.includes('?') ? '&' : '?') + param;
}
}
/**
* Derives store URL from account URL
* @param {string} accountUrl - Account URL
* @param {string|null} sellerId - Seller ID if available
* @returns {string|null} Store URL or null
*/
function deriveStoreUrl(accountUrl, sellerId) {
try {
const urlObj = new URL(accountUrl);
const pathname = urlObj.pathname;
// Try to extract seller ID from URL if not provided
let id = sellerId;
if (!id) {
const usrMatch = pathname.match(/\/usr\/([^\/\?]+)/);
if (usrMatch && usrMatch[1]) {
id = usrMatch[1];
} else {
const strMatch = pathname.match(/\/str\/([^\/\?]+)/);
if (strMatch && strMatch[1]) {
id = strMatch[1];
}
}
}
if (id) {
return `${urlObj.protocol}//${urlObj.hostname}/str/${encodeURIComponent(id)}`;
}
return null;
} catch (e) {
console.warn("[BACKGROUND] Failed to derive store URL:", e);
return null;
}
}
export async function getJwt() {
const data = await chrome.storage.local.get(STORAGE_KEY);
return data[STORAGE_KEY] || "";