diff --git a/README.MD b/README.MD index 8fe0d72f..c9f06d55 100644 --- a/README.MD +++ b/README.MD @@ -7,9 +7,10 @@ We use the raw data and try not to process the data to much. Just give some filt ## Demo
+Desktop: +Mobile: -
diff --git a/docs/imgs/SOSalary-screenshot1.png b/docs/imgs/SOSalary-screenshot1.png index 61f5875a..99798266 100644 Binary files a/docs/imgs/SOSalary-screenshot1.png and b/docs/imgs/SOSalary-screenshot1.png differ diff --git a/docs/imgs/SOSalary-screenshot2.png b/docs/imgs/SOSalary-screenshot2.png index 3e2f6218..f4da7d83 100644 Binary files a/docs/imgs/SOSalary-screenshot2.png and b/docs/imgs/SOSalary-screenshot2.png differ diff --git a/docs/imgs/SOSalary-screenshot3.png b/docs/imgs/SOSalary-screenshot3.png deleted file mode 100644 index 1b1e92bd..00000000 Binary files a/docs/imgs/SOSalary-screenshot3.png and /dev/null differ diff --git a/src/components/app.tsx b/src/components/app.tsx index b575188f..3ec00954 100644 --- a/src/components/app.tsx +++ b/src/components/app.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { Profiler } from 'react' import entryStore from '../stores/entryStore' import BoxPlot from './boxplot' import BarPlot from './barplot' @@ -14,9 +14,12 @@ import SurveyEntry from '../model/surveyEntry' import ConsideredDataTable from './consideredDataTable' import CurrencyConversionTable from './currencyConversionTable' import SalaryEstimator from './salaryEstimator' +import HistogramTab from './histogramTab' import translationStore from '../stores/translationStore' import controlStore from '../stores/controlStore' import { uiStore } from '../stores/uiStore' +import { mark, measure } from '../utils/perfLogger' +import { trackRender, startFPSMonitor, logAllRenderStats } from '../utils/reactPerfMonitor' interface AppState { tabIndex: number @@ -45,6 +48,12 @@ class App extends React.Component, AppState> { componentDidMount(): void { uiStore.initMobileDetection() + startFPSMonitor(1000) + console.log('[ReactPerf] FPS monitor started. Stats will be logged after 60s or on manual trigger.') + } + + componentWillUnmount(): void { + logAllRenderStats() } render(): JSX.Element { @@ -107,6 +116,7 @@ class App extends React.Component, AppState> { }}> + @@ -114,11 +124,24 @@ class App extends React.Component, AppState> {
- {this.state.tabIndex === 0 ? : - this.state.tabIndex === 1 ? : - this.state.tabIndex === 2 ? : - this.state.tabIndex === 3 ? : - } + + {this.state.tabIndex === 0 ? : null} + + + {this.state.tabIndex === 1 ? : null} + + + {this.state.tabIndex === 2 ? : null} + + + {this.state.tabIndex === 3 ? : null} + + + {this.state.tabIndex === 4 ? : null} + + + {this.state.tabIndex === 5 ? : null} +
@@ -153,6 +176,7 @@ class App extends React.Component, AppState> { }}> + @@ -160,11 +184,24 @@ class App extends React.Component, AppState> {
- {this.state.tabIndex === 0 ? : - this.state.tabIndex === 1 ? : - this.state.tabIndex === 2 ? : - this.state.tabIndex === 3 ? : - } + + {this.state.tabIndex === 0 ? : null} + + + {this.state.tabIndex === 1 ? : null} + + + {this.state.tabIndex === 2 ? : null} + + + {this.state.tabIndex === 3 ? : null} + + + {this.state.tabIndex === 4 ? : null} + + + {this.state.tabIndex === 5 ? : null} +
@@ -190,6 +227,7 @@ class App extends React.Component, AppState> { { if (value) { controlStore.setLanguage(value as 'en' | 'de') @@ -210,7 +248,11 @@ class App extends React.Component, AppState> { } private changeTab = (event: React.ChangeEvent, newValue: number | string): void => { - this.setState({tabIndex: Number(newValue)}) + mark('app-change-tab-start') + this.setState({tabIndex: Number(newValue)}, () => { + const duration = measure('app-change-tab', 'app-change-tab-start') + console.log(`[PERF] Tab change: ${duration.toFixed(0)}ms, tab=${Number(newValue)}`) + }) } private toggleControls(): void { diff --git a/src/components/appBar.tsx b/src/components/appBar.tsx index 6a2bd789..af0bce3f 100644 --- a/src/components/appBar.tsx +++ b/src/components/appBar.tsx @@ -19,6 +19,7 @@ export const LanguageSelector: React.FC = () => { { if (value) { controlStore.setLanguage(value as 'en' | 'de') @@ -66,7 +67,7 @@ class MenuAppBar extends React.Component { - + {t.title} diff --git a/src/components/barplot.tsx b/src/components/barplot.tsx index a2b2ff33..f8b506b0 100644 --- a/src/components/barplot.tsx +++ b/src/components/barplot.tsx @@ -62,7 +62,7 @@ class BarPlot extends React.Component { const invalidNumbers = [yearEntrySet.invalidEntryCount] const matchingFilterNumbers = [filteredYearList.length] -return [ + return [ { y: matchingFilterNumbers, name: 'matching filter', diff --git a/src/components/consideredDataTable.tsx b/src/components/consideredDataTable.tsx index 7ccba4dc..82a5a9e5 100644 --- a/src/components/consideredDataTable.tsx +++ b/src/components/consideredDataTable.tsx @@ -127,19 +127,19 @@ const ConsideredDataTable = observer(() => { } ] -const renderMappedTable = (): JSX.Element => { - const rowsWithId = mappedData.map((entry, index) => { - const rawSalary = entry._salary - const entryCurrencyRatio = entryStore.currencyValues?.getRatioByCode(entry.currency) ?? 1 - const usdSalary = entry.salaryIsUsd ? rawSalary : rawSalary / entryCurrencyRatio - const targetCurrencyRatio = entryStore.currencyValues?.getRatioByCode(selectedCurrency) ?? 1 - return { - ...entry, - id: `mapped-${index}`, - convertedSalary: usdSalary * targetCurrencyRatio, - salary: rawSalary - } - }) + const renderMappedTable = (): JSX.Element => { + const rowsWithId = mappedData.map((entry, index) => { + const rawSalary = entry._salary + const entryCurrencyRatio = entryStore.currencyValues?.getRatioByCode(entry.currency) ?? 1 + const usdSalary = entry.salaryIsUsd ? rawSalary : rawSalary / entryCurrencyRatio + const targetCurrencyRatio = entryStore.currencyValues?.getRatioByCode(selectedCurrency) ?? 1 + return { + ...entry, + id: `mapped-${index}`, + convertedSalary: usdSalary * targetCurrencyRatio, + salary: rawSalary + } + }) return (
diff --git a/src/components/controlPane.tsx b/src/components/controlPane.tsx index d14a09cb..f19b3b82 100644 --- a/src/components/controlPane.tsx +++ b/src/components/controlPane.tsx @@ -2,6 +2,7 @@ import React from 'react' import { Checkbox, FormGroup, FormControl, Slider, Box, TextField, Typography, IconButton } from '@mui/material' import MoreVertIcon from '@mui/icons-material/MoreVert' import Menu from '@mui/material/Menu' +import MenuIcon from '@mui/icons-material/Menu' import MenuItem from '@mui/material/MenuItem' import { inject, observer } from 'mobx-react' import { injectClause, StoreProps } from '../stores/storeHelper' @@ -91,11 +92,15 @@ class ControlPane extends React.Component { get headerWithMenu(): JSX.Element { const t = translationStore.t + const isMobile = uiStore.isMobileView return (
{t.filters} + + + @@ -104,6 +109,10 @@ class ControlPane extends React.Component { ) } + closeMenu = (event: React.MouseEvent): void => { + uiStore.setControlPaneOpen(false) + } + handleMenuClick = (event: React.MouseEvent): void => { this.setState({ anchorEl: event.currentTarget }) } @@ -419,13 +428,33 @@ class ControlPane extends React.Component { get salaryFilter(): JSX.Element { const t = translationStore.t + const cs = this.props.controlStore! + const salaryMin = cs.salaryThresholdMin + const salaryMax = cs.salaryThresholdMax + + const slider = ( + `${(value / 1000).toLocaleString()}k`} + disableSwap + color="secondary" + /> + ) + return (
{t.salaryFilterHint}} + controlComponent={slider} isEnabled={this.props.controlStore!.enableSalaryFilter} enable={(event, value) => { this.props.controlStore!.setEnableSalaryFilter(value)}}> + {t.salaryFilterHint}
) } @@ -439,6 +468,14 @@ class ControlPane extends React.Component { this.props.controlStore!.setCompanySizeFromMax(value) } + handleSalaryThresholdChange = (_event: Event | React.SyntheticEvent, value: number | number[]): void => { + const cs = this.props.controlStore! + if (Array.isArray(value) && value.length === 2) { + cs.setSalaryThresholdMin(value[0]) + cs.setSalaryThresholdMax(value[1]) + } + } + handleChangesForCountries(event: React.ChangeEvent, value: { key: string, label: string }[]): void { this.props.controlStore!.setCountries(value.map(v => v.key)) } diff --git a/src/components/histogramTab.tsx b/src/components/histogramTab.tsx new file mode 100644 index 00000000..5026e215 --- /dev/null +++ b/src/components/histogramTab.tsx @@ -0,0 +1,101 @@ +import React from 'react' +import { observer } from 'mobx-react' +import Plot from 'react-plotly.js' +import { Data, Layout } from 'plotly.js' +import { uiStore } from '../stores/uiStore' +import controlStore from '../stores/controlStore' +import entryStore from '../stores/entryStore' +import translationStore from '../stores/translationStore' + +const HistogramTab = observer(() => { + const t = translationStore.t + const selectedYearNum = parseInt(controlStore.selectedYear, 10) + const selectedYearData = entryStore.parsedDataByYear[selectedYearNum] + const selectedCurrency = controlStore.selectedCurrency + const filteredData = uiStore.filteredData[selectedYearNum] ?? [] + const mappedData = selectedYearData?.resultSet ?? [] + + const isLoading = mappedData.length === 0 + + if (isLoading) { + return ( +
+

{t.noDataAvailable || t.salaryTab}...
Loading large dataset, please wait.

+
+ ) + } + + if (filteredData.length === 0) { + return ( +
+

No data available for histogram

+
+ ) + } + + const currencyValues = entryStore.currencyValues + const salaries = filteredData.map(entry => { + const rawSalary = entry._salary + const entryCurrencyRatio = currencyValues?.getRatioByCode(entry.currency) ?? 1 + const usdSalary = entry.salaryIsUsd ? rawSalary : rawSalary / entryCurrencyRatio + const targetCurrencyRatio = currencyValues?.getRatioByCode(selectedCurrency) ?? 1 + return usdSalary * targetCurrencyRatio + }) + + const binSize = 10000 + const maxSalary = Math.max(...salaries) + const maxBin = Math.max(Math.ceil(maxSalary / binSize) * binSize, binSize * 2) + const numBins = maxBin / binSize + + const bins: number[] = new Array(numBins).fill(0) + salaries.forEach(salary => { + const binIndex = Math.min(Math.floor(salary / binSize), bins.length - 1) + if (binIndex >= 0 && binIndex < bins.length) { + bins[binIndex]++ + } + }) + + const xLabels = bins.map((_, i) => { + const from = i * binSize + const to = (i + 1) * binSize + return `${(from / 1000)}k - ${(to / 1000)}k` + }) + + const histogramData: Data[] = [{ + x: xLabels, + y: bins, + type: 'bar', + marker: { color: '#F48024' } + }] + + const histogramLayout: Partial = { + title: { text: 'Salary Distribution' }, + xaxis: { + title: { text: `Salary (${selectedCurrency})` }, + tickangle: -45 + }, + yaxis: { + title: { text: 'Number of Salaries' } + }, + paper_bgcolor: '#FF000000', + plot_bgcolor: '#FF000000', + margin: { + l: 60, + r: 30, + t: 60, + b: 120 + } + } + + return ( +
+ +
+ ) +}) + +export default HistogramTab diff --git a/src/components/salaryEstimator.tsx b/src/components/salaryEstimator.tsx index d142f3bf..c67404b9 100644 --- a/src/components/salaryEstimator.tsx +++ b/src/components/salaryEstimator.tsx @@ -69,50 +69,50 @@ class SalaryEstimator extends React.Component { return usdSalary * targetCurrencyRatio } -private _isSalaryInRangeUSD(entry: SurveyEntry): boolean { - const currencyValues = this.props.entryStore!.currencyValues - const rawSalary = entry._salary - const usdValue = entry.salaryIsUsd ? rawSalary : rawSalary / (currencyValues?.getRatioByCode(entry.currency) ?? 1) - return usdValue >= 10000 && usdValue <= 250000 -} + private _isSalaryInRangeUSD(entry: SurveyEntry): boolean { + const currencyValues = this.props.entryStore!.currencyValues + const rawSalary = entry._salary + const usdValue = entry.salaryIsUsd ? rawSalary : rawSalary / (currencyValues?.getRatioByCode(entry.currency) ?? 1) + return usdValue >= 10000 && usdValue <= 250000 + } private allEntries(): SurveyEntry[] { const year = parseInt(this.props.entryStore!.selectedYear, 10) return this.props.entryStore!.parsedDataByYear[year]?.resultSet ?? [] } -private matchesFilter(entry: SurveyEntry, opts: { exp: [number, number]; abilities: string[]; countries: string[]; degrees: string[]; companyMin: number | null; companyMax: number | null; genders: Gender[] }): boolean { - const exp = entry.expirienceInYears - if (exp && !(exp.min >= opts.exp[0] && exp.max <= opts.exp[1])) return false - if (opts.abilities.length > 0 && !(entry.abilities ?? []).some(a => opts.abilities.includes(a))) return false - if (opts.countries.length > 0 && !opts.countries.includes(entry.country!)) return false - if (opts.degrees.length > 0 && !opts.degrees.includes(entry.highestDegree!)) return false - if (opts.genders.length > 0 && !opts.genders.includes(entry.gender!)) return false - const cs = entry.companySize - if (cs) { - if (opts.companyMin !== null && opts.companyMin !== undefined && cs.max < opts.companyMin) return false - if (opts.companyMax !== null && opts.companyMax !== undefined && cs.min > opts.companyMax) return false + private matchesFilter(entry: SurveyEntry, opts: { exp: [number, number]; abilities: string[]; countries: string[]; degrees: string[]; companyMin: number | null; companyMax: number | null; genders: Gender[] }): boolean { + const exp = entry.expirienceInYears + if (exp && !(exp.min >= opts.exp[0] && exp.max <= opts.exp[1])) return false + if (opts.abilities.length > 0 && !(entry.abilities ?? []).some(a => opts.abilities.includes(a))) return false + if (opts.countries.length > 0 && !opts.countries.includes(entry.country!)) return false + if (opts.degrees.length > 0 && !opts.degrees.includes(entry.highestDegree!)) return false + if (opts.genders.length > 0 && !opts.genders.includes(entry.gender!)) return false + const cs = entry.companySize + if (cs) { + if (opts.companyMin !== null && opts.companyMin !== undefined && cs.max < opts.companyMin) return false + if (opts.companyMax !== null && opts.companyMax !== undefined && cs.min > opts.companyMax) return false + } + return true } - return true -} -private computeStats(entries: SurveyEntry[]): Stats | null { - if (entries.length === 0) return null - const salaries = entries.map(e => this.convertSalary(e)) - const sorted = [...salaries].sort((a, b) => a - b) - const median = sorted[Math.floor(sorted.length / 2)] - const mean = salaries.reduce((a, b) => a + b, 0) / salaries.length - const variance = salaries.reduce((a, b) => a + Math.pow(b - mean, 2), 0) / salaries.length - const std = Math.sqrt(variance) - return { - count: salaries.length, - median, - mean, - std, - lower: mean - std, - upper: mean + std + private computeStats(entries: SurveyEntry[]): Stats | null { + if (entries.length === 0) return null + const salaries = entries.map(e => this.convertSalary(e)) + const sorted = [...salaries].sort((a, b) => a - b) + const median = sorted[Math.floor(sorted.length / 2)] + const mean = salaries.reduce((a, b) => a + b, 0) / salaries.length + const variance = salaries.reduce((a, b) => a + Math.pow(b - mean, 2), 0) / salaries.length + const std = Math.sqrt(variance) + return { + count: salaries.length, + median, + mean, + std, + lower: mean - std, + upper: mean + std + } } -} private currentFilter(): { exp: [number, number], @@ -159,28 +159,28 @@ private computeStats(entries: SurveyEntry[]): Stats | null { if (base.abilities.length > 0) { const without = { ...base, abilities: [] as string[] } result.push({ - label: translationStore.t.estimatorAbilities + ': -', + label: translationStore.t.estimatorAbilities + ':', stats: this.computeStats(all.filter(e => this.matchesFilter(e, without))) }) } if (base.countries.length > 0) { const without = { ...base, countries: [] as string[] } result.push({ - label: translationStore.t.estimatorCountries + ': -', + label: translationStore.t.estimatorCountries + ':', stats: this.computeStats(all.filter(e => this.matchesFilter(e, without))) }) } if (base.degrees.length > 0) { const without = { ...base, degrees: [] as string[] } result.push({ - label: translationStore.t.estimatorDegree + ': -', + label: translationStore.t.estimatorDegree + ':', stats: this.computeStats(all.filter(e => this.matchesFilter(e, without))) }) } if (base.genders.length > 0) { const without = { ...base, genders: [] as Gender[] } result.push({ - label: translationStore.t.estimatorGender + ': -', + label: translationStore.t.estimatorGender + ':', stats: this.computeStats(all.filter(e => this.matchesFilter(e, without))) }) } @@ -375,6 +375,11 @@ private computeStats(entries: SurveyEntry[]): Stats | null { {t.estimatorDataQuality}: {quality.text} + {stats && stats.std > stats.mean / 2 && ( + + {t.estimatorHighSalaryWarning} + + )} )} @@ -406,7 +411,7 @@ private computeStats(entries: SurveyEntry[]): Stats | null { {s.label} {s.stats ? fmt(s.stats.median) : '-'} - {s.stats ? fmt(s.stats.median - baseMedian) : '-'} + {s.stats ? fmt(baseMedian - s.stats.median) : '-'} ))} diff --git a/src/components/statisticsModal.tsx b/src/components/statisticsModal.tsx index 5c038221..ac69fc8e 100644 --- a/src/components/statisticsModal.tsx +++ b/src/components/statisticsModal.tsx @@ -9,6 +9,8 @@ import Typography from '@mui/material/Typography' import Box from '@mui/material/Box' import translationStore from '../stores/translationStore' +import { observer } from 'mobx-react' + interface IStatisticsModalProps { open: boolean onClose: () => void @@ -19,7 +21,7 @@ interface IStatisticsModalState { open: boolean } -export default class StatisticsModal extends React.Component { +class StatisticsModal extends React.Component { constructor(props: IStatisticsModalProps) { super(props) this.state = { @@ -93,3 +95,5 @@ function Section({ title, text, noMargin }: { title: string; text: string; noMar ) } + +export default observer(StatisticsModal) diff --git a/src/model/controlState.ts b/src/model/controlState.ts index a50e4c3a..a86fd3f9 100644 --- a/src/model/controlState.ts +++ b/src/model/controlState.ts @@ -21,6 +21,8 @@ export default class ControlState { countriesFilterActive = false enableSalaryFilter = true + salaryThresholdMin = 10000 + salaryThresholdMax = 250000 constructor (partial: ControlState) { Object.assign(this, partial) @@ -38,13 +40,13 @@ export default class ControlState { private filterBySalary(entry: SurveyEntry): boolean { if (!this.enableSalaryFilter) { - return true; + return true } const rawSalary = entry._salary const entryStore = SurveyEntry.entryStore const currencyValues = entryStore?.currencyValues const usdSalary = entry.salaryIsUsd ? rawSalary : rawSalary / (currencyValues?.getRatioByCode(entry.currency) ?? 1) - return usdSalary >= 10000 && usdSalary <= 250000; + return usdSalary >= this.salaryThresholdMin && usdSalary <= this.salaryThresholdMax } private filterByAbilities(entry: SurveyEntry): boolean { diff --git a/src/services/stackOverflowCsvReader.ts b/src/services/stackOverflowCsvReader.ts index ee3df611..9dbbef9e 100644 --- a/src/services/stackOverflowCsvReader.ts +++ b/src/services/stackOverflowCsvReader.ts @@ -1,10 +1,12 @@ import Papa, { ParseStepResult } from 'papaparse' -import { transaction } from 'mobx' import { CsvRowMapper } from '../mapper/CsvRowMapper' import { CHUNK_COUNT_PER_YEAR } from '../model/constantMetaData' import CsvRow from '../model/csvRow' import ResultSetForYear from '../model/resultSetForYear' import SurveyEntry from '../model/surveyEntry' +import { mark, measure, logLongTasks } from '../utils/perfLogger' + +logLongTasks() export default class StackOverflowCsvReader { @@ -12,88 +14,118 @@ export default class StackOverflowCsvReader { static readonly BASIC_CONFIG ={ download: true, - worker: false, // Using worker=true for better performance with large files - /* -Uncaught DataCloneError: Failed to execute 'postMessage' on 'Worker': function (header, index) { - const UNNAMED_COLUMN_PREFIX = 'columnIndex-'; - if (header =...... } could not be cloned. - */ - + worker: true, delimiter: ',', - header: true, - transformHeader: function(header: string, index: number): string { - const UNNAMED_COLUMN_PREFIX = 'columnIndex-' - if (header == null || header === '') { - return UNNAMED_COLUMN_PREFIX + index - } - return header - } + header: true } - startWorkerForYear ( + async startWorkerForYear ( resultsetForYear: ResultSetForYear, consumer: (row: Papa.ParseStepResult) => void, - completed: (rawRows: CsvRow[]) => void, + completed: (rawRows: CsvRow[], validRows: SurveyEntry[], invalidCount: number, totalCount: number) => void, onValidEntry?: (entry: SurveyEntry) => void - ): void { - let validRows: SurveyEntry[] = [] - let invalidCount = 0 - let totalCount = 0 - let rawRows: CsvRow[] = [] - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const config = { - ...StackOverflowCsvReader.BASIC_CONFIG, - step: (row: Papa.ParseStepResult) => { - const mapper = new CsvRowMapper(resultsetForYear.year) - const rowEntry = mapper.map(row) - if (rowEntry.isValid) { - validRows.push(rowEntry) - onValidEntry?.(rowEntry) - } else { - invalidCount++ - } - totalCount++ - // Store raw CSV row for raw data table - rawRows.push(row.data) - consumer(row) - }, - complete: () => { - // Batch update observables once per chunk within a transaction - transaction(() => { - // Replace array entirely to avoid multiple MobX notifications - resultsetForYear.resultSet = [...resultsetForYear.resultSet, ...validRows] - resultsetForYear.invalidEntryCount += invalidCount - resultsetForYear.overallEntryCount += totalCount - }) - const chunkRawRows = rawRows - validRows = [] - rawRows = [] - invalidCount = 0 - totalCount = 0 - - this.handleNextChunk(resultsetForYear, config) - // Hand the raw CSV rows of this chunk to the caller for storage - completed(chunkRawRows) - } - } as Papa.ParseRemoteConfig + ): Promise { const year = resultsetForYear.year.toString() const chunkCountForYear = CHUNK_COUNT_PER_YEAR[year] resultsetForYear.chunksParsed = 0 resultsetForYear.chunksAvailable = chunkCountForYear - this.handleNextChunk(resultsetForYear, config) + await this.handleNextChunk(resultsetForYear, consumer, completed, onValidEntry) } - private handleNextChunk (resultsetForYear: ResultSetForYear, config: Papa.ParseRemoteConfig): void { + private async handleNextChunk ( + resultsetForYear: ResultSetForYear, + consumer: (row: Papa.ParseStepResult) => void, + completed: (rawRows: CsvRow[], validRows: SurveyEntry[], invalidCount: number, totalCount: number) => void, + onValidEntry?: (entry: SurveyEntry) => void + ): Promise { resultsetForYear.chunksParsed++ if (resultsetForYear.chunksParsed > resultsetForYear.chunksAvailable) { - // All chunks processed, nothing more to do return } const fileName = this.generateFileName(resultsetForYear.year.toString(), resultsetForYear.chunksParsed) const fileUrl = this.baseUrl + '/' + fileName - Papa.parse(fileUrl, config) + + const validRows: SurveyEntry[] = [] + let invalidCount = 0 + let totalCount = 0 + const rawRows: CsvRow[] = [] + let normalizedFields: string[] | null = null + + const chunkStart = performance.now() + mark(`chunk-${fileName}-start`) + + await new Promise((resolve, reject) => { + Papa.parse(fileUrl, { + ...StackOverflowCsvReader.BASIC_CONFIG, + complete: (results) => { + const parseEnd = performance.now() + measure('chunk-parse', `chunk-${fileName}-start`, `chunk-${fileName}-parse-end`) + mark(`chunk-${fileName}-parse-end`) + + if (!normalizedFields && results.meta && results.meta.fields) { + normalizedFields = results.meta.fields.map((field, index) => + field == null || field === '' ? `${StackOverflowCsvReader.UNNAMED_COLUMN_PREFIX}${index}` : field + ) + } + + const mapper = new CsvRowMapper(resultsetForYear.year) + const data = results.data as CsvRow[] + for (const row of data) { + const normalizedData: CsvRow = {} + if (normalizedFields && results.meta && results.meta.fields) { + for (let i = 0; i < results.meta.fields.length; i++) { + const originalKey = results.meta.fields[i] + const normalizedKey = normalizedFields[i] + if (normalizedKey !== undefined && originalKey !== undefined) { + normalizedData[normalizedKey] = row[originalKey] + } + } + } + + const normalizedRow = { ...row, ...normalizedData } as CsvRow + const rowEntry = mapper.map({ data: normalizedRow, meta: { fields: normalizedFields || Object.keys(normalizedData) } } as Papa.ParseStepResult) + if (rowEntry.isValid) { + validRows.push(rowEntry) + onValidEntry?.(rowEntry) + } else { + invalidCount++ + } + totalCount++ + rawRows.push(row) + } + + const mapEnd = performance.now() + measure('chunk-map', `chunk-${fileName}-parse-end`, `chunk-${fileName}-map-end`) + mark(`chunk-${fileName}-map-end`) + + completed(rawRows, validRows, invalidCount, totalCount) + + const txEnd = performance.now() + measure('chunk-mobx-tx', `chunk-${fileName}-map-end`, `chunk-${fileName}-tx-end`) + mark(`chunk-${fileName}-tx-end`) + + const chunkEnd = performance.now() + measure('chunk-total', `chunk-${fileName}-start`, `chunk-${fileName}-total-end`) + mark(`chunk-${fileName}-total-end`) + console.log( + `[PERF] ${fileName}: total=${(chunkEnd - chunkStart).toFixed(0)}ms ` + + `parse=${(parseEnd - chunkStart).toFixed(0)}ms ` + + `map=${(mapEnd - parseEnd).toFixed(0)}ms ` + + `tx=${(txEnd - mapEnd).toFixed(0)}ms ` + + `rows=${totalCount}` + ) + + resolve() + }, + error: (err: unknown) => { + console.error('Papa parse error for chunk', fileName, err) + reject(err) + } + } as Papa.ParseRemoteConfig) + }) + + await this.handleNextChunk(resultsetForYear, consumer, completed, onValidEntry) } private generateFileName(year: string, chunk: number): string { diff --git a/src/stores/controlStore.ts b/src/stores/controlStore.ts index c704b28e..aa499caf 100644 --- a/src/stores/controlStore.ts +++ b/src/stores/controlStore.ts @@ -25,6 +25,8 @@ export class ControlStore { countriesFilterActive = false enableSalaryFilter = true + salaryThresholdMin = 10000 + salaryThresholdMax = 250000 pendingState: any = null @@ -55,6 +57,8 @@ export class ControlStore { const degreeFilterActive = this.degreeFilterActive const countriesFilterActive = this.countriesFilterActive const enableSalaryFilter = this.enableSalaryFilter + const salaryThresholdMin = this.salaryThresholdMin + const salaryThresholdMax = this.salaryThresholdMax return new ControlState({ selectedYear, @@ -71,7 +75,9 @@ export class ControlStore { companySizeFilterActive, degreeFilterActive, countriesFilterActive, - enableSalaryFilter + enableSalaryFilter, + salaryThresholdMin, + salaryThresholdMax } as ControlState) } @@ -156,6 +162,14 @@ export class ControlStore { this.enableSalaryFilter = enableSalaryFilter } + setSalaryThresholdMin(salaryThresholdMin: number): void { + this.salaryThresholdMin = salaryThresholdMin + } + + setSalaryThresholdMax(salaryThresholdMax: number): void { + this.salaryThresholdMax = salaryThresholdMax + } + setSelectedCurrency(currency: Currency): void { this.selectedCurrency = currency } @@ -184,6 +198,8 @@ export class ControlStore { if (state.degreeFilterActive !== undefined) this.degreeFilterActive = state.degreeFilterActive if (state.countriesFilterActive !== undefined) this.countriesFilterActive = state.countriesFilterActive if (state.enableSalaryFilter !== undefined) this.enableSalaryFilter = state.enableSalaryFilter + if (state.salaryThresholdMin !== undefined) this.salaryThresholdMin = state.salaryThresholdMin + if (state.salaryThresholdMax !== undefined) this.salaryThresholdMax = state.salaryThresholdMax } } @@ -204,7 +220,9 @@ export class ControlStore { companySizeFilterActive: this.companySizeFilterActive, degreeFilterActive: this.degreeFilterActive, countriesFilterActive: this.countriesFilterActive, - enableSalaryFilter: this.enableSalaryFilter + enableSalaryFilter: this.enableSalaryFilter, + salaryThresholdMin: this.salaryThresholdMin, + salaryThresholdMax: this.salaryThresholdMax } } @@ -227,6 +245,8 @@ export class ControlStore { if (state.degreeFilterActive !== undefined) this.degreeFilterActive = state.degreeFilterActive if (state.countriesFilterActive !== undefined) this.countriesFilterActive = state.countriesFilterActive if (state.enableSalaryFilter !== undefined) this.enableSalaryFilter = state.enableSalaryFilter + if (state.salaryThresholdMin !== undefined) this.salaryThresholdMin = state.salaryThresholdMin + if (state.salaryThresholdMax !== undefined) this.salaryThresholdMax = state.salaryThresholdMax } } diff --git a/src/stores/entryStore.ts b/src/stores/entryStore.ts index e46631e8..a194f5d1 100644 --- a/src/stores/entryStore.ts +++ b/src/stores/entryStore.ts @@ -1,5 +1,5 @@ import FreeCurrency from '../model/currencyValues' -import { makeAutoObservable } from 'mobx' +import { makeAutoObservable, observable, transaction } from 'mobx' import CurrencyService from '../services/currencyService' import StackOverflowCsvReader from '../services/stackOverflowCsvReader' import { ParseStepResult } from 'papaparse' @@ -9,6 +9,7 @@ import { AVAILABLE_YEARS } from '../model/constantMetaData' import { AbstractCsvRowMapper } from '../mapper/AbstractCsvRowMapper' import { idbRawStore, RAW_PAGE_SIZE } from '../services/idbRawStore' import SurveyEntry from '../model/surveyEntry' +import { mark, measure } from '../utils/perfLogger' const STORAGE_KEY_PREFIX = 'salaryGuide-' @@ -37,17 +38,31 @@ export class EntryStore { currencyValues!: FreeCurrency reader!: StackOverflowCsvReader selectedYear = AVAILABLE_YEARS[AVAILABLE_YEARS.length - 1] + isParsing = false // Sink that receives every valid parsed entry during streaming (wired by uiStore) private streamSink: ((entry: SurveyEntry) => void) | null = null // Buffer of raw CSV rows for the year currently being parsed, flushed to IndexedDB per page - private rawBuffer: CsvRow[] = [] - private rawPageCount = 0 - private rawBufferYear = -1 + public rawBuffer: CsvRow[] = [] + public rawPageCount = 0 + public rawBufferYear = -1 + + // Buffer for parsed entries during chunked loading; flushed to observables only after final chunk + public pendingValidRows: SurveyEntry[] = [] + public pendingInvalidCount = 0 + public pendingTotalCount = 0 constructor() { - makeAutoObservable(this) + makeAutoObservable(this, { + isParsing: observable, + pendingValidRows: false, + pendingInvalidCount: false, + pendingTotalCount: false, + rawBuffer: false, + rawPageCount: false, + rawBufferYear: false + }) this.loadData() } @@ -84,6 +99,7 @@ export class EntryStore { initParser (year: string): void { this.selectedYear = year + this.isParsing = true const resultsetForYear = this.parsedDataByYear[parseInt(year)] // Only clear if no existing data @@ -105,6 +121,11 @@ export class EntryStore { this.rawBuffer = [] this.rawPageCount = 0 this.rawBufferYear = parseInt(year) + + // Reset parsed-entry buffering + this.pendingValidRows = [] + this.pendingInvalidCount = 0 + this.pendingTotalCount = 0 this.reader.startWorkerForYear( yearData, @@ -114,25 +135,34 @@ export class EntryStore { ) } - private handleRawChunk(rawRows: CsvRow[]): void { + private handleRawChunk(rawRows: CsvRow[], validRows: SurveyEntry[], invalidCount: number, totalCount: number): void { const yearData = this.parsedDataByYear[this.rawBufferYear] if (!yearData) return const parsed = yearData.chunksParsed const available = yearData.chunksAvailable - const invalidEntryCount = yearData.invalidEntryCount const overallEntryCount = yearData.overallEntryCount + const invalidEntryCount = yearData.invalidEntryCount // eslint-disable-next-line no-console console.log('Finished parsing a chunk for year: ' + this.rawBufferYear + '\n' + '\t chunks parsed ' + parsed + ' chunks to go ' + available + '\n ' + '\t entries parsed ' + overallEntryCount + ' invalid ones ' + invalidEntryCount + ' ') - // Buffer raw CSV rows and flush full pages to IndexedDB (never keep it all in RAM) - this.rawBuffer.push(...rawRows) + mark('chunk-buffer-start') + Array.prototype.push.apply(this.pendingValidRows, validRows) + this.pendingInvalidCount += invalidCount + this.pendingTotalCount += totalCount + Array.prototype.push.apply(this.rawBuffer, rawRows) + const bufferDuration = measure('chunk-buffer', 'chunk-buffer-start') + console.log(`[PERF] Chunk buffer: ${bufferDuration.toFixed(0)}ms, pending=${this.pendingValidRows.length}, rawBuffer=${this.rawBuffer.length}`) + + mark('idb-write-start') while (this.rawBuffer.length >= RAW_PAGE_SIZE) { const page = this.rawBuffer.splice(0, RAW_PAGE_SIZE) void idbRawStore.savePage(this.rawBufferYear, this.rawPageCount, page) this.rawPageCount++ } + const idbDuration = measure('idb-write', 'idb-write-start') + console.log(`[PERF] IndexedDB flush: ${idbDuration.toFixed(0)}ms, pages=${this.rawPageCount}`) const isLastChunk = parsed > 0 && parsed >= available if (isLastChunk) { @@ -141,18 +171,23 @@ export class EntryStore { this.rawPageCount++ this.rawBuffer = [] } - this.streamSinkFinalize?.() + mark('final-tx-start') + transaction(() => { + Array.prototype.push.apply(yearData.resultSet, this.pendingValidRows) + yearData.invalidEntryCount += this.pendingInvalidCount + yearData.overallEntryCount += this.pendingTotalCount + }) + const txDuration = measure('final-tx', 'final-tx-start') + console.log(`[PERF] Final MobX tx: ${txDuration.toFixed(0)}ms, entries=${this.pendingValidRows.length}`) + this.pendingValidRows = [] + this.pendingInvalidCount = 0 + this.pendingTotalCount = 0 + this.isParsing = false // Only save parsed entries to session storage on the LAST chunk this.saveToSession(String(this.rawBufferYear)) } } - private streamSinkFinalize: (() => void) | null = null - - setStreamFinalize(fn: () => void): void { - this.streamSinkFinalize = fn - } - saveToSession(year: string): void { // Raw CSV rows are persisted to IndexedDB (paginated) instead of sessionStorage // to avoid holding the full dataset in memory. Parsed entries could be cached here @@ -178,7 +213,7 @@ export class EntryStore { this.parsedDataByYear[yearNum].resultSet = data.resultSet || [] this.parsedDataByYear[yearNum].overallEntryCount = data.overallEntryCount || 0 this.parsedDataByYear[yearNum].invalidEntryCount = data.invalidEntryCount || 0 - this.parsedData.resultSet = [...this.parsedData.resultSet, ...(data.resultSet || [])] + Array.prototype.push.apply(this.parsedData.resultSet, data.resultSet || []) // Populate distinct values for filters this.populateDistinctValues(data.resultSet || []) return true @@ -220,4 +255,4 @@ export class EntryStore { export type EntriesByYearMap = { [year: number]: ResultSetForYear } -export default new EntryStore() \ No newline at end of file +export default new EntryStore() diff --git a/src/stores/translationStore.ts b/src/stores/translationStore.ts index 5057b289..0c2588b7 100644 --- a/src/stores/translationStore.ts +++ b/src/stores/translationStore.ts @@ -30,7 +30,7 @@ const translations = { countriesLabel: 'Countries', degreeLabel: 'Highest Degree', salaryFilterLabel: 'Salary Threshold Filter', - salaryFilterHint: 'When disabled: consider all salaries. When enabled: filter 10k-250k', + salaryFilterHint: 'When disabled: consider all salaries. When enabled: filter by salary range in USD', companySizeFrom: 'From', companySizeTo: 'To', saveToSession: 'Save to Session', @@ -44,6 +44,7 @@ const translations = { rawCsvTab: 'Raw CSV', mappedTab: 'Mapped All', filteredTab: 'Filtered', + histogramTab: 'Histogram', entries: 'entries', page: 'Page', salaryRaw: 'Salary (raw)', @@ -83,7 +84,7 @@ const translations = { statisticsRealisticText: 'For a normal distribution, roughly 68% of all developers earn between "Mean − Std" and "Mean + Std". This range is a good estimate of what a realistic salary looks like.', // Estimator - estimatorIntro: 'Enter your profile and we estimate the salary you could expect, based on the Stack Overflow data. You can also compare similar datasets.', + estimatorIntro: 'Enter your profile and we estimate the salary you could expect, based on the Stack Overflow data. Salary thresholds are not considered to be unbiased.', estimatorExperience: 'Years of experience', estimatorAbilities: 'Tools and Technologies', estimatorCountries: 'Countries', @@ -99,6 +100,7 @@ const translations = { estimatorQualityGood: 'Good - enough data and a consistent spread', estimatorQualityMedium: 'Medium - the estimate is an approximation', estimatorQualityLow: 'Low - too little data or very high spread, treat with caution', + estimatorHighSalaryWarning: 'Warning: The standard deviation is very high compared to the mean. This indicates many unusually high salaries that might be fake or CEO salaries, which are not competitive benchmarks.', estimatorSimilarTitle: 'Similar datasets to compare', estimatorSimilarHint: 'Datasets that differ in only one criterion from your selection, so you can see its effect on the salary.', estimatorDifference: 'Difference', @@ -138,7 +140,7 @@ const translations = { countriesLabel: 'Länder', degreeLabel: 'Höchster Abschluss', salaryFilterLabel: 'Gehaltsfilter', - salaryFilterHint: 'Wenn deaktiviert: alle Gehälter berücksichtigen. Wenn aktiviert: Filter 10k-250k', + salaryFilterHint: 'Wenn deaktiviert: alle Gehälter berücksichtigen. Wenn aktiviert: Nach Gehaltsbereich in USD filtern', companySizeFrom: 'Von', companySizeTo: 'Bis', saveToSession: 'In Sitzung speichern', @@ -146,13 +148,14 @@ const translations = { downloadJson: 'JSON herunterladen', uploadJson: 'JSON hochladen', shareLink: 'Link teilen', - shareLinkSuccess: 'Link in die Zwischenablage kopieren!', + shareLinkSuccess: 'Link in die Zwischenablage kopiert!', // ConsideredDataTable dataTables: 'Daten Tabellen', rawCsvTab: 'Roh CSV', mappedTab: 'Alle gemappt', filteredTab: 'Gefiltert', + histogramTab: 'Histogramm', entries: 'Einträge', page: 'Seite', salaryRaw: 'Gehalt (roh)', @@ -192,7 +195,7 @@ const translations = { statisticsRealisticText: 'Bei einer Normalverteilung liegen etwa 68% aller Entwickler zwischen "Mittelwert − Std" und "Mittelwert + Std". Dieser Bereich ist eine gute Schätzung für ein realistisches Gehalt.', // Estimator - estimatorIntro: 'Gib dein Profil ein und wir schätzen das Gehalt, das du erwarten kannst, basierend auf den Stack-Overflow-Daten. Du kannst auch ähnliche Datensätze vergleichen.', + estimatorIntro: 'Gib dein Profil ein und wir schätzen das Gehalt, das du erwarten kannst, basierend auf den Stack-Overflow-Daten. Gehaltsfilter werden nicht berücksichtigt.', estimatorExperience: 'Jahre Erfahrung', estimatorAbilities: 'Tools und Technologien', estimatorCountries: 'Länder', @@ -200,7 +203,7 @@ const translations = { estimatorCompanySize: 'Firmengröße', estimatorGender: 'Geschlecht', estimatorCalculate: 'Erwartetes Gehalt schätzen', - estimatorResultTitle: 'Geschätztes erwartetes Gehalt', + estimatorResultTitle: 'Geschätztes erwartete Gehalt', estimatorNoData: 'Nicht genug Daten für diese Filterkombination. Versuche, deine Auswahl zu erweitern.', estimatorSampleSize: 'Basiert auf {count} Datenpunkten', estimatorTypicalRange: 'Typischer realistischer Bereich', @@ -208,6 +211,7 @@ const translations = { estimatorQualityGood: 'Gut - genug Daten und konsistente Streuung', estimatorQualityMedium: 'Mittel - die Schätzung ist eine Näherung', estimatorQualityLow: 'Niedrig - zu wenig Daten oder sehr hohe Streuung, mit Vorsicht genießen', + estimatorHighSalaryWarning: 'Warnung: Die Standardabweichung ist sehr hoch im Vergleich zum Mittelwert. Dies deutet auf viele ungewöhnlich hohe Gehälter hin, die möglicherweise gefälscht sind oder Gehälter von Geschäftsführern darstellen, die keine wettbewerbsfähigen Vergleichswerte sind.', estimatorSimilarTitle: 'Ähnliche Datensätze zum Vergleich', estimatorSimilarHint: 'Datensätze, die sich in nur einem Kriterium von deiner Auswahl unterscheiden, damit du den Effekt auf das Gehalt siehst.', estimatorDifference: 'Differenz', @@ -249,4 +253,4 @@ class TranslationStore { } } -export default new TranslationStore() \ No newline at end of file +export default new TranslationStore() diff --git a/src/stores/uiStore.ts b/src/stores/uiStore.ts index 83bcf82f..f071ff17 100644 --- a/src/stores/uiStore.ts +++ b/src/stores/uiStore.ts @@ -6,6 +6,7 @@ import controlStore from './controlStore' import entryStore from './entryStore' import { Gender } from '../model/gender' import { StatsAccumulator, BoxStats } from './statsAccumulator' +import { mark, measure } from '../utils/perfLogger' type ReactionData = { years: string[] @@ -23,6 +24,8 @@ type ReactionData = { degrees: string[] companySizeFilterActive: boolean enableSalaryFilter: boolean + salaryThresholdMin: number + salaryThresholdMax: number } export class UiStore { @@ -35,7 +38,6 @@ export class UiStore { boxStats: BoxStats | null = null private boxAccumulator = new StatsAccumulator() private boxStatsYear = -1 - private isStreaming = false private streamCounter = 0 private readonly flushInterval = 5000 @@ -59,7 +61,6 @@ export class UiStore { setMobileView: action, setControlPaneOpen: action, recordStreamEntry: action, - finalizeStream: action, rebuildBoxStats: action, }) @@ -97,6 +98,8 @@ export class UiStore { degrees: cs.degrees, companySizeFilterActive: cs.companySizeFilterActive, enableSalaryFilter: cs.enableSalaryFilter, + salaryThresholdMin: cs.salaryThresholdMin, + salaryThresholdMax: cs.salaryThresholdMax, selectedCurrency: cs.selectedCurrency, currencyValuesReady: this.entryStore.currencyValues != null } @@ -109,9 +112,9 @@ export class UiStore { this.debounceTimer = setTimeout(() => { if (this.latestReactionData) { this.lastFilterUpdateTime = Date.now() - console.log('[DEBUG] UiStore filtering triggered at', new Date(this.lastFilterUpdateTime).toISOString()) - this.updateFilteredData() - if (!this.isStreaming) { + console.log('[DEBUG] UiStore filtering triggered at', new Date(this.lastFilterUpdateTime).toISOString(), 'isParsing=', this.entryStore.isParsing) + if (!this.entryStore.isParsing) { + this.updateFilteredData() this.rebuildBoxStats() } } @@ -126,6 +129,7 @@ export class UiStore { const selectedYearNum = parseInt(this.entryStore.selectedYear, 10) const parsedData = this.entryStore.parsedDataByYear[selectedYearNum] const controlState = this.controlStore.controlState + mark('ui-update-filtered-start') if (parsedData?.resultSet) { this.filteredData[selectedYearNum] = [...parsedData.resultSet] @@ -133,6 +137,9 @@ export class UiStore { } else { this.filteredData[selectedYearNum] = [] } + + const duration = measure('ui-update-filtered', 'ui-update-filtered-start') + console.log(`[PERF] updateFilteredData: ${duration.toFixed(0)}ms, results=${this.filteredData[selectedYearNum].length}`) } setMobileView = (value: boolean): void => { @@ -153,65 +160,60 @@ export class UiStore { } isSalaryInRange(usdValue: number): boolean { - return usdValue >= 10000 && usdValue <= 250000 - } - -recordStreamEntry = (entry: SurveyEntry): void => { - const year = parseInt(this.entryStore.selectedYear, 10) - if (year !== this.boxStatsYear) { - this.boxAccumulator.reset() - this.boxStatsYear = year - this.isStreaming = true - this.streamCounter = 0 + return usdValue >= this.controlStore.salaryThresholdMin && usdValue <= this.controlStore.salaryThresholdMax } - if (!this.controlStore.controlState.filterByState(entry)) { - return - } - this.boxAccumulator.add(this.convertSalary(entry)) - this.streamCounter++ - if (this.streamCounter % this.flushInterval === 0) { - this.boxStats = this.boxAccumulator.toBoxStats() - } -} - finalizeStream = (): void => { - this.boxStats = this.boxAccumulator.toBoxStats() - this.isStreaming = false + recordStreamEntry = (entry: SurveyEntry): void => { + const year = parseInt(this.entryStore.selectedYear, 10) + if (year !== this.boxStatsYear) { + this.boxAccumulator.reset() + this.boxStatsYear = year + this.streamCounter = 0 + } + this.boxAccumulator.add(this.convertSalary(entry)) + this.streamCounter++ + if (this.streamCounter % this.flushInterval === 0) { + this.boxStats = this.boxAccumulator.toBoxStats() + } } private rebuildBoxStatsRequestId = 0 -rebuildBoxStats = (): void => { - const year = parseInt(this.entryStore.selectedYear, 10) - this.boxAccumulator.reset() - this.boxStatsYear = year - const parsedData = this.entryStore.parsedDataByYear[year] - const controlState = this.controlStore.controlState - if (parsedData?.resultSet) { - const entries = parsedData.resultSet - const chunkSize = 1000 - let index = 0 - const requestId = ++this.rebuildBoxStatsRequestId - const processChunk = (): void => { - if (requestId !== this.rebuildBoxStatsRequestId) return - const end = Math.min(index + chunkSize, entries.length) - for (let i = index; i < end; i++) { - if (controlState.filterByState(entries[i])) { - this.boxAccumulator.add(this.convertSalary(entries[i])) + rebuildBoxStats = (): void => { + const year = parseInt(this.entryStore.selectedYear, 10) + this.boxAccumulator.reset() + this.boxStatsYear = year + const parsedData = this.entryStore.parsedDataByYear[year] + const controlState = this.controlStore.controlState + mark('ui-rebuild-boxstats-start') + + if (parsedData?.resultSet) { + const entries = parsedData.resultSet + const chunkSize = 1000 + let index = 0 + const requestId = ++this.rebuildBoxStatsRequestId + const processChunk = (): void => { + if (requestId !== this.rebuildBoxStatsRequestId) return + const end = Math.min(index + chunkSize, entries.length) + for (let i = index; i < end; i++) { + if (controlState.filterByState(entries[i])) { + this.boxAccumulator.add(this.convertSalary(entries[i])) + } + } + index = end + if (index < entries.length) { + requestAnimationFrame(processChunk) + } else { + this.boxStats = this.boxAccumulator.toBoxStats() + const duration = measure('ui-rebuild-boxstats', 'ui-rebuild-boxstats-start') + console.log(`[PERF] rebuildBoxStats: ${duration.toFixed(0)}ms, entries=${entries.length}`) } } - index = end - if (index < entries.length) { - requestAnimationFrame(processChunk) - } else { - this.boxStats = this.boxAccumulator.toBoxStats() - } + requestAnimationFrame(processChunk) + } else { + this.boxStats = this.boxAccumulator.toBoxStats() } - requestAnimationFrame(processChunk) - } else { - this.boxStats = this.boxAccumulator.toBoxStats() } -} destroy(): void { if (this.reactionDisposer) { @@ -226,4 +228,3 @@ rebuildBoxStats = (): void => { export const uiStore = new UiStore(controlStore, entryStore) entryStore.setStreamSink((entry) => uiStore.recordStreamEntry(entry)) -entryStore.setStreamFinalize(() => uiStore.finalizeStream()) \ No newline at end of file diff --git a/src/utils/perfLogger.ts b/src/utils/perfLogger.ts new file mode 100644 index 00000000..b474c9ef --- /dev/null +++ b/src/utils/perfLogger.ts @@ -0,0 +1,48 @@ +const MARK_PREFIX = 'salary-guide:' + +export function mark(name: string): void { + try { + performance.mark(`${MARK_PREFIX}${name}`) + } catch { + // ignore if performance API is unavailable or mark already exists + } +} + +export function measure(name: string, startMark: string, endMark?: string): number { + try { + const start = `${MARK_PREFIX}${startMark}` + const end = endMark ? `${MARK_PREFIX}${endMark}` : undefined + const measureName = `${MARK_PREFIX}${name}` + if (end) { + performance.measure(measureName, { start, end }) + } else { + performance.measure(measureName, { start }) + } + const entries = performance.getEntriesByName(measureName) + const last = entries[entries.length - 1] + return last ? last.duration : 0 + } catch { + return 0 + } +} + +export function logLongTasks(thresholdMs = 50): (() => void) | null { + if (typeof PerformanceObserver === 'undefined') { + return null + } + try { + const observer = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + if (entry.duration >= thresholdMs) { + console.warn( + `[PERF] Long task detected: ${entry.duration.toFixed(1)}ms (start: ${entry.startTime.toFixed(0)}ms)` + ) + } + } + }) + observer.observe({ type: 'longtask', buffered: true }) + return () => observer.disconnect() + } catch { + return null + } +} diff --git a/src/utils/reactPerfMonitor.ts b/src/utils/reactPerfMonitor.ts new file mode 100644 index 00000000..51205498 --- /dev/null +++ b/src/utils/reactPerfMonitor.ts @@ -0,0 +1,67 @@ +const PREFIX = '[ReactPerf]' + +const renderCounts = new Map() +const renderDurations = new Map() + +export function getRenderCount(displayName: string): number { + return renderCounts.get(displayName) || 0 +} + +export function getRenderStats(displayName: string): { count: number; avgMs: number; maxMs: number } { + const count = renderCounts.get(displayName) || 0 + const durations = renderDurations.get(displayName) || [] + const avgMs = durations.length > 0 ? durations.reduce((a, b) => a + b, 0) / durations.length : 0 + const maxMs = durations.length > 0 ? Math.max(...durations) : 0 + return { count, avgMs, maxMs } +} + +export function resetRenderStats(): void { + renderCounts.clear() + renderDurations.clear() +} + +export function trackRender(displayName: string): (id: string, phase: 'mount' | 'update' | 'nested-update', actualDuration: number) => void { + return (_id: string, _phase: string, actualDuration: number) => { + renderCounts.set(displayName, (renderCounts.get(displayName) || 0) + 1) + const durations = renderDurations.get(displayName) || [] + durations.push(actualDuration) + renderDurations.set(displayName, durations) + } +} + +export function startFPSMonitor(intervalMs = 1000): (() => void) | null { + if (typeof window === 'undefined' || typeof requestAnimationFrame === 'undefined') { + return null + } + + let frameCount = 0 + let lastTime = performance.now() + let rafId: number + + const tick = (time: number): void => { + frameCount++ + const elapsed = time - lastTime + if (elapsed >= intervalMs) { + const fps = (frameCount * 1000) / elapsed + const minFps = Math.min(fps, 60) + if (minFps < 30) { + console.warn(`${PREFIX} Low FPS: ${minFps.toFixed(1)} (avg ${fps.toFixed(1)})`) + } + frameCount = 0 + lastTime = time + } + rafId = requestAnimationFrame(tick) + } + + rafId = requestAnimationFrame(tick) + return () => cancelAnimationFrame(rafId) +} + +export function logAllRenderStats(): void { + console.group(`${PREFIX} Render Statistics`) + for (const name of renderCounts.keys()) { + const stats = getRenderStats(name) + console.log(`${name}: renders=${stats.count}, avg=${stats.avgMs.toFixed(1)}ms, max=${stats.maxMs.toFixed(1)}ms`) + } + console.groupEnd() +}