diff --git a/meeting-cost-meter/.gitignore b/meeting-cost-meter/.gitignore index 8aafd14..5d1fa66 100644 --- a/meeting-cost-meter/.gitignore +++ b/meeting-cost-meter/.gitignore @@ -5,6 +5,9 @@ node_modules/ # Local planning transcripts can contain account details or copied production data. poc/ +# Unrelated local workshop checkout; never stage it with this extension. +workation-2026-group-11/ + # Derived salary data — never commit. Regenerate with `npm run ingest`. extension/data/rates.json diff --git a/meeting-cost-meter/README.md b/meeting-cost-meter/README.md index 665fb75..a98859d 100644 --- a/meeting-cost-meter/README.md +++ b/meeting-cost-meter/README.md @@ -9,20 +9,20 @@ meetings *feel* expensive so people wrap up sooner. ## How it works ``` -Notion (token + salary PDFs) Chrome (no token, no salaries) +Notion/CSV + salary PDFs Chrome (no token or raw salary bands) ┌──────────────────────────────┐ rates.json ┌────────────────────────────────┐ -│ ingest/ — run once, on your │ ───────────► │ extension/ — MV3, vanilla JS │ -│ machine, with @notionhq/client│ name→rate │ scrape → match → cost → overlay │ +│ ingest/ — run locally once │ ───────────► │ extension/ — MV3, vanilla JS │ +│ parse → classify → round rate │ name→rate │ scrape → match → ledger → UI │ └──────────────────────────────┘ └────────────────────────────────┘ ``` Two stages, deliberately split for privacy: 1. **`ingest/` (build-time bake).** A Node script queries the *Current Employees* Notion - database, downloads and parses the two *Salary ranges* PDFs, and writes a minimal + database (or CSV), downloads/reads the available *Salary ranges* PDFs, and writes a minimal `extension/data/rates.json` — just **normalized name keys → a rounded per-minute rate**, - plus a company-average fallback. No token, no salary bands, and no PDFs ever reach the - browser. + plus coverage metadata and an optional explicitly configured company fallback. No token, + raw salary bands, or PDFs reach the browser. 2. **`extension/` (the Chrome extension).** Dependency-free content scripts that scrape the Meet participant list, match names to baked rates, run the escalating-cost math, and draw an **aggregate-only** overlay (total, rate/min, elapsed, headcount — never individual @@ -50,10 +50,17 @@ token (permission issue): any regular Notion member can still export the data. 1. In Notion, open *Current Employees* → `•••` → *Export* → CSV. Save it as `ingest/data/employees.csv` (the CSV column names are mapped in `config.local.csvColumns`). -2. Download the salary PDFs and list them in `config.local.pdfs` - (default: `ingest/data/salary-delivery.pdf`). +2. Download the salary PDFs and list them in `config.local.pdfs`. Delivery is required at + `ingest/data/salary-delivery.pdf`; Business is picked up automatically from + `ingest/data/salary-business.pdf` when available. 3. `ingest/data/` is gitignored — real employee data never gets committed. +The supplied employee export has no contract-type column. The checked-in decision is therefore +`"contractType": "average"`: each matched band uses the midpoint between its B2B and CoE +versions and the UI marks those rates as estimates. For exact per-person rates, add the real +CSV/Notion property name to `csvColumns.contractType` or `properties.contractType`; recognized +values include `B2B`, `CoE`, `Contract of Employment`, and `UoP`. + **`"notion"` — live API.** Create an internal integration at , **share both pages with it** (*List of employees* and *Salary ranges*), then: @@ -78,9 +85,11 @@ monthly/annual, `levelTokens`, `teamToSection`, `labelAliases`) and the heuristi npm run ingest ``` -This writes `extension/data/rates.json` and logs how many employees matched a band. Anyone -who didn't match (role/level not found in a PDF) falls back to the average rate at runtime — -review that list and extend `labelAliases` if needed. +This writes `extension/data/rates.json` and logs how many employees matched a band. A person +without a salary band has no rate and is excluded from the cost with a visible “partial +estimate” warning. To use a defensible company-wide fallback, set `fallbackAnnualGross` to an +explicit approved figure; the ingest never derives a fallback from only the employees that +happened to match. > `extension/data/rates.json` is **gitignored** — it's derived salary data. Don't commit it. @@ -93,6 +102,8 @@ review that list and extend `labelAliases` if needed. | `hoursPerYear` | Working hours/year for the hourly conversion | 2016 | | `overheadMultiplier` | Employer overhead (benefits/taxes); 1.0 = none | 1.0 | | `rateRoundingSignificantDigits` | Rounding to blunt salary precision | 2 | +| `contractType` | Missing per-person contract assumption | average | +| `fallbackAnnualGross` | Approved fallback for unknown bands; null=none | null | `ratePerMinute = (bandPoint × 12? × overhead) / (hoursPerYear × 60)`. @@ -102,23 +113,26 @@ review that list and extend `labelAliases` if needed. 2. **Load unpacked** → select the `extension/` folder. 3. Join a Google Meet call. The overlay appears top-right. -Out of the box (before you run the ingest) the extension uses the committed -`extension/data/rates.mock.json` (fake names/rates) so you can demo it immediately. Once -`rates.json` exists it's preferred automatically. +Real mode requires the locally generated `extension/data/rates.json`. If it is absent or +unreadable, the overlay says **Real rates unavailable** and does not calculate a cost. Demo +rates are loaded only when **Mock mode** is explicitly enabled, so fake values cannot silently +appear in a real meeting. - **Toggle overlay:** click the toolbar icon, or press `Alt+Shift+C` (e.g. before a screen-share). -- **Settings:** right-click the icon → *Options* to tune the escalation curve, matching, and - mock mode. +- **Currency:** use the PLN/USD button in the overlay. Conversion uses the latest official + NBP table-A USD midpoint, cached locally; no hard-coded exchange rate is used. +- **Settings:** right-click the icon → *Options* to tune escalation, matching, mock mode, and + the USD cost-alert thresholds. ### Mock mode (demo without any real data) Options → **Mock mode** runs the whole pipeline on fake data: a simulated roster (people join at minute 2/4/6…, one steps out and comes back), the committed fake rates, and an accelerated clock (default 60× — an hour of "meeting" per real minute). It works on any -`meet.google.com` tab without joining a call, and the overlay shows a **MOCK** badge so the -numbers can't be mistaken for real ones. There is also a standalone demo page in -`extension/demo/` with interactive time controls. +Meet conference-code page, while home routes such as `/landing` stay idle. The overlay shows +a **MOCK** badge so the numbers can't be mistaken for real ones. There is also a standalone +demo page in `extension/demo/` with interactive time controls. ### When a name isn't recognized @@ -144,31 +158,51 @@ emails. Enable *Use Google Calendar for exact matching* in Options after a one-t The extension then resolves the current Meet code to its calendar event (`chrome.identity` + `calendar.readonly`), matches invitee emails exactly against the baked email keys, and uses DOM scraping only for *presence* (who is actually in the room). If -scraping breaks entirely, all non-declined invitees are counted; ad-hoc meetings with no -event fall back to name matching automatically. +scraping returns nobody, nobody is billed: Calendar invitees are never treated as proof of +attendance. Ad-hoc meetings with no event use name matching automatically. ### The escalation ("bump") ``` multiplier m(t) = 1 for t ≤ grace period = 1 + α·(t − grace) after, capped at maxMultiplier -total = base_rate/min × t × m(t) +increment = roster_rate/min × ∫m(t)dt +total = sum of all roster-period increments ``` Defaults: grace `30 min`, `α = 0.05/min`, cap `×4` → ×1.75 at 45 min, ×2.5 at 60 min. All -adjustable on the Options page. The overlay turns amber then red as the penalty climbs. +adjustable on the Options page. Timing starts only after a participant roster is detected. +When someone joins or leaves, the new room rate affects only future seconds; earlier cost is +never recalculated or reset. Meet's post-call screen freezes the final cost and elapsed time +and stops any red alert blinking; Rejoin resumes without charging the time spent outside the +call. The ledger is saved locally so a page reload keeps the total. + +### Cost colors + +The overlay background and toolbar icon are green by default, yellow above `$10`, orange above +`$20`, and red above `$30`; red blinks. All three strictly-greater-than thresholds live in one +`alertThresholdsUsd` setting and are editable together in Options. Threshold comparisons are +always in USD even while the overlay displays PLN. ## Known limitations - **Name matching by default.** Without the Calendar setup, matching is display-name-based - (diacritics/word-order handled, plus overrides/suggestions above). Unmatched attendees use - the average rate and are counted separately (`8 in call (6 matched)`). + (diacritics/word-order handled, plus overrides/suggestions above). Unknown rates are excluded + and clearly marked; only an explicitly configured company fallback can estimate them. - **Meet DOM is fragile.** `extension/content/scrape.js` tries several selector strategies and fails soft (warning in the console when it finds nobody). When Meet changes its markup, run `window.__MCM.scrape.debugDump()` and set a custom selector in Options; `test/fixtures/meet-people-panel.html` is a reference snapshot. -- **Only the Delivery salary PDF is wired up.** Business-team employees fall back to the - average rate until a Business PDF is added to `config.local.pdfs` (or the Notion page). +- **The Business salary PDF is not present locally.** The optional path is already configured, + but Business-team rates stay unknown until `ingest/data/salary-business.pdf` is supplied (or + the Notion source exposes that attachment). No Business bands are invented. +- **Contract type is currently an assumption.** The available employee CSV has no contract + type, so matched rates use the B2B/CoE midpoint and are labeled as estimates. +- **Calendar OAuth still needs a real client ID.** Calendar matching remains disabled by + default until the placeholder in `extension/manifest.json` is replaced. +- **Controlled live-Meet validation is still required before wider use.** Unit tests cover + parsing, matching, ledger integration, currency conversion, and thresholds, but Meet DOM + changes can only be confirmed in a real call. - **PDF parsing is heuristic** and must be tuned to the real layout (see step 1). ## Testing @@ -184,12 +218,13 @@ loaded into a fake-DOM VM sandbox (`test/load-mcm.js`) for the extension's conte ``` ingest/ notion.js · pdf.js · parse-salary.js · build-rates.js · index.js · dump-pdf-text.js · config.json -extension/ manifest.json · content/{scrape,match,cost,overlay,main}.js · background/ · options/ · data/ +extension/ manifest.json · shared/settings.js · content/{scrape,match,cost,currency,overlay,main}.js · background/ · options/ · data/ test/ *.test.js · fixtures/ ``` ## Privacy -This tool reads compensation data. Keep it internal: `rates.json` stays out of git, the -overlay shows only aggregate numbers, and per-person rates are rounded. Treat the baked file -as confidential. +This tool reads compensation data. Keep it internal: `rates.json` stays out of git and +per-person rates are rounded, but the generated file still contains employee identity keys and +rate proxies so the browser can match locally. The overlay shows only aggregate numbers. Treat +the baked file and any packaged extension containing it as confidential. diff --git a/meeting-cost-meter/extension/background/service-worker.js b/meeting-cost-meter/extension/background/service-worker.js index ee9bb45..d286511 100644 --- a/meeting-cost-meter/extension/background/service-worker.js +++ b/meeting-cost-meter/extension/background/service-worker.js @@ -1,22 +1,20 @@ -// Service worker: seeds default settings, routes toolbar clicks to the content -// script, and resolves the current Meet's calendar event to invitee emails -// (the Calendar-API exact-match path — content scripts can't use -// chrome.identity, so the OAuth + fetch happen here). - -const DEFAULTS = { - thresholdMin: 30, - alphaPerMin: 0.05, - maxMultiplier: 4, - overlayEnabled: true, - mockMode: false, - mockSpeedX: 60, - calendarEnabled: false, - customSelector: '', -}; +// Service worker: shared settings, toolbar state, NBP exchange-rate caching, +// and optional Google Calendar identity lookup. + +importScripts('../shared/settings.js'); + +const MCM = self.__MCM; +const DEFAULTS = MCM.settings.DEFAULTS; +const FX_CACHE_KEY = 'mcm_fx_usd_pln'; +const FX_FRESH_MS = 12 * 60 * 60 * 1000; chrome.runtime.onInstalled.addListener(async () => { const existing = await chrome.storage.sync.get(DEFAULTS); - await chrome.storage.sync.set({ ...DEFAULTS, ...existing }); + await chrome.storage.sync.set({ + ...DEFAULTS, + ...existing, + alertThresholdsUsd: MCM.settings.alertThresholds(existing.alertThresholdsUsd), + }); }); chrome.action.onClicked.addListener((tab) => { @@ -27,6 +25,89 @@ chrome.action.onClicked.addListener((tab) => { } }); +// --- Toolbar alert state -------------------------------------------------- + +function toolbarIcon(color, darkText = false) { + if (typeof OffscreenCanvas === 'undefined') return null; + const images = {}; + for (const size of [16, 32]) { + const canvas = new OffscreenCanvas(size, size); + const ctx = canvas.getContext('2d'); + ctx.clearRect(0, 0, size, size); + ctx.fillStyle = color; + ctx.beginPath(); + ctx.arc(size / 2, size / 2, size * 0.46, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = darkText ? '#202124' : '#ffffff'; + ctx.font = `700 ${Math.round(size * 0.68)}px system-ui`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('$', size / 2, size / 2 + size * 0.03); + images[size] = ctx.getImageData(0, 0, size, size); + } + return images; +} + +async function setToolbarAlert(tabId, level, blinkOn) { + if (tabId == null) return; + const knownLevel = Object.hasOwn(MCM.settings.ALERT_COLORS, level) + ? level + : 'unavailable'; + const visible = knownLevel !== 'red' || blinkOn !== false; + const color = visible + ? MCM.settings.ALERT_COLORS[knownLevel] + : MCM.settings.ALERT_COLORS.unavailable; + const icon = toolbarIcon(color, knownLevel === 'yellow'); + const title = + knownLevel === 'idle' + ? 'Meeting Cost — no active meeting' + : knownLevel === 'unavailable' + ? 'Meeting Cost — rates or FX unavailable' + : `Meeting Cost — ${knownLevel} alert`; + const showBadge = visible && knownLevel !== 'idle' && knownLevel !== 'unavailable'; + + const actions = [ + chrome.action.setBadgeBackgroundColor({ tabId, color }), + chrome.action.setBadgeText({ tabId, text: showBadge ? '$' : '' }), + chrome.action.setTitle({ tabId, title }), + ]; + if (icon) actions.push(chrome.action.setIcon({ tabId, imageData: icon })); + await Promise.all(actions).catch(() => {}); +} + +// --- NBP USD/PLN quote ---------------------------------------------------- + +async function getUsdPlnRate() { + const { [FX_CACHE_KEY]: cached } = await chrome.storage.local.get(FX_CACHE_KEY); + const age = Date.now() - Number(cached?.fetchedAt); + if (Number(cached?.plnPerUsd) > 0 && age >= 0 && age < FX_FRESH_MS) { + return { ...cached, cached: true }; + } + + try { + const res = await fetch( + 'https://api.nbp.pl/api/exchangerates/rates/a/usd/?format=json', + { headers: { Accept: 'application/json' } }, + ); + if (!res.ok) throw new Error(`NBP API ${res.status}`); + const body = await res.json(); + const quote = body?.rates?.[body.rates.length - 1]; + const plnPerUsd = Number(quote?.mid); + if (!(plnPerUsd > 0)) throw new Error('NBP API returned no USD midpoint'); + const fresh = { + plnPerUsd, + effectiveDate: quote.effectiveDate || '', + fetchedAt: Date.now(), + }; + await chrome.storage.local.set({ [FX_CACHE_KEY]: fresh }); + return { ...fresh, cached: false }; + } catch (error) { + // A stale official quote is preferable to an invented hard-coded value. + if (Number(cached?.plnPerUsd) > 0) return { ...cached, cached: true, stale: true }; + throw error; + } +} + // --- Calendar lookup ------------------------------------------------------- function getAuthToken() { @@ -55,6 +136,10 @@ function eventMatchesCode(event, code) { async function getCalendarAttendees(meetCode) { const code = stripCode(meetCode); if (!code || code === 'unknown' || code.length < 6) return { attendees: [], eventFound: false }; + const clientId = chrome.runtime.getManifest().oauth2?.client_id || ''; + if (!clientId || clientId.startsWith('REPLACE_WITH_')) { + throw new Error('Calendar OAuth client ID is not configured'); + } const token = await getAuthToken(); const now = Date.now(); @@ -90,6 +175,16 @@ async function getCalendarAttendees(meetCode) { } chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + if (msg?.type === 'mcm-alert-state') { + setToolbarAlert(_sender.tab?.id, msg.level, msg.blinkOn); + return; + } + if (msg?.type === 'mcm-fx-rate') { + getUsdPlnRate() + .then(sendResponse) + .catch((err) => sendResponse({ error: String(err?.message || err) })); + return true; + } if (msg?.type === 'mcm-calendar-attendees') { getCalendarAttendees(msg.code) .then(sendResponse) diff --git a/meeting-cost-meter/extension/content/cost.js b/meeting-cost-meter/extension/content/cost.js index 3f2026d..25190dc 100644 --- a/meeting-cost-meter/extension/content/cost.js +++ b/meeting-cost-meter/extension/content/cost.js @@ -1,11 +1,8 @@ -// Escalating cost engine. +// Escalating, cumulative cost engine. // -// base = sum of per-minute rates over everyone present (currency/min) -// m(t) = penalty multiplier, 1 until thresholdMin, then ramps up -// currentRate/min = base * m(t) -// total = base * t * m(t) -// -// Pure module (no DOM/chrome) so it unit-tests in a Node VM. +// The ledger integrates each roster's rate only over the time that roster was +// active. Joining or leaving therefore changes future accrual without +// recalculating earlier minutes at the new rate. (function () { const G = typeof window !== 'undefined' ? window : globalThis; const MCM = (G.__MCM = G.__MCM || {}); @@ -18,18 +15,122 @@ function multiplier(elapsedMin, settings) { const s = { ...DEFAULTS, ...(settings || {}) }; - if (elapsedMin <= s.thresholdMin) return 1; - return Math.min(s.maxMultiplier, 1 + s.alphaPerMin * (elapsedMin - s.thresholdMin)); + const elapsed = Math.max(0, Number(elapsedMin) || 0); + const threshold = Math.max(0, Number(s.thresholdMin) || 0); + const alpha = Math.max(0, Number(s.alphaPerMin) || 0); + const cap = Math.max(1, Number(s.maxMultiplier) || 1); + if (elapsed <= threshold) return 1; + return Math.min(cap, 1 + alpha * (elapsed - threshold)); } + // Exact integral of multiplier(t) between two elapsed-minute values. + function integrateMultiplier(fromMin, toMin, settings) { + const start = Math.max(0, Number(fromMin) || 0); + const end = Math.max(start, Number(toMin) || 0); + if (end === start) return 0; + + const s = { ...DEFAULTS, ...(settings || {}) }; + const threshold = Math.max(0, Number(s.thresholdMin) || 0); + const alpha = Math.max(0, Number(s.alphaPerMin) || 0); + const cap = Math.max(1, Number(s.maxMultiplier) || 1); + if (alpha === 0 || cap === 1) return end - start; + + const capAt = threshold + (cap - 1) / alpha; + let area = 0; + let cursor = start; + + if (cursor < threshold) { + const stop = Math.min(end, threshold); + area += stop - cursor; + cursor = stop; + } + if (cursor < end && cursor < capAt) { + const stop = Math.min(end, capAt); + const x0 = cursor - threshold; + const x1 = stop - threshold; + area += stop - cursor + (alpha * (x1 * x1 - x0 * x0)) / 2; + cursor = stop; + } + if (cursor < end) area += (end - cursor) * cap; + return area; + } + + // Kept as a stateless projection for callers that need a single fixed-rate + // interval. Live meetings use createLedger(). function compute(baseRatePerMin, elapsedMin, settings) { - const m = multiplier(elapsedMin, settings); + const elapsed = Math.max(0, Number(elapsedMin) || 0); + const base = Math.max(0, Number(baseRatePerMin) || 0); + const m = multiplier(elapsed, settings); return { multiplier: m, - currentRatePerMin: baseRatePerMin * m, - total: baseRatePerMin * Math.max(0, elapsedMin) * m, + currentRatePerMin: base * m, + total: base * integrateMultiplier(0, elapsed, settings), }; } - MCM.cost = { DEFAULTS, multiplier, compute }; + function createLedger(saved) { + const initial = saved && typeof saved === 'object' ? saved : {}; + let started = initial.started === true; + let elapsedMin = nonNegative(initial.elapsedMin); + let total = nonNegative(initial.total); + let activeRatePerMin = nonNegative(initial.activeRatePerMin); + let lastTsMs = Number.isFinite(Number(initial.lastTsMs)) ? Number(initial.lastTsMs) : null; + + function update({ nowMs = Date.now(), ratePerMinute = 0, hasPresence = false, settings } = {}) { + const now = Number(nowMs); + if (!Number.isFinite(now)) throw new TypeError('nowMs must be a finite number'); + const nextRate = hasPresence ? nonNegative(ratePerMinute) : 0; + + if (!started) { + if (!hasPresence) return view(settings); + started = true; + lastTsMs = now; + activeRatePerMin = nextRate; + return view(settings); + } + + const deltaMin = lastTsMs == null ? 0 : Math.max(0, now - lastTsMs) / 60000; + if (deltaMin > 0) { + total += activeRatePerMin * integrateMultiplier(elapsedMin, elapsedMin + deltaMin, settings); + elapsedMin += deltaMin; + } + lastTsMs = now; + activeRatePerMin = nextRate; + return view(settings); + } + + function view(settings) { + const m = multiplier(elapsedMin, settings); + return { + started, + elapsedMin, + total, + multiplier: m, + currentRatePerMin: activeRatePerMin * m, + baseRatePerMin: activeRatePerMin, + }; + } + + function snapshot() { + return { version: 1, started, elapsedMin, total, activeRatePerMin, lastTsMs }; + } + + // Resume after an intentional pause (for example, Meet's post-call screen) + // without adding the paused wall-clock gap to meeting elapsed time. + function rebase(nowMs = Date.now(), settings) { + const now = Number(nowMs); + if (!Number.isFinite(now)) throw new TypeError('nowMs must be a finite number'); + if (started) lastTsMs = now; + return view(settings); + } + + return { update, view, snapshot, rebase }; + } + + function nonNegative(value) { + const n = Number(value); + return Number.isFinite(n) && n > 0 ? n : 0; + } + + MCM.cost = { DEFAULTS, multiplier, integrateMultiplier, compute, createLedger }; })(); diff --git a/meeting-cost-meter/extension/content/currency.js b/meeting-cost-meter/extension/content/currency.js new file mode 100644 index 0000000..7d9ed1a --- /dev/null +++ b/meeting-cost-meter/extension/content/currency.js @@ -0,0 +1,27 @@ +// PLN/USD conversion helpers. NBP publishes PLN per 1 USD, so PLN -> USD is +// division and USD -> PLN is multiplication. No guessed fallback is used: +// conversion stays unavailable until a real NBP quote (or cached quote) exists. +(function () { + const G = typeof window !== 'undefined' ? window : globalThis; + const MCM = (G.__MCM = G.__MCM || {}); + + function normalizeCurrency(value) { + const code = String(value || '').trim().toUpperCase(); + return code === 'USD' ? 'USD' : code === 'PLN' ? 'PLN' : code; + } + + function convert(amount, fromCurrency, toCurrency, plnPerUsd) { + const value = Number(amount); + const from = normalizeCurrency(fromCurrency); + const to = normalizeCurrency(toCurrency); + if (!Number.isFinite(value)) return null; + if (from === to) return value; + const rate = Number(plnPerUsd); + if (!(rate > 0)) return null; + if (from === 'PLN' && to === 'USD') return value / rate; + if (from === 'USD' && to === 'PLN') return value * rate; + return null; + } + + MCM.currency = { normalizeCurrency, convert }; +})(); diff --git a/meeting-cost-meter/extension/content/main.js b/meeting-cost-meter/extension/content/main.js index 02d5cd9..a666d55 100644 --- a/meeting-cost-meter/extension/content/main.js +++ b/meeting-cost-meter/extension/content/main.js @@ -1,44 +1,53 @@ -// Orchestrator content script. Loaded last, after scrape/match/cost/overlay have -// registered themselves on window.__MCM. +// Live orchestrator. Rates, attendance, and time are deliberately independent: +// - real mode never loads demo rates; +// - Calendar identifies scraped attendees but never substitutes invitees; +// - a cumulative ledger preserves prior cost when the roster changes. (function () { const MCM = window.__MCM; - if (!MCM || !MCM.scrape || !MCM.match || !MCM.cost || !MCM.overlay) { + if ( + !MCM?.scrape || + !MCM?.match || + !MCM?.cost || + !MCM?.currency || + !MCM?.overlay || + !MCM?.settings + ) { console.error('[MCM] namespace incomplete — check content script order in manifest.json'); return; } const TICK_MS = 1000; - const MAX_MEETING_MS = 8 * 60 * 60 * 1000; // reset a stale stored start after 8h + const LEDGER_SAVE_MS = 5000; + const MAX_MEETING_MS = 8 * 60 * 60 * 1000; const CALENDAR_REFRESH_MS = 10 * 60 * 1000; + const FX_REFRESH_MS = 6 * 60 * 60 * 1000; const OVERRIDES_KEY = 'mcm_overrides'; - async function loadRatesFile(files) { - for (const file of files) { - try { - const res = await fetch(chrome.runtime.getURL(file)); - if (res.ok) { - const json = await res.json(); - if (json.people) return json; - } - } catch (_) { - /* try next */ - } + async function loadRatesFile(file) { + try { + const res = await fetch(chrome.runtime.getURL(file)); + if (!res.ok) return null; + const json = await res.json(); + if (!Array.isArray(json?.people) || !json.people.length || !json.currency) return null; + return json; + } catch { + return null; } - return { currency: 'USD', defaultRatePerMinute: 1, people: [] }; } async function loadSettings() { const defaults = { - ...MCM.cost.DEFAULTS, - overlayEnabled: true, - mockMode: false, - mockSpeedX: 60, - calendarEnabled: false, - customSelector: '', + ...MCM.settings.DEFAULTS, + alertThresholdsUsd: { ...MCM.settings.ALERT_THRESHOLDS_USD }, }; try { const stored = await chrome.storage.sync.get(defaults); - return { ...defaults, ...stored }; + return { + ...defaults, + ...stored, + displayCurrency: stored.displayCurrency === 'USD' ? 'USD' : 'PLN', + alertThresholdsUsd: MCM.settings.alertThresholds(stored.alertThresholdsUsd), + }; } catch { return defaults; } @@ -54,45 +63,85 @@ } function meetingCode() { - return location.pathname.replace(/\//g, '') || 'unknown'; + return MCM.scrape.meetingCodeFromPath(location.pathname); } - // Persist the start time so a page reload doesn't reset the meter. - async function getMeetingStart() { - const code = meetingCode(); - const key = 'mcm_start'; + function ledgerStorageKey(code) { + return `mcm_ledger_${code}`; + } + + async function loadLedgerSnapshot(code) { + if (!code) return null; + const key = ledgerStorageKey(code); try { const { [key]: saved } = await chrome.storage.local.get(key); - const now = Date.now(); - if (saved && saved.code === code && now - saved.ts < MAX_MEETING_MS) return saved.ts; - await chrome.storage.local.set({ [key]: { code, ts: now } }); - return now; + const age = Date.now() - Number(saved?.savedAt); + if ( + saved?.code === code && + saved.snapshot?.version === 1 && + age >= 0 && + age < MAX_MEETING_MS + ) { + return saved.snapshot; + } } catch { - return Date.now(); + // A non-persistent ledger is still safe; it just will not survive reload. + } + return null; + } + + async function requestFxQuote() { + try { + const response = await chrome.runtime.sendMessage({ type: 'mcm-fx-rate' }); + const rate = Number(response?.plnPerUsd); + return rate > 0 ? { plnPerUsd: rate, effectiveDate: response.effectiveDate || '' } : null; + } catch { + return null; } } async function main() { - const [realRates, mockRates, settings, startTs, savedOverrides] = await Promise.all([ - loadRatesFile(['data/rates.json', 'data/rates.mock.json']), - loadRatesFile(['data/rates.mock.json']), + const initialMeetingCode = meetingCode(); + const [realRates, mockRates, settings, savedOverrides, savedLedger] = await Promise.all([ + loadRatesFile('data/rates.json'), + loadRatesFile('data/rates.mock.json'), loadSettings(), - getMeetingStart(), loadOverrides(), + loadLedgerSnapshot(initialMeetingCode), ]); - const realIndex = MCM.match.buildIndex(realRates); - const mockIndex = MCM.match.buildIndex(mockRates); + + if (!realRates) { + console.warn( + '[MCM] real rates are unavailable. Real mode will not use demo values; run `npm run ingest` and reload the extension.', + ); + } + + const realIndex = realRates ? MCM.match.buildIndex(realRates) : new Map(); + const mockIndex = mockRates ? MCM.match.buildIndex(mockRates) : new Map(); let currentSettings = settings; let overrides = savedOverrides; - let mockStart = Date.now(); // mock meetings always start "now" - let calendarAttendees = null; // [{email, displayName, responseStatus}] | null + let realLedger = MCM.cost.createLedger(savedLedger); + let mockLedger = MCM.cost.createLedger(); + let mockWallStart = Date.now(); + let fxQuote = null; + let calendarAttendees = null; let calendarWarned = false; - const suggestionCache = new Map(); // normalized display name -> suggestions + let lastLedgerSave = 0; + let lastToolbarState = ''; + let currentMeetingCode = initialMeetingCode; + let finishedMeeting = null; + let lastMatchedRates = emptyMatchSummary(); + const suggestionCache = new Map(); MCM.scrape.setCustomSelector(currentSettings.customSelector); const overlay = MCM.overlay.createOverlay({ - currency: realRates.currency, + currency: currentSettings.displayCurrency, + onCurrencyChange: (currency) => { + currentSettings.displayCurrency = currency; + chrome.storage.sync.set({ displayCurrency: currency }).catch(() => {}); + tick(); + }, onManualRoster: (names) => { MCM.scrape.setManualRoster(names); tick(); @@ -105,6 +154,7 @@ }, }); overlay.setVisible(currentSettings.overlayEnabled !== false); + overlay.setActive(initialMeetingCode != null); function suggestionsFor(detail, keys) { const norm = MCM.match.normalizeName(detail.name); @@ -114,62 +164,287 @@ return suggestionCache.get(norm); } + function emptyMatchSummary() { + return { + matched: 0, + total: 0, + ratePerMinuteTotal: 0, + estimated: 0, + fallback: 0, + unknown: 0, + details: [], + }; + } + + async function refreshFx() { + const quote = await requestFxQuote(); + if (quote) { + fxQuote = quote; + tick(); + } else if (!fxQuote) { + console.warn('[MCM] NBP USD/PLN quote unavailable; USD display and USD alert thresholds are paused.'); + } + } + async function refreshCalendar() { if (!currentSettings.calendarEnabled || currentSettings.mockMode) return; + const code = meetingCode(); + if (!code) return; try { - const res = await chrome.runtime.sendMessage({ type: 'mcm-calendar-attendees', code: meetingCode() }); + const res = await chrome.runtime.sendMessage({ + type: 'mcm-calendar-attendees', + code, + }); if (res?.error) throw new Error(res.error); calendarAttendees = res?.eventFound ? res.attendees : null; if (res && !res.eventFound && !calendarWarned) { calendarWarned = true; - console.info('[MCM] no calendar event found for this Meet — using name matching.'); + console.info('[MCM] no Calendar event found for this Meet — using name matching.'); } } catch (err) { calendarAttendees = null; if (!calendarWarned) { calendarWarned = true; - console.warn(`[MCM] Calendar lookup failed (${err.message}) — falling back to name matching. Check the OAuth client id in manifest.json.`); + console.warn( + `[MCM] Calendar lookup failed (${err.message}) — using name matching. Check the OAuth client ID in manifest.json.`, + ); } } } + function saveRealLedger(force = false) { + if (!currentMeetingCode) return; + const now = Date.now(); + if (!force && now - lastLedgerSave < LEDGER_SAVE_MS) return; + lastLedgerSave = now; + const key = ledgerStorageKey(currentMeetingCode); + chrome.storage.local + .set({ + [key]: { + code: currentMeetingCode, + savedAt: now, + snapshot: realLedger.snapshot(), + }, + }) + .catch(() => {}); + } + + function notifyToolbar(level, allowBlink = true) { + const blinkOn = + level !== 'red' || !allowBlink || Math.floor(Date.now() / 1000) % 2 === 0; + const stateKey = `${level}:${allowBlink}:${blinkOn}`; + if (stateKey === lastToolbarState) return; + lastToolbarState = stateKey; + chrome.runtime + .sendMessage({ type: 'mcm-alert-state', level, blinkOn }) + .catch(() => {}); + } + + function renderUnavailable(names, mock) { + const message = mock + ? 'Mock rates are unavailable. Reinstall the extension data files.' + : 'Real rates unavailable. Run `npm run ingest`, then reload the extension.'; + overlay.update({ + total: null, + currentRatePerMin: null, + multiplier: 1, + matched: 0, + totalPeople: names.length, + elapsedMin: 0, + currency: currentSettings.displayCurrency, + mock, + unmatched: [], + alertLevel: 'unavailable', + alertThresholdsUsd: currentSettings.alertThresholdsUsd, + availabilityMessage: message, + ratesAvailable: false, + started: false, + estimated: 0, + fallback: 0, + unknown: 0, + }); + notifyToolbar('unavailable'); + } + + function renderCost({ ledgerState, matchedRates, rates, mock, unmatched = [], ended = false }) { + const sourceCurrency = MCM.currency.normalizeCurrency(rates.currency); + const displayCurrency = currentSettings.displayCurrency; + const plnPerUsd = fxQuote?.plnPerUsd; + const total = MCM.currency.convert( + ledgerState.total, + sourceCurrency, + displayCurrency, + plnPerUsd, + ); + const currentRatePerMin = MCM.currency.convert( + ended ? 0 : ledgerState.currentRatePerMin, + sourceCurrency, + displayCurrency, + plnPerUsd, + ); + const totalUsd = MCM.currency.convert( + ledgerState.total, + sourceCurrency, + 'USD', + plnPerUsd, + ); + const alertLevel = MCM.settings.alertLevel( + totalUsd, + currentSettings.alertThresholdsUsd, + ); + const fxUnavailable = total == null || currentRatePerMin == null || totalUsd == null; + const messages = []; + if (ended) messages.push('Meeting ended — final cost frozen.'); + if (fxUnavailable) { + messages.push('Live NBP USD/PLN quote unavailable; conversion and cost alerts are paused.'); + } + + overlay.update({ + total, + currentRatePerMin, + multiplier: ledgerState.multiplier, + matched: matchedRates.matched, + totalPeople: matchedRates.total, + elapsedMin: ledgerState.elapsedMin, + currency: displayCurrency, + mock, + unmatched, + alertLevel, + alertThresholdsUsd: currentSettings.alertThresholdsUsd, + availabilityMessage: messages.join(' '), + ratesAvailable: true, + started: ledgerState.started, + estimated: matchedRates.estimated, + fallback: matchedRates.fallback, + unknown: matchedRates.unknown, + ended, + }); + notifyToolbar(alertLevel, !ended); + } + function tick() { + const pageMeetingCode = meetingCode(); + if (!pageMeetingCode) { + if (currentMeetingCode) { + saveRealLedger(true); + currentMeetingCode = null; + realLedger = MCM.cost.createLedger(); + mockLedger = MCM.cost.createLedger(); + mockWallStart = Date.now(); + calendarAttendees = null; + } + finishedMeeting = null; + lastMatchedRates = emptyMatchSummary(); + overlay.setActive(false); + notifyToolbar('idle'); + return; + } + + overlay.setActive(true); + if (pageMeetingCode !== currentMeetingCode) { + currentMeetingCode = pageMeetingCode; + realLedger = MCM.cost.createLedger(); + mockLedger = MCM.cost.createLedger(); + mockWallStart = Date.now(); + lastLedgerSave = 0; + calendarAttendees = null; + calendarWarned = false; + finishedMeeting = null; + lastMatchedRates = emptyMatchSummary(); + refreshCalendar(); + } + const mock = !!currentSettings.mockMode; const rates = mock ? mockRates : realRates; const index = mock ? mockIndex : realIndex; + const mockSpeed = Number(currentSettings.mockSpeedX) > 0 + ? Number(currentSettings.mockSpeedX) + : 1; + const mockNowMs = (Date.now() - mockWallStart) * mockSpeed; + const rosterElapsedMin = mockNowMs / 60000; + const ledger = mock ? mockLedger : realLedger; + const ledgerNowMs = mock ? mockNowMs : Date.now(); - let elapsedMin; - if (mock) { - const speed = Number(currentSettings.mockSpeedX) > 0 ? Number(currentSettings.mockSpeedX) : 1; - elapsedMin = ((Date.now() - mockStart) / 60000) * speed; - } else { - elapsedMin = (Date.now() - startTs) / 60000; + if (MCM.scrape.isMeetingEnded()) { + if (!rates) { + renderUnavailable([], mock); + return; + } + if ( + !finishedMeeting || + finishedMeeting.code !== pageMeetingCode || + finishedMeeting.mock !== mock + ) { + const ledgerState = ledger.update({ + nowMs: ledgerNowMs, + ratePerMinute: 0, + hasPresence: false, + settings: currentSettings, + }); + finishedMeeting = { + code: pageMeetingCode, + mock, + ledgerState, + matchedRates: lastMatchedRates, + }; + if (!mock) saveRealLedger(true); + } + renderCost({ ...finishedMeeting, rates, ended: true }); + return; } - const names = mock ? MCM.mock.rosterAt(elapsedMin) : MCM.scrape.getParticipants(); + if (finishedMeeting?.code === pageMeetingCode) { + // A Rejoin removes the post-call heading. Resume from "now" without + // charging or timing the interval spent on the exit screen. + realLedger.rebase(Date.now(), currentSettings); + mockLedger.rebase(mockNowMs, currentSettings); + finishedMeeting = null; + } + + const names = mock + ? MCM.mock.rosterAt(rosterElapsedMin) + : MCM.scrape.getParticipants(); + + if (!rates) { + renderUnavailable(names, mock); + return; + } - let m; - if (!mock && calendarAttendees && calendarAttendees.length) { - m = MCM.match.matchByCalendar(names, calendarAttendees, rates, index, { overrides }); + let matchedRates; + if (!mock && calendarAttendees?.length) { + matchedRates = MCM.match.matchByCalendar( + names, + calendarAttendees, + rates, + index, + { overrides }, + ); } else { - m = MCM.match.matchParticipants(names, rates, index, { overrides }); + matchedRates = MCM.match.matchParticipants(names, rates, index, { overrides }); } + lastMatchedRates = matchedRates; const keys = [...index.keys()]; - const unmatched = m.details - .filter((d) => !d.isMatch) - .map((d) => ({ name: d.name, suggestions: suggestionsFor(d, keys) })) - .filter((u) => u.suggestions.length); + const unmatched = matchedRates.details + .filter((detail) => !detail.isMatch) + .map((detail) => ({ + name: detail.name, + suggestions: suggestionsFor(detail, keys), + })) + .filter((entry) => entry.suggestions.length); - const c = MCM.cost.compute(m.ratePerMinuteTotal, elapsedMin, currentSettings); - overlay.update({ - total: c.total, - currentRatePerMin: c.currentRatePerMin, - multiplier: c.multiplier, - matched: m.matched, - totalPeople: m.total, - elapsedMin, - currency: rates.currency, + const ledgerState = ledger.update({ + nowMs: ledgerNowMs, + ratePerMinute: matchedRates.ratePerMinuteTotal, + hasPresence: names.length > 0, + settings: currentSettings, + }); + if (!mock) saveRealLedger(); + + renderCost({ + ledgerState, + matchedRates, + rates, mock, unmatched, }); @@ -177,25 +452,40 @@ setInterval(tick, TICK_MS); MCM.scrape.onChange(tick); + refreshFx(); + setInterval(refreshFx, FX_REFRESH_MS); refreshCalendar(); setInterval(refreshCalendar, CALENDAR_REFRESH_MS); tick(); - // Toolbar click (from background) and hotkey both toggle the overlay. chrome.runtime.onMessage.addListener((msg) => { if (msg?.type === 'toggle-overlay') overlay.toggle(); }); - window.addEventListener('keydown', (e) => { - if (e.altKey && e.shiftKey && (e.key === 'C' || e.key === 'c')) overlay.toggle(); + window.addEventListener('keydown', (event) => { + if (event.altKey && event.shiftKey && (event.key === 'C' || event.key === 'c')) { + overlay.toggle(); + } }); + window.addEventListener('beforeunload', () => saveRealLedger(true)); - // React to settings changes from the options page without a reload. chrome.storage.onChanged.addListener((changes, area) => { if (area !== 'sync') return; - for (const [k, { newValue }] of Object.entries(changes)) currentSettings[k] = newValue; - if ('overlayEnabled' in changes) overlay.setVisible(currentSettings.overlayEnabled !== false); - if ('customSelector' in changes) MCM.scrape.setCustomSelector(currentSettings.customSelector); - if ('mockMode' in changes && changes.mockMode.newValue) mockStart = Date.now(); // restart the demo + for (const [key, { newValue }] of Object.entries(changes)) { + currentSettings[key] = newValue; + } + currentSettings.alertThresholdsUsd = MCM.settings.alertThresholds( + currentSettings.alertThresholdsUsd, + ); + if ('overlayEnabled' in changes) { + overlay.setVisible(currentSettings.overlayEnabled !== false); + } + if ('customSelector' in changes) { + MCM.scrape.setCustomSelector(currentSettings.customSelector); + } + if ('mockMode' in changes && changes.mockMode.newValue) { + mockWallStart = Date.now(); + mockLedger = MCM.cost.createLedger(); + } if ('calendarEnabled' in changes) { calendarWarned = false; if (changes.calendarEnabled.newValue) refreshCalendar(); diff --git a/meeting-cost-meter/extension/content/match.js b/meeting-cost-meter/extension/content/match.js index 1f91b08..e7827f4 100644 --- a/meeting-cost-meter/extension/content/match.js +++ b/meeting-cost-meter/extension/content/match.js @@ -29,12 +29,31 @@ const map = new Map(); for (const person of rates.people || []) { for (const key of person.keys || []) { - if (!map.has(key)) map.set(key, person.ratePerMinute); + if (!map.has(key)) { + map.set(key, { + ratePerMinute: Number(person.ratePerMinute), + estimated: person.estimated === true, + }); + } } } return map; } + function rateEntry(value) { + if (value && typeof value === 'object') { + const rate = Number(value.ratePerMinute); + return Number.isFinite(rate) ? { rate, estimated: value.estimated === true } : null; + } + const rate = Number(value); + return Number.isFinite(rate) ? { rate, estimated: false } : null; + } + + function explicitFallback(rates) { + const value = Number(rates?.defaultRatePerMinute); + return Number.isFinite(value) && value > 0 ? value : null; + } + // --- fuzzy fallback (Levenshtein similarity) --- function levenshtein(a, b) { const m = a.length; @@ -86,6 +105,9 @@ let ratePerMinuteTotal = 0; let matched = 0; + let estimated = 0; + let fallback = 0; + let unknown = 0; const details = []; for (const name of names) { @@ -94,35 +116,92 @@ const normLookup = normalizeName(lookupName); let rate = null; + let isEstimated = false; let matchType = 'none'; for (const c of [normLookup, tokenKey(lookupName)]) { if (index.has(c)) { - rate = index.get(c); - matchType = overrides[normDisplay] ? 'override' : 'exact'; - break; + const entry = rateEntry(index.get(c)); + if (entry) { + rate = entry.rate; + isEstimated = entry.estimated; + matchType = overrides[normDisplay] ? 'override' : 'exact'; + break; + } } } if (rate == null) { const near = nearestKey(normLookup, keys, fuzzyThreshold); if (near != null) { - rate = index.get(near); - matchType = 'fuzzy'; + const entry = rateEntry(index.get(near)); + if (entry) { + rate = entry.rate; + isEstimated = entry.estimated; + matchType = 'fuzzy'; + } } } const isMatch = matchType !== 'none'; - if (!isMatch) rate = rates.defaultRatePerMinute || 0; - if (isMatch) matched++; + let isFallback = false; + let rateKnown = true; + if (!isMatch) { + const fallbackRate = explicitFallback(rates); + if (fallbackRate != null) { + rate = fallbackRate; + isEstimated = true; + isFallback = true; + fallback++; + } else { + rate = 0; + rateKnown = false; + unknown++; + } + } else { + matched++; + } + if (isEstimated) estimated++; ratePerMinuteTotal += rate; - details.push({ name, rate, isMatch, matchType }); + details.push({ name, rate, isMatch, matchType, isEstimated, isFallback, rateKnown }); } - return { matched, total: names.length, ratePerMinuteTotal, details }; + return { + matched, + total: names.length, + ratePerMinuteTotal, + estimated, + fallback, + unknown, + details, + }; } /** Exact match by email address (Calendar-API path). */ function rateForEmail(email, rates, index) { - const rate = index.get(String(email || '').trim().toLowerCase()); - return rate != null ? { rate, isMatch: true } : { rate: rates.defaultRatePerMinute || 0, isMatch: false }; + const entry = rateEntry(index.get(String(email || '').trim().toLowerCase())); + if (entry) { + return { + rate: entry.rate, + isMatch: true, + isEstimated: entry.estimated, + isFallback: false, + rateKnown: true, + }; + } + const fallbackRate = explicitFallback(rates); + return fallbackRate != null + ? { + rate: fallbackRate, + isMatch: false, + isEstimated: true, + isFallback: true, + rateKnown: true, + } + : { + rate: 0, + isMatch: false, + isEstimated: false, + isFallback: false, + rateKnown: false, + }; } /** @@ -156,8 +235,8 @@ /** * Calendar-first matching: invitees [{email, displayName?, responseStatus?}] * carry identity + rate (exact email keys); scraped display names carry - * presence only. With no scraped names, every non-declined invitee is - * charged. Names that resolve to no invitee fall back to name matching. + * presence only. With no scraped names, nobody is charged: invitees are not + * proof of attendance. Names that resolve to no invitee use name matching. */ function matchByCalendar(names, invitees, rates, index, opts = {}) { const presenceThreshold = opts.presenceThreshold ?? 0.6; @@ -169,24 +248,27 @@ return { email: String(i.email).trim().toLowerCase(), label: i.displayName || localpartName, nameKeys, claimed: false }; }); - // No presence info — charge the whole invite list. + // No presence info: an invite list is not an attendance roster. if (!names || !names.length) { - let ratePerMinuteTotal = 0; - let matched = 0; - const details = []; - for (const inv of pool) { - const r = rateForEmail(inv.email, rates, index); - if (r.isMatch) matched++; - ratePerMinuteTotal += r.rate; - details.push({ name: inv.label, rate: r.rate, isMatch: r.isMatch, matchType: r.isMatch ? 'email' : 'none' }); - } - return { matched, total: pool.length, ratePerMinuteTotal, details, presence: 'invitees' }; + return { + matched: 0, + total: 0, + ratePerMinuteTotal: 0, + estimated: 0, + fallback: 0, + unknown: 0, + details: [], + presence: 'none', + }; } // Presence known: resolve each scraped name to an invitee. The candidate // set is tiny (this meeting's invitees), so a lower threshold is safe. let ratePerMinuteTotal = 0; let matched = 0; + let estimated = 0; + let fallback = 0; + let unknown = 0; const details = []; for (const name of names) { const target = tokenKey(opts.overrides?.[normalizeName(name)] || name); @@ -206,16 +288,39 @@ best.claimed = true; const r = rateForEmail(best.email, rates, index); if (r.isMatch) matched++; + if (r.isEstimated) estimated++; + if (r.isFallback) fallback++; + if (!r.rateKnown) unknown++; ratePerMinuteTotal += r.rate; - details.push({ name, rate: r.rate, isMatch: r.isMatch, matchType: r.isMatch ? 'email' : 'none' }); + details.push({ + name, + rate: r.rate, + isMatch: r.isMatch, + matchType: r.isMatch ? 'email' : 'none', + isEstimated: r.isEstimated, + isFallback: r.isFallback, + rateKnown: r.rateKnown, + }); } else { const m = matchParticipants([name], rates, index, opts); if (m.details[0].isMatch) matched++; + estimated += m.estimated; + fallback += m.fallback; + unknown += m.unknown; ratePerMinuteTotal += m.details[0].rate; details.push(m.details[0]); } } - return { matched, total: names.length, ratePerMinuteTotal, details, presence: 'scraped' }; + return { + matched, + total: names.length, + ratePerMinuteTotal, + estimated, + fallback, + unknown, + details, + presence: 'scraped', + }; } MCM.match = { normalizeName, tokenKey, buildIndex, matchParticipants, rateForEmail, suggestFor, matchByCalendar, similarity }; diff --git a/meeting-cost-meter/extension/content/overlay.js b/meeting-cost-meter/extension/content/overlay.js index 1482d43..a499cbe 100644 --- a/meeting-cost-meter/extension/content/overlay.js +++ b/meeting-cost-meter/extension/content/overlay.js @@ -1,6 +1,5 @@ -// Aggregate-only floating overlay, rendered in a Shadow DOM so Meet's CSS can't -// touch it and ours can't leak out. Shows total cost, current rate/min, elapsed -// time, headcount, and an escalation badge. No per-person figures. +// Aggregate-only floating overlay rendered in a Shadow DOM. The whole card +// follows the USD alert thresholds; red blinks. No per-person rates are shown. (function () { const G = typeof window !== 'undefined' ? window : globalThis; const MCM = (G.__MCM = G.__MCM || {}); @@ -9,25 +8,40 @@ :host { all: initial; } .card { position: fixed; top: 88px; right: 16px; z-index: 2147483647; - width: 232px; font-family: 'Google Sans', Roboto, system-ui, sans-serif; - color: #fff; background: #202124; border-radius: 12px; overflow: hidden; + width: 248px; font-family: 'Google Sans', Roboto, system-ui, sans-serif; + color: #fff; background: #188038; border-radius: 12px; overflow: hidden; box-shadow: 0 6px 24px rgba(0,0,0,.4); user-select: none; + transition: background .35s, color .35s; } - .bar { height: 6px; background: #34a853; transition: background .4s; } - .bar.warn { background: #fbbc04; } .bar.alarm { background: #ea4335; } + .card.green { background:#188038; } + .card.yellow { background:#f9ab00; color:#202124; } + .card.orange { background:#e8710a; color:#fff; } + .card.red { background:#d93025; color:#fff; animation:mcm-alarm-blink 1s steps(2,end) infinite; } + .card.red.ended { animation:none; filter:none; box-shadow:0 6px 24px rgba(217,48,37,.55); } + .card.unavailable { background:#5f6368; color:#fff; } + @keyframes mcm-alarm-blink { + 0%, 49% { filter:brightness(1); box-shadow:0 6px 24px rgba(217,48,37,.8); } + 50%, 100% { filter:brightness(.62); box-shadow:0 6px 24px rgba(0,0,0,.25); } + } + .bar { height: 6px; background: rgba(255,255,255,.45); } .head { display:flex; align-items:center; justify-content:space-between; - padding: 8px 12px; cursor: move; background: #2d2e30; } + padding: 8px 10px 8px 12px; cursor: move; background: rgba(0,0,0,.18); } .title { font-size: 12px; font-weight: 600; letter-spacing:.02em; opacity:.9; } - .head button { all: unset; cursor: pointer; opacity:.7; padding:2px 6px; font-size:14px; } + .actions { display:flex; align-items:center; gap:2px; } + .head button { all: unset; cursor: pointer; opacity:.8; padding:2px 6px; font-size:14px; } .head button:hover { opacity: 1; } + .head .currency { font-size:10px; font-weight:700; border:1px solid currentColor; + border-radius:5px; padding:2px 5px; } .body { padding: 12px 14px 14px; } .total { font-size: 34px; font-weight: 700; line-height: 1.05; font-variant-numeric: tabular-nums; } .sub { display:flex; justify-content:space-between; font-size:12px; opacity:.75; margin-top: 8px; } .badge { margin-top: 10px; font-size: 12px; font-weight:600; padding: 5px 8px; border-radius: 6px; display:none; } .badge.show { display:block; } - .badge.warn { background: rgba(251,188,4,.18); color:#fdd663; } - .badge.alarm { background: rgba(234,67,53,.2); color:#f28b82; } + .badge { background:rgba(0,0,0,.18); color:inherit; } + .notice { margin-top:10px; font-size:11px; line-height:1.35; padding:6px 8px; + border-radius:6px; background:rgba(0,0,0,.16); display:none; } + .notice.show { display:block; } .mock-pill { font-size: 10px; font-weight: 700; letter-spacing:.06em; padding: 2px 6px; border-radius: 4px; background: rgba(138,180,248,.25); color:#8ab4f8; display:none; } .mock-pill.show { display:inline-block; } @@ -41,16 +55,23 @@ .u-chip:hover { background: rgba(138,180,248,.3); } .foot { margin-top:10px; } .foot input { width: 100%; box-sizing: border-box; font-size: 11px; padding:5px 6px; - border-radius:6px; border:1px solid #5f6368; background:#202124; color:#e8eaed; } + border-radius:6px; border:1px solid currentColor; background:rgba(0,0,0,.22); color:inherit; } + .foot input::placeholder { color:currentColor; opacity:.65; } .foot label { font-size: 10px; opacity:.6; display:block; margin-bottom:3px; } .hidden { display:none !important; } `; function fmtMoney(n, currency) { + if (!Number.isFinite(n)) return '—'; try { - return new Intl.NumberFormat(undefined, { style: 'currency', currency, maximumFractionDigits: 0 }).format(n || 0); + return new Intl.NumberFormat(undefined, { + style: 'currency', + currency, + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(n); } catch { - return `${Math.round(n || 0)} ${currency}`; + return `${n.toFixed(2)} ${currency}`; } } @@ -63,7 +84,12 @@ return h ? `${h}:${pad(m)}:${pad(sec)}` : `${pad(m)}:${pad(sec)}`; } - function createOverlay({ currency = 'USD', onManualRoster, onConfirmOverride } = {}) { + function createOverlay({ + currency = 'PLN', + onManualRoster, + onConfirmOverride, + onCurrencyChange, + } = {}) { const host = document.createElement('div'); host.id = 'mcm-overlay-host'; const root = host.attachShadow({ mode: 'open' }); @@ -73,13 +99,17 @@
💸 Meeting Cost MOCK - + + + +
+
Not recognized — click to fix:
+ + diff --git a/meeting-cost-meter/extension/demo/demo.js b/meeting-cost-meter/extension/demo/demo.js index f377798..40e7d58 100644 --- a/meeting-cost-meter/extension/demo/demo.js +++ b/meeting-cost-meter/extension/demo/demo.js @@ -10,7 +10,12 @@ let rates = { currency: 'USD', defaultRatePerMinute: 1, people: [] }; let index = new Map(); let overlay = null; - let settings = { ...MCM.cost.DEFAULTS }; + let settings = { + ...MCM.settings.DEFAULTS, + alertThresholdsUsd: { ...MCM.settings.ALERT_THRESHOLDS_USD }, + }; + let ledger = MCM.cost.createLedger(); + let fxQuote = null; function elapsedMin() { const live = clock.running ? ((Date.now() - clock.wallStart) / 60000) * clock.speed : 0; @@ -34,24 +39,66 @@ async function loadSettings() { try { - settings = { ...MCM.cost.DEFAULTS, ...(await chrome.storage.sync.get(MCM.cost.DEFAULTS)) }; + settings = { + ...settings, + ...(await chrome.storage.sync.get(settings)), + }; + settings.alertThresholdsUsd = MCM.settings.alertThresholds(settings.alertThresholdsUsd); } catch (_) { /* keep defaults */ } } + async function loadFx() { + try { + const response = await chrome.runtime.sendMessage({ type: 'mcm-fx-rate' }); + if (Number(response?.plnPerUsd) > 0) fxQuote = response; + } catch (_) { + /* PLN demo still works; USD and alert colors remain unavailable */ + } + } + function tick() { const m = MCM.match.matchParticipants(roster, rates, index); const t = elapsedMin(); - const c = MCM.cost.compute(m.ratePerMinuteTotal, t, settings); + const c = ledger.update({ + nowMs: t * 60000, + ratePerMinute: m.ratePerMinuteTotal, + hasPresence: roster.length > 0, + settings, + }); + const displayCurrency = settings.displayCurrency === 'USD' ? 'USD' : 'PLN'; + const total = MCM.currency.convert( + c.total, + rates.currency, + displayCurrency, + fxQuote?.plnPerUsd, + ); + const currentRatePerMin = MCM.currency.convert( + c.currentRatePerMin, + rates.currency, + displayCurrency, + fxQuote?.plnPerUsd, + ); + const totalUsd = MCM.currency.convert(c.total, rates.currency, 'USD', fxQuote?.plnPerUsd); overlay.update({ - total: c.total, - currentRatePerMin: c.currentRatePerMin, + total, + currentRatePerMin, multiplier: c.multiplier, matched: m.matched, totalPeople: m.total, - elapsedMin: t, - currency: rates.currency, + elapsedMin: c.elapsedMin, + currency: displayCurrency, + mock: true, + alertLevel: MCM.settings.alertLevel(totalUsd, settings.alertThresholdsUsd), + alertThresholdsUsd: settings.alertThresholdsUsd, + availabilityMessage: + totalUsd == null ? 'Live NBP USD/PLN quote unavailable; USD alerts are paused.' : '', + ratesAvailable: true, + started: c.started, + estimated: m.estimated, + fallback: m.fallback, + unknown: m.unknown, }); } @@ -92,6 +139,7 @@ document.getElementById('reset').addEventListener('click', () => { clock.baseMin = 0; clock.wallStart = Date.now(); + ledger = MCM.cost.createLedger(); tick(); }); for (const btn of document.querySelectorAll('.speed')) { @@ -120,14 +168,33 @@ } async function main() { - await Promise.all([loadRates(), loadSettings()]); - overlay = MCM.overlay.createOverlay({ currency: rates.currency }); + await Promise.all([loadRates(), loadSettings(), loadFx()]); + // Seed the roster at t=0 so the initial 20-minute demo state has history. + const initial = MCM.match.matchParticipants(roster, rates, index); + ledger.update({ + nowMs: 0, + ratePerMinute: initial.ratePerMinuteTotal, + hasPresence: roster.length > 0, + settings, + }); + overlay = MCM.overlay.createOverlay({ + currency: settings.displayCurrency, + onCurrencyChange: (currency) => { + settings.displayCurrency = currency; + chrome.storage.sync.set({ displayCurrency: currency }).catch(() => {}); + tick(); + }, + }); wireControls(); renderChips(); tick(); setInterval(tick, 250); chrome.storage.onChanged?.addListener((changes, area) => { - if (area === 'sync') for (const [k, { newValue }] of Object.entries(changes)) settings[k] = newValue; + if (area === 'sync') { + for (const [k, { newValue }] of Object.entries(changes)) settings[k] = newValue; + settings.alertThresholdsUsd = MCM.settings.alertThresholds(settings.alertThresholdsUsd); + tick(); + } }); } diff --git a/meeting-cost-meter/extension/manifest.json b/meeting-cost-meter/extension/manifest.json index 35ba485..b81141e 100644 --- a/meeting-cost-meter/extension/manifest.json +++ b/meeting-cost-meter/extension/manifest.json @@ -4,7 +4,10 @@ "version": "0.1.0", "description": "Shows a live, escalating cost for your Google Meet meetings to nudge everyone to finish sooner.", "permissions": ["storage", "identity"], - "host_permissions": ["https://www.googleapis.com/*"], + "host_permissions": [ + "https://www.googleapis.com/*", + "https://api.nbp.pl/*" + ], "oauth2": { "client_id": "REPLACE_WITH_YOUR_OAUTH_CLIENT_ID.apps.googleusercontent.com", "scopes": ["https://www.googleapis.com/auth/calendar.readonly"] @@ -16,9 +19,11 @@ { "matches": ["https://meet.google.com/*"], "js": [ + "shared/settings.js", "content/scrape.js", "content/match.js", "content/cost.js", + "content/currency.js", "content/overlay.js", "content/mock.js", "content/main.js" diff --git a/meeting-cost-meter/extension/options/options.html b/meeting-cost-meter/extension/options/options.html index 5ffdb62..6111143 100644 --- a/meeting-cost-meter/extension/options/options.html +++ b/meeting-cost-meter/extension/options/options.html @@ -12,8 +12,12 @@ label { display: block; font-weight: 600; font-size: 14px; margin-bottom: 4px; } .hint { font-weight: 400; color: #5f6368; font-size: 12px; } input[type="number"] { width: 90px; padding: 6px 8px; border: 1px solid #dadce0; border-radius: 6px; } + select { padding: 6px 8px; border: 1px solid #dadce0; border-radius: 6px; background:#fff; } input[type="text"] { width: 100%; box-sizing: border-box; padding: 6px 8px; border: 1px solid #dadce0; border-radius: 6px; font-family: ui-monospace, monospace; font-size: 12px; } .toggle { display: flex; align-items: center; gap: 8px; } + .thresholds { display:grid; grid-template-columns:repeat(3, 1fr); gap:12px; } + .thresholds label { font-size:12px; font-weight:500; } + .thresholds input { width:100% !important; box-sizing:border-box; } h2 { font-size: 15px; margin: 26px 0 6px; border-top: 1px solid #eee; padding-top: 18px; } #status { color: #1e8e3e; font-size: 13px; height: 16px; } @@ -22,7 +26,8 @@

💸 Meeting Cost Meter

The cost ticks up linearly, then a penalty multiplier accelerates it after a grace period — - to nudge everyone to wrap up. Per-person salaries are never shown or stored in the browser. + to nudge everyone to wrap up. Per-person rates are never shown; the local generated + rates file remains confidential and is excluded from Git.

@@ -30,6 +35,35 @@

💸 Meeting Cost Meter

+

Currency and cost alerts

+ +
+ + +
+

+ PLN/USD conversion uses the latest official NBP table-A midpoint and a cached official + quote if NBP is temporarily unavailable. The extension never guesses an exchange rate. +

+ +
+ +
+ + + +
+
+
@@ -80,6 +114,7 @@

Demo

+ diff --git a/meeting-cost-meter/extension/options/options.js b/meeting-cost-meter/extension/options/options.js index 9dc8777..55e1697 100644 --- a/meeting-cost-meter/extension/options/options.js +++ b/meeting-cost-meter/extension/options/options.js @@ -1,16 +1,8 @@ // Load/save settings to chrome.storage.sync. main.js reacts live via // chrome.storage.onChanged, so no reload is needed. -const DEFAULTS = { - thresholdMin: 30, - alphaPerMin: 0.05, - maxMultiplier: 4, - overlayEnabled: true, - mockMode: false, - mockSpeedX: 60, - calendarEnabled: false, - customSelector: '', -}; +const SETTINGS = window.__MCM.settings; +const DEFAULTS = SETTINGS.DEFAULTS; const NUMERIC = ['thresholdMin', 'alphaPerMin', 'maxMultiplier', 'mockSpeedX']; const BOOLEAN = ['overlayEnabled', 'mockMode', 'calendarEnabled']; @@ -28,6 +20,11 @@ async function restore() { document.getElementById('overlayEnabled').checked = s.overlayEnabled !== false; for (const key of NUMERIC) document.getElementById(key).value = s[key]; for (const key of TEXT) document.getElementById(key).value = s[key] || ''; + document.getElementById('displayCurrency').value = s.displayCurrency === 'USD' ? 'USD' : 'PLN'; + const alerts = SETTINGS.alertThresholds(s.alertThresholdsUsd); + document.getElementById('alertYellowUsd').value = alerts.yellow; + document.getElementById('alertOrangeUsd').value = alerts.orange; + document.getElementById('alertRedUsd').value = alerts.red; } function save() { @@ -38,10 +35,24 @@ function save() { if (Number.isFinite(n)) patch[key] = n; } for (const key of TEXT) patch[key] = document.getElementById(key).value.trim(); + patch.displayCurrency = document.getElementById('displayCurrency').value === 'USD' ? 'USD' : 'PLN'; + patch.alertThresholdsUsd = SETTINGS.alertThresholds({ + yellow: document.getElementById('alertYellowUsd').value, + orange: document.getElementById('alertOrangeUsd').value, + red: document.getElementById('alertRedUsd').value, + }); chrome.storage.sync.set(patch).then(() => flash('Saved ✓')); } document.addEventListener('DOMContentLoaded', restore); -for (const key of [...BOOLEAN, ...NUMERIC, ...TEXT]) { +for (const key of [ + ...BOOLEAN, + ...NUMERIC, + ...TEXT, + 'displayCurrency', + 'alertYellowUsd', + 'alertOrangeUsd', + 'alertRedUsd', +]) { document.getElementById(key).addEventListener('change', save); } diff --git a/meeting-cost-meter/extension/shared/settings.js b/meeting-cost-meter/extension/shared/settings.js new file mode 100644 index 0000000..c5057cc --- /dev/null +++ b/meeting-cost-meter/extension/shared/settings.js @@ -0,0 +1,64 @@ +// Shared runtime defaults and alert-level rules. This is the single source of +// truth for the content script, options page, and service worker. +(function () { + const G = typeof window !== 'undefined' ? window : globalThis; + const MCM = (G.__MCM = G.__MCM || {}); + + const ALERT_THRESHOLDS_USD = Object.freeze({ + yellow: 10, + orange: 20, + red: 30, + }); + + const ALERT_COLORS = Object.freeze({ + idle: '#5f6368', + unavailable: '#5f6368', + green: '#188038', + yellow: '#f9ab00', + orange: '#e8710a', + red: '#d93025', + }); + + const DEFAULTS = Object.freeze({ + thresholdMin: 30, + alphaPerMin: 0.05, + maxMultiplier: 4, + overlayEnabled: true, + mockMode: false, + mockSpeedX: 60, + calendarEnabled: false, + customSelector: '', + displayCurrency: 'PLN', + alertThresholdsUsd: ALERT_THRESHOLDS_USD, + }); + + function alertThresholds(value) { + const raw = value && typeof value === 'object' ? value : {}; + const yellow = positiveNumber(raw.yellow, ALERT_THRESHOLDS_USD.yellow); + const orange = Math.max(positiveNumber(raw.orange, ALERT_THRESHOLDS_USD.orange), yellow); + const red = Math.max(positiveNumber(raw.red, ALERT_THRESHOLDS_USD.red), orange); + return { yellow, orange, red }; + } + + function alertLevel(totalUsd, thresholds) { + if (!Number.isFinite(totalUsd)) return 'unavailable'; + const t = alertThresholds(thresholds); + if (totalUsd > t.red) return 'red'; + if (totalUsd > t.orange) return 'orange'; + if (totalUsd > t.yellow) return 'yellow'; + return 'green'; + } + + function positiveNumber(value, fallback) { + const n = Number(value); + return Number.isFinite(n) && n >= 0 ? n : fallback; + } + + MCM.settings = { + ALERT_THRESHOLDS_USD, + ALERT_COLORS, + DEFAULTS, + alertThresholds, + alertLevel, + }; +})(); diff --git a/meeting-cost-meter/ingest/build-rates.js b/meeting-cost-meter/ingest/build-rates.js index db6f2f8..b477991 100644 --- a/meeting-cost-meter/ingest/build-rates.js +++ b/meeting-cost-meter/ingest/build-rates.js @@ -5,8 +5,8 @@ // Real "Delivery" PDF has two keying schemes and two contract types: // - Developers: keyed by LEVEL (Expert / Senior 2 / … / Mid 1) // - QA & Portfolio: keyed by full role TITLE -// - each exists as (B2B) and (CoE); the employee DB has no contract-type -// field, so config.contractType picks one (or "average"). +// - each exists as (B2B) and (CoE). Per-person contract type is used when +// supplied; otherwise config.contractType is the explicit assumption. import { buildKeys } from './normalize.js'; @@ -140,6 +140,17 @@ function findBand(bands, classified, contractType) { return bestScore > -Infinity ? best : null; } +/** Normalize common labels from CSV/Notion to the PDF's two contract types. */ +export function normalizeContractType(value) { + const text = norm(value); + if (!text) return null; + if (text === 'b2b' || /business to business|contractor/.test(text)) return 'B2B'; + if (text === 'coe' || /contract of employment|employment contract|umowa o prace|uop/.test(text)) { + return 'CoE'; + } + return null; +} + /** Resolve to a {min,max} band, honoring config.contractType ("B2B"|"CoE"|"average"). */ export function resolveBand(bands, classified, config) { const ct = config.contractType || 'B2B'; @@ -169,27 +180,45 @@ export function roundSig(n, sig) { return Math.round(n * mult) / mult; } -const mean = (arr) => (arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : 0); - -/** @returns {{ people, defaultRatePerMinute, matchedCount, unmatched }} */ +/** + * Build browser-safe rate records. An unmatched attendee gets a fallback only + * when fallbackAnnualGross is explicitly configured; we never derive it from + * the subset that happened to match a PDF. + */ export function buildRates({ employees, salaryTable, config }) { const people = []; - const rates = []; const unmatched = []; + let estimatedCount = 0; for (const emp of employees) { const classified = classifyEmployee(emp, config); - const band = resolveBand(salaryTable, classified, config); + const employeeContractType = normalizeContractType(emp.contractType); + const contractType = employeeContractType || config.contractType || 'average'; + const band = resolveBand(salaryTable, classified, { ...config, contractType }); if (!band) { unmatched.push({ emp, classified }); continue; } const annual = annualize(bandPointValue(band.min, band.max, config.bandPoint), config.period); const rate = roundSig(computeRatePerMinute(annual, config), config.rateRoundingSignificantDigits); - people.push({ keys: buildKeys(emp), ratePerMinute: rate }); - rates.push(rate); + const estimated = !employeeContractType && contractType === 'average'; + if (estimated) estimatedCount++; + people.push({ keys: buildKeys(emp), ratePerMinute: rate, estimated }); } - const defaultRatePerMinute = roundSig(mean(rates), config.rateRoundingSignificantDigits); - return { people, defaultRatePerMinute, matchedCount: people.length, unmatched }; + const fallbackAnnualGross = Number(config.fallbackAnnualGross); + const defaultRatePerMinute = + fallbackAnnualGross > 0 + ? roundSig( + computeRatePerMinute(fallbackAnnualGross, config), + config.rateRoundingSignificantDigits, + ) + : null; + return { + people, + defaultRatePerMinute, + matchedCount: people.length, + estimatedCount, + unmatched, + }; } diff --git a/meeting-cost-meter/ingest/config.json b/meeting-cost-meter/ingest/config.json index 3afb28f..4a9708e 100644 --- a/meeting-cost-meter/ingest/config.json +++ b/meeting-cost-meter/ingest/config.json @@ -2,7 +2,8 @@ "currency": "PLN", "period": "monthly", "bandPoint": "mid", - "contractType": "B2B", + "contractType": "average", + "fallbackAnnualGross": null, "hoursPerYear": 2016, "overheadMultiplier": 1.0, "rateRoundingSignificantDigits": 2, @@ -17,9 +18,13 @@ "email": "Email", "team": "Team", "roleSeniority": "Role / Seniority Level", - "alias": "alternatively called" + "alias": "alternatively called", + "contractType": null }, - "pdfs": [{ "path": "ingest/data/salary-delivery.pdf", "section": "delivery" }] + "pdfs": [ + { "path": "ingest/data/salary-delivery.pdf", "section": "delivery" }, + { "path": "ingest/data/salary-business.pdf", "section": "business", "optional": true } + ] }, "employees": { @@ -37,7 +42,8 @@ "email": "Email", "alias": "alternatively called", "team": "Team", - "roleSeniority": "Role / Seniority Level" + "roleSeniority": "Role / Seniority Level", + "contractType": null }, "levelTokens": [ diff --git a/meeting-cost-meter/ingest/csv.js b/meeting-cost-meter/ingest/csv.js index 22180ae..affdcc6 100644 --- a/meeting-cost-meter/ingest/csv.js +++ b/meeting-cost-meter/ingest/csv.js @@ -57,7 +57,9 @@ const splitTags = (cell) => /** * Map a CSV export (header row + data rows) to employee records using - * `columns` = { firstName, lastName, email, team, roleSeniority, alias? } + * `columns` = { + * firstName, lastName, email, team, roleSeniority, alias?, contractType? + * } * (values are the CSV header names). Multi-value cells (teams, role/seniority) * are comma-separated inside one quoted cell — split back into tags. */ @@ -98,6 +100,7 @@ export function csvToEmployees(text, columns) { aliases: alias ? splitTags(alias) : [], teams: splitTags(cell(row, 'team')), roleTags: splitTags(cell(row, 'roleSeniority')), + contractType: cell(row, 'contractType'), }); } return employees; diff --git a/meeting-cost-meter/ingest/dump-pdf-text.js b/meeting-cost-meter/ingest/dump-pdf-text.js index 5df0a4f..01f8cb7 100644 --- a/meeting-cost-meter/ingest/dump-pdf-text.js +++ b/meeting-cost-meter/ingest/dump-pdf-text.js @@ -15,10 +15,23 @@ const ROOT = path.resolve(fileURLToPath(import.meta.url), '../..'); async function getPdfSources() { const useLocal = process.argv.includes('--local') || (!process.argv.includes('--notion') && config.dataSource === 'local'); if (useLocal) { - return (config.local?.pdfs || []).map((p) => { + const sources = []; + for (const p of config.local?.pdfs || []) { const abs = path.resolve(ROOT, p.path); - return { data: new Uint8Array(fs.readFileSync(abs)), section: p.section, filename: path.basename(abs) }; - }); + if (!fs.existsSync(abs)) { + if (p.optional) { + console.warn(`Optional salary PDF not found: ${path.relative(ROOT, abs)} (${p.section})`); + continue; + } + throw new Error(`Required salary PDF not found: ${path.relative(ROOT, abs)}`); + } + sources.push({ + data: new Uint8Array(fs.readFileSync(abs)), + section: p.section, + filename: path.basename(abs), + }); + } + return sources; } const { createClient, fetchSalaryPdfLinks } = await import('./notion.js'); const { downloadPdf } = await import('./pdf.js'); diff --git a/meeting-cost-meter/ingest/index.js b/meeting-cost-meter/ingest/index.js index e90a153..5f8de6a 100644 --- a/meeting-cost-meter/ingest/index.js +++ b/meeting-cost-meter/ingest/index.js @@ -51,11 +51,23 @@ async function loadFromLocal() { console.log(`Reading employees from ${path.relative(ROOT, csvPath)}…`); const employees = csvToEmployees(fs.readFileSync(csvPath, 'utf8'), config.local.csvColumns); - const pdfSources = (config.local.pdfs || []).map((p) => { + const pdfSources = []; + for (const p of config.local.pdfs || []) { const abs = path.resolve(ROOT, p.path); + if (!fs.existsSync(abs)) { + if (p.optional) { + console.warn(`Optional salary PDF not found: ${path.relative(ROOT, abs)} (${p.section})`); + continue; + } + throw new Error(`Required salary PDF not found: ${path.relative(ROOT, abs)}`); + } console.log(`Reading ${path.relative(ROOT, abs)} (${p.section})…`); - return { data: new Uint8Array(fs.readFileSync(abs)), section: p.section, filename: path.basename(abs) }; - }); + pdfSources.push({ + data: new Uint8Array(fs.readFileSync(abs)), + section: p.section, + filename: path.basename(abs), + }); + } return { employees, pdfSources }; } @@ -77,15 +89,23 @@ async function main() { console.warn('\n ⚠ No salary bands parsed. Run `npm run ingest:dump-pdf` and tune ingest/pdf.js.'); } - const { people, defaultRatePerMinute, matchedCount, unmatched } = buildRates({ + const { people, defaultRatePerMinute, matchedCount, estimatedCount, unmatched } = buildRates({ employees, salaryTable, config, }); console.log(`\nMatched ${matchedCount}/${employees.length} employees to a salary band.`); + if (estimatedCount) { + console.log( + `${estimatedCount} matched rates use the configured "${config.contractType}" contract-type assumption.`, + ); + } if (unmatched.length) { - console.log(`${unmatched.length} without a band (will use the average rate at runtime):`); + const fallback = defaultRatePerMinute + ? `will use the explicit ${defaultRatePerMinute} ${config.currency}/min fallback` + : 'have no configured rate and will be excluded from cost with a visible warning'; + console.log(`${unmatched.length} without a band (${fallback}):`); for (const u of unmatched.slice(0, 25)) { console.log(` - [${u.classified.section}] role="${u.classified.roleFamily}" level="${u.classified.level}"`); } @@ -96,6 +116,14 @@ async function main() { generatedAt: new Date().toISOString().slice(0, 10), currency: config.currency, defaultRatePerMinute, + coverage: { + employeeCount: employees.length, + matchedCount, + estimatedCount, + unmatchedCount: unmatched.length, + contractTypeAssumption: config.contractType, + hasExplicitFallback: defaultRatePerMinute != null, + }, people, }; diff --git a/meeting-cost-meter/ingest/notion.js b/meeting-cost-meter/ingest/notion.js index ed8c95e..53db533 100644 --- a/meeting-cost-meter/ingest/notion.js +++ b/meeting-cost-meter/ingest/notion.js @@ -28,6 +28,16 @@ function multiSelect(prop) { return (prop?.multi_select || []).map((o) => o.name); } +function propertyText(prop) { + return ( + prop?.select?.name || + prop?.status?.name || + plainText(prop?.rich_text) || + plainText(prop?.title) || + '' + ); +} + /** Map one Notion page (DB row) to a flat employee record using config.properties. */ export function mapEmployee(page, props) { const p = page.properties || {}; @@ -38,6 +48,7 @@ export function mapEmployee(page, props) { const alias = plainText(p[props.alias]?.rich_text); const teams = multiSelect(p[props.team]); const roleTags = multiSelect(p[props.roleSeniority]); + const contractType = props.contractType ? propertyText(p[props.contractType]) : ''; if (!firstName && !lastName && !title && !email) return null; // empty/placeholder row @@ -49,6 +60,7 @@ export function mapEmployee(page, props) { aliases: alias ? [alias] : [], teams, roleTags, + contractType, }; } diff --git a/meeting-cost-meter/package.json b/meeting-cost-meter/package.json index d3048a6..7b0e9f7 100644 --- a/meeting-cost-meter/package.json +++ b/meeting-cost-meter/package.json @@ -7,7 +7,8 @@ "scripts": { "ingest": "node --env-file-if-exists=.env ingest/index.js", "ingest:dump-pdf": "node --env-file-if-exists=.env ingest/dump-pdf-text.js", - "test": "node --test" + "demo:serve": "node test/browser/server.js", + "test": "node --test test/*.test.js" }, "engines": { "node": ">=22" diff --git a/meeting-cost-meter/test/browser/harness.html b/meeting-cost-meter/test/browser/harness.html new file mode 100644 index 0000000..1802fc5 --- /dev/null +++ b/meeting-cost-meter/test/browser/harness.html @@ -0,0 +1,38 @@ + + + + + + Meeting Cost Meter — Visual QA + + + +
+

Meeting Cost Meter visual QA

+

Use these deterministic states to verify colors, red blinking, estimates, and PLN/USD switching.

+
+ + + + + + +
+
+ + + + + + + diff --git a/meeting-cost-meter/test/browser/harness.js b/meeting-cost-meter/test/browser/harness.js new file mode 100644 index 0000000..0570f6c --- /dev/null +++ b/meeting-cost-meter/test/browser/harness.js @@ -0,0 +1,63 @@ +(function () { + const MCM = window.__MCM; + const thresholds = { ...MCM.settings.ALERT_THRESHOLDS_USD }; + const plnPerUsd = 4; + let totalUsd = 0; + let displayCurrency = 'USD'; + let mode = 'normal'; + + const overlay = MCM.overlay.createOverlay({ + currency: displayCurrency, + onCurrencyChange: (currency) => { + displayCurrency = currency; + render(); + }, + }); + + function render() { + const total = MCM.currency.convert(totalUsd, 'USD', displayCurrency, plnPerUsd); + const rate = MCM.currency.convert( + mode === 'waiting' ? 0 : 2, + 'USD', + displayCurrency, + plnPerUsd, + ); + overlay.update({ + total, + currentRatePerMin: rate, + multiplier: mode === 'waiting' ? 1 : 1.25, + matched: 3, + totalPeople: mode === 'waiting' ? 0 : 4, + elapsedMin: 12.5, + currency: displayCurrency, + mock: false, + unmatched: [], + alertLevel: MCM.settings.alertLevel(totalUsd, thresholds), + alertThresholdsUsd: thresholds, + ratesAvailable: true, + started: mode !== 'waiting', + estimated: mode === 'estimate' ? 1 : 0, + fallback: 0, + unknown: mode === 'estimate' ? 1 : 0, + }); + } + + for (const button of document.querySelectorAll('[data-total]')) { + button.addEventListener('click', () => { + totalUsd = Number(button.dataset.total); + mode = 'normal'; + render(); + }); + } + document.getElementById('estimate').addEventListener('click', () => { + mode = 'estimate'; + render(); + }); + document.getElementById('waiting').addEventListener('click', () => { + totalUsd = 0; + mode = 'waiting'; + render(); + }); + + render(); +})(); diff --git a/meeting-cost-meter/test/browser/server.js b/meeting-cost-meter/test/browser/server.js new file mode 100644 index 0000000..5b6946d --- /dev/null +++ b/meeting-cost-meter/test/browser/server.js @@ -0,0 +1,36 @@ +// Minimal allowlisted server for visual QA. It deliberately cannot serve +// rates.json, ingest inputs, environment files, or any other workspace data. +import http from 'node:http'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(fileURLToPath(import.meta.url), '../../..'); +const FILES = new Map([ + ['/', ['test/browser/harness.html', 'text/html; charset=utf-8']], + ['/harness.js', ['test/browser/harness.js', 'text/javascript; charset=utf-8']], + ['/shared/settings.js', ['extension/shared/settings.js', 'text/javascript; charset=utf-8']], + ['/content/cost.js', ['extension/content/cost.js', 'text/javascript; charset=utf-8']], + ['/content/currency.js', ['extension/content/currency.js', 'text/javascript; charset=utf-8']], + ['/content/overlay.js', ['extension/content/overlay.js', 'text/javascript; charset=utf-8']], +]); + +const server = http.createServer((req, res) => { + const entry = FILES.get(new URL(req.url, 'http://localhost').pathname); + if (!entry) { + res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('Not found'); + return; + } + const [relativePath, contentType] = entry; + res.writeHead(200, { + 'Content-Type': contentType, + 'Cache-Control': 'no-store', + 'X-Content-Type-Options': 'nosniff', + }); + fs.createReadStream(path.join(ROOT, relativePath)).pipe(res); +}); + +server.listen(4173, '127.0.0.1', () => { + console.log('Meeting Cost visual harness: http://127.0.0.1:4173/'); +}); diff --git a/meeting-cost-meter/test/build-rates.test.js b/meeting-cost-meter/test/build-rates.test.js index 911ec2d..a19ae1b 100644 --- a/meeting-cost-meter/test/build-rates.test.js +++ b/meeting-cost-meter/test/build-rates.test.js @@ -3,7 +3,13 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { classifyEmployee, resolveBand, buildRates, roundSig } from '../ingest/build-rates.js'; +import { + classifyEmployee, + resolveBand, + buildRates, + roundSig, + normalizeContractType, +} from '../ingest/build-rates.js'; import { parseSalarySection } from '../ingest/parse-salary.js'; import config from '../ingest/config.json' with { type: 'json' }; @@ -57,7 +63,7 @@ test('roundSig keeps significant digits', () => { test('buildRates: matched gets a rate, unmatched is flagged, email baked as key', () => { const employees = [ - emp({ firstName: 'Ada', lastName: 'Lovelace', title: 'Lovelace Ada', email: 'ada.lovelace@example.test', roleTags: ['RN Dev', 'Senior 1'] }), + emp({ firstName: 'Ada', lastName: 'Lovelace', title: 'Lovelace Ada', email: 'ada.lovelace@example.test', roleTags: ['RN Dev', 'Senior 1'], contractType: 'B2B' }), emp({ firstName: 'Bob', lastName: 'NoBand', title: 'NoBand Bob', teams: ['Sales'], roleTags: ['Mystery Role'] }), ]; const res = buildRates({ employees, salaryTable: BANDS, config }); @@ -68,4 +74,39 @@ test('buildRates: matched gets a rate, unmatched is flagged, email baked as key' assert.equal(res.people[0].ratePerMinute, expected); assert.ok(res.people[0].keys.includes('ada lovelace')); assert.ok(res.people[0].keys.includes('ada.lovelace@example.test')); + assert.equal(res.people[0].estimated, false); + assert.equal(res.defaultRatePerMinute, null); +}); + +test('buildRates marks the configured average contract assumption as estimated', () => { + const res = buildRates({ + employees: [emp({ roleTags: ['RN Dev', 'Senior 1'] })], + salaryTable: BANDS, + config: { ...config, contractType: 'average' }, + }); + assert.equal(res.people.length, 1); + assert.equal(res.people[0].estimated, true); + assert.equal(res.estimatedCount, 1); +}); + +test('fallback rate exists only when an explicit company annual figure is configured', () => { + const noFallback = buildRates({ employees: [], salaryTable: BANDS, config }); + assert.equal(noFallback.defaultRatePerMinute, null); + + const withFallback = buildRates({ + employees: [], + salaryTable: BANDS, + config: { ...config, fallbackAnnualGross: 120_000 }, + }); + assert.equal( + withFallback.defaultRatePerMinute, + roundSig(120_000 / (config.hoursPerYear * 60), config.rateRoundingSignificantDigits), + ); +}); + +test('normalizeContractType accepts common B2B and employment labels', () => { + assert.equal(normalizeContractType('B2B'), 'B2B'); + assert.equal(normalizeContractType('Contract of Employment'), 'CoE'); + assert.equal(normalizeContractType('UoP'), 'CoE'); + assert.equal(normalizeContractType('unknown'), null); }); diff --git a/meeting-cost-meter/test/cost.test.js b/meeting-cost-meter/test/cost.test.js index e7dd77d..d25594e 100644 --- a/meeting-cost-meter/test/cost.test.js +++ b/meeting-cost-meter/test/cost.test.js @@ -20,13 +20,109 @@ test('multiplier is capped at maxMultiplier', () => { assert.equal(cost.multiplier(10000, S), 4); }); -test('compute: total = base * elapsed * multiplier', () => { - const r = cost.compute(10, 60, S); // base 10/min, 60 min, m=2.5 +test('compute integrates the escalating rate without repricing earlier minutes', () => { + const r = cost.compute(10, 60, S); assert.equal(r.multiplier, 2.5); assert.equal(r.currentRatePerMin, 25); - assert.equal(r.total, 10 * 60 * 2.5); + // 30 min × 1 plus 30 min under a linear 1→2.5 ramp. + assert.equal(r.total, 10 * (30 + 30 * 1.75)); }); test('compute: no negative totals', () => { assert.equal(cost.compute(10, -5, S).total, 0); }); + +test('integrateMultiplier includes the capped region exactly', () => { + const settings = { thresholdMin: 10, alphaPerMin: 0.1, maxMultiplier: 2 }; + // 10 min at 1 + a 10 min 1→2 ramp + 10 min capped at 2. + assert.equal(cost.integrateMultiplier(0, 30, settings), 10 + 15 + 20); +}); + +test('ledger keeps historical cost when the roster rate changes', () => { + const ledger = cost.createLedger(); + const flat = { thresholdMin: 999, alphaPerMin: 0, maxMultiplier: 1 }; + + ledger.update({ nowMs: 0, ratePerMinute: 10, hasPresence: true, settings: flat }); + let state = ledger.update({ + nowMs: 60_000, + ratePerMinute: 20, + hasPresence: true, + settings: flat, + }); + assert.equal(state.total, 10); + + state = ledger.update({ + nowMs: 120_000, + ratePerMinute: 20, + hasPresence: true, + settings: flat, + }); + assert.equal(state.total, 30); // 10 for minute one + 20 for minute two +}); + +test('ledger waits for a roster, pauses at zero presence, and never resets', () => { + const ledger = cost.createLedger(); + const flat = { thresholdMin: 999, alphaPerMin: 0, maxMultiplier: 1 }; + + let state = ledger.update({ nowMs: 60_000, ratePerMinute: 5, hasPresence: false, settings: flat }); + assert.equal(state.started, false); + assert.equal(state.elapsedMin, 0); + + ledger.update({ nowMs: 120_000, ratePerMinute: 5, hasPresence: true, settings: flat }); + state = ledger.update({ nowMs: 180_000, ratePerMinute: 0, hasPresence: false, settings: flat }); + assert.equal(state.total, 5); + state = ledger.update({ nowMs: 240_000, ratePerMinute: 0, hasPresence: false, settings: flat }); + assert.equal(state.total, 5); + assert.equal(state.elapsedMin, 2); + + ledger.update({ nowMs: 300_000, ratePerMinute: 2, hasPresence: true, settings: flat }); + state = ledger.update({ nowMs: 360_000, ratePerMinute: 2, hasPresence: true, settings: flat }); + assert.equal(state.total, 7); +}); + +test('ledger snapshot restores the accumulated total', () => { + const flat = { thresholdMin: 999, alphaPerMin: 0, maxMultiplier: 1 }; + const first = cost.createLedger(); + first.update({ nowMs: 0, ratePerMinute: 3, hasPresence: true, settings: flat }); + first.update({ nowMs: 120_000, ratePerMinute: 3, hasPresence: true, settings: flat }); + + const restored = cost.createLedger(first.snapshot()); + const state = restored.update({ + nowMs: 180_000, + ratePerMinute: 3, + hasPresence: true, + settings: flat, + }); + assert.equal(state.total, 9); + assert.equal(state.elapsedMin, 3); +}); + +test('ledger rebase resumes without charging or timing a paused gap', () => { + const flat = { thresholdMin: 999, alphaPerMin: 0, maxMultiplier: 1 }; + const ledger = cost.createLedger(); + ledger.update({ nowMs: 0, ratePerMinute: 4, hasPresence: true, settings: flat }); + let state = ledger.update({ + nowMs: 60_000, + ratePerMinute: 0, + hasPresence: false, + settings: flat, + }); + assert.equal(state.total, 4); + assert.equal(state.elapsedMin, 1); + + ledger.rebase(10 * 60_000, flat); + ledger.update({ + nowMs: 10 * 60_000, + ratePerMinute: 4, + hasPresence: true, + settings: flat, + }); + state = ledger.update({ + nowMs: 11 * 60_000, + ratePerMinute: 4, + hasPresence: true, + settings: flat, + }); + assert.equal(state.total, 8); + assert.equal(state.elapsedMin, 2); +}); diff --git a/meeting-cost-meter/test/csv.test.js b/meeting-cost-meter/test/csv.test.js index e798d5f..616dd00 100644 --- a/meeting-cost-meter/test/csv.test.js +++ b/meeting-cost-meter/test/csv.test.js @@ -54,3 +54,13 @@ test('csvToEmployees keeps email-only rows (no name), drops empty rows', () => { test('csvToEmployees throws a clear error when a required column is missing', () => { assert.throws(() => csvToEmployees('Nope,Header\n1,2', COLUMNS), /missing the "Name" column/); }); + +test('csvToEmployees reads an optional per-person contract type column', () => { + const columns = { ...COLUMNS, contractType: 'Contract type' }; + const csv = [ + `${HEADER},Contract type`, + 'Jamie,Example,jamie.example@example.test,Technical Delivery,"Senior 1, RN Dev",Demo Project,Demo Manager,Demo HRBP,jamie-demo,CoE', + ].join('\n'); + const [emp] = csvToEmployees(csv, columns); + assert.equal(emp.contractType, 'CoE'); +}); diff --git a/meeting-cost-meter/test/currency-settings.test.js b/meeting-cost-meter/test/currency-settings.test.js new file mode 100644 index 0000000..504ebf2 --- /dev/null +++ b/meeting-cost-meter/test/currency-settings.test.js @@ -0,0 +1,35 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { loadMcm } from './load-mcm.js'; + +const { currency, settings } = loadMcm( + 'extension/shared/settings.js', + 'extension/content/currency.js', +); + +test('PLN/USD conversion uses PLN per USD in the correct direction', () => { + assert.equal(currency.convert(40, 'PLN', 'USD', 4), 10); + assert.equal(currency.convert(10, 'USD', 'PLN', 4), 40); + assert.equal(currency.convert(12, 'PLN', 'PLN', null), 12); +}); + +test('cross-currency conversion refuses to guess without an official quote', () => { + assert.equal(currency.convert(40, 'PLN', 'USD', null), null); + assert.equal(currency.convert(40, 'EUR', 'USD', 4), null); +}); + +test('alert ranges use strict USD thresholds and red is the highest level', () => { + const ranges = { yellow: 10, orange: 20, red: 30 }; + assert.equal(settings.alertLevel(10, ranges), 'green'); + assert.equal(settings.alertLevel(10.01, ranges), 'yellow'); + assert.equal(settings.alertLevel(20.01, ranges), 'orange'); + assert.equal(settings.alertLevel(30.01, ranges), 'red'); + assert.equal(settings.alertLevel(null, ranges), 'unavailable'); +}); + +test('invalid or descending alert thresholds are normalized in one config', () => { + assert.deepEqual( + { ...settings.alertThresholds({ yellow: 12, orange: 5, red: 2 }) }, + { yellow: 12, orange: 12, red: 12 }, + ); +}); diff --git a/meeting-cost-meter/test/match.test.js b/meeting-cost-meter/test/match.test.js index 25142fe..a635eed 100644 --- a/meeting-cost-meter/test/match.test.js +++ b/meeting-cost-meter/test/match.test.js @@ -39,4 +39,31 @@ test('unmatched attendees fall back to the default rate', () => { assert.equal(r.total, 2); assert.equal(Math.round(r.ratePerMinuteTotal * 100) / 100, 3.0); // 1.9 + 1.1 assert.equal(r.details.find((d) => d.name === 'Some External Guest').isMatch, false); + assert.equal(r.fallback, 1); + assert.equal(r.estimated, 1); +}); + +test('unknown attendees are excluded and flagged when no explicit fallback exists', () => { + const rates = { defaultRatePerMinute: null, people: [person('Ada Lovelace', 1.9)] }; + const index = match.buildIndex(rates); + const r = match.matchParticipants(['Ada Lovelace', 'Some External Guest'], rates, index); + assert.equal(r.ratePerMinuteTotal, 1.9); + assert.equal(r.unknown, 1); + assert.equal(r.fallback, 0); + assert.equal(r.details[1].rateKnown, false); +}); + +test('matched contract-assumption rates are counted as estimates', () => { + const rates = { + defaultRatePerMinute: null, + people: [{ ...person('Ada Lovelace', 1.9), estimated: true }], + }; + const r = match.matchParticipants( + ['Ada Lovelace'], + rates, + match.buildIndex(rates), + ); + assert.equal(r.matched, 1); + assert.equal(r.estimated, 1); + assert.equal(r.fallback, 0); }); diff --git a/meeting-cost-meter/test/scrape-route.test.js b/meeting-cost-meter/test/scrape-route.test.js new file mode 100644 index 0000000..ffdba1a --- /dev/null +++ b/meeting-cost-meter/test/scrape-route.test.js @@ -0,0 +1,44 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { loadMcm } from './load-mcm.js'; + +const { scrape } = loadMcm('extension/content/scrape.js'); + +test('only Meet conference-code paths are treated as meeting pages', () => { + assert.equal(scrape.isMeetingPath('/abc-defg-hij'), true); + assert.equal(scrape.isMeetingPath('/ABC-DEFG-HIJ/'), true); + + for (const path of ['/', '/landing', '/new', '/lookup/team-sync', '/settings']) { + assert.equal(scrape.isMeetingPath(path), false, path); + } +}); + +test('meetingCodeFromPath returns a normalized code or null', () => { + assert.equal(scrape.meetingCodeFromPath('/ABC-DEFG-HIJ'), 'abc-defg-hij'); + assert.equal(scrape.meetingCodeFromPath('/landing'), null); + assert.equal(scrape.meetingCodeFromPath(''), null); +}); + +test('post-meeting headings are recognized without matching active-call text', () => { + for (const text of [ + 'You left the meeting', + 'You have left the call', + "You've left the meeting", + 'The meeting has ended', + 'Call ended', + ]) { + assert.equal(scrape.isMeetingEndedText(text), true, text); + } + for (const text of ['Ready to join?', 'People in this call', 'Leave call', 'Meeting details']) { + assert.equal(scrape.isMeetingEndedText(text), false, text); + } +}); + +test('Rejoin plus Return to home screen is a post-meeting fallback signal', () => { + assert.equal( + scrape.hasPostMeetingControls(['Submit feedback', 'Rejoin', 'Return to home screen']), + true, + ); + assert.equal(scrape.hasPostMeetingControls(['Join now', 'Cancel']), false); + assert.equal(scrape.hasPostMeetingControls(['Rejoin']), false); +});