“How many hours is safe?” has no single answer until volume is part of the question. I built a listening-time calculator that records each session, converts decibels into an allowed duration, and adds the day’s dose. The useful lesson is that a running health estimate needs a time boundary and an honest model, not just a big green percentage.
The reference point is an exponential rule
The source anchors the calculation at 85 dB for eight hours:
const REFERENCE_DB = 85;
const REFERENCE_HOURS = 8;
function allowedHours(db) {
return REFERENCE_HOURS / Math.pow(2, (db - REFERENCE_DB) / 3);
}
function dosePercent(minutes, db) {
const hours = Math.max(0, Number(minutes) || 0) / 60;
return (hours / allowedHours(db)) * 100;
}
Each increase of three decibels halves the allowed time in this model. At 88 dB the same eight-hour intuition is no longer appropriate; at 100 dB the permitted duration becomes very short. Keeping the formula in named functions makes the assumption reviewable instead of hiding it in a template expression.
A volume slider is only an estimate
The tool supports direct dB input and a percentage mode. Percentage mode maps the control through a curve:
function estimateDbFromPercent(percent, maxDb) {
const p = clampNumber(percent, 0, 100, 60) / 100;
const max = clampNumber(maxDb, 85, 115, 103);
const floorDb = 45;
return Math.round(
(floorDb + (max - floorDb) * Math.pow(p, 0.72)) * 10
) / 10;
}
This is not pretending that every phone reports calibrated sound pressure. It is a user-facing estimate with bounded inputs. clampNumber prevents a malformed value from producing NaN, and the maximum is limited to 115 dB so an accidental slider value does not create an absurd result.
Records are daily, not an infinite history
Adding a session stores the calculated dose and a local timestamp:
records.value.push({
id: `sl_${Date.now()}_${Math.floor(Math.random() * 10000)}`,
db,
minutes,
dosePercent: Math.round(dosePercent(minutes, db) * 10) / 10,
listenTime: parseLocalInput(form.listenLocal),
inputMode: inputMode.value,
});
filterTodayRecords();
persistRecords();
The computed total is simply the sum of stored doses:
const todayDosePercent = computed(() =>
records.value.reduce((sum, record) => sum + record.dosePercent, 0)
);
On load and whenever the date key changes, records are filtered with getDateKey. That avoids showing yesterday’s exposure as today’s dose while still allowing the browser to keep the small saved state in localStorage.
The chart communicates overage differently
The doughnut chart uses one data shape until the dose passes 100 percent. Once it does, it switches from “dose plus remaining” to “100 plus overage.” That prevents a 160% dose from making the safe portion visually larger than the whole. A one-minute timer updates nowTick, which lets midnight trigger the date filter without requiring a page refresh.
There are real limitations. The dB percentage mapping is an estimate, headphones and phones measure differently, and the formula is an educational exposure model rather than medical advice. Records are also local to one browser and are discarded from the active list when their local calendar day changes. A user crossing time zones may see the boundary move, because getDateKey uses the device’s local date.
Why the daily boundary is part of the UX
The tool stores a date alongside records, but it does not trust that field alone when loading. It recalculates the date key from each listenTime and filters against the current local day. That choice is safer than assuming a saved JSON object is still current after the browser has been closed overnight. It also makes the behavior easy to explain: the summary is a view of records whose timestamps belong to today on this device.
The doughnut chart has its own edge-case policy. Dose is clamped to a display range of zero through 150 percent. Below 100, the slices are dose and remaining allowance. Above 100, the slices become a full 100 percent baseline plus the amount over. This is not changing the stored math; it is preventing the visualization from implying that an over-limit dose has “negative remaining” space.
I would not use the chart color as the only warning. The computed status class changes at 80 and 100 percent, and the text also reports safe, near-limit, or over-limit states. That redundancy matters for readers who cannot distinguish the colors or who are reading the page with a screen reader. A health-oriented interface should make the numeric dose and the assumption behind it available even when the chart is ignored.
The form also resets the local timestamp after a successful add. That tiny interaction detail reduces a common data-entry error: entering several sessions while accidentally reusing the time of the first one. The stored record keeps both the original input mode and the resolved dB value, so later display or debugging can tell whether a number came from direct dB entry or the estimate curve.
What the dose number does and does not mean
The dose percentage is additive because each record represents a share of the allowed exposure at its own level. A short loud session can therefore consume more of the day’s allowance than a much longer quiet session. The implementation does not average the dB values first; that would lose the important fact that the allowed duration changes nonlinearly with level.
The current dB value also drives the “remaining hours” text separately from the accumulated records. It calculates the remaining dose as Math.max(0, 100 - todayDosePercent) and applies that fraction to allowedHours(currentDb). Once the total reaches or exceeds 100 percent, the remaining value cannot become negative. That is a display and guidance boundary, while the individual records still retain their calculated dose values for the chart and list.
This separation is useful when explaining the interface to someone implementing a similar feature. A progress bar can be bounded for readability, but the underlying measurements should remain inspectable. It also avoids a misleading reset: changing the current form’s volume does not rewrite older records. Only a newly added record uses the current dB estimate and minutes.
Finally, the local persistence is intentionally modest. persistRecords stores JSON under one versioned key and catches storage failures, while loadRecords falls back to an empty list if the saved value cannot be parsed. There is no account, cloud backup, or device synchronization. That keeps the tool private and simple, but a user should not treat it as a permanent medical record.
I turned the calculation into a small free tool: Safe Listening Time Calculator.
Top comments (0)