Class Activity Calculator

Introduction

Class Activity Calculator is an online application to calculate the students' class activity grades. The class activity grade (CA) is calculated based on the marks a student gets during the term. Here's a sample grade sheet we use in class:

Please review the source code and provide feedback.

<!DOCTYPE html>
<html lang="en">

<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="Calculate the class activity grades of the ILI students.">
<title>Class Activity Calculator</title>

<body>
<img src="logo.png" alt="Logo">
<h1>Class Activity Calculator</h1>
<nav>
<a href="kids.html">Kids</a>
</nav>
<main>
<form autocomplete="off">
<fieldset data-weight="4">
<legend>Listening & Speaking</legend>
<input type="number" step="any" min="0" max="100">
<input type="number" step="any" min="0" max="100">
<input type="number" step="any" min="0" max="100">
<input type="number" step="any" min="0" max="100">
<input type="number" step="any" min="0" max="100">
<output></output>
</fieldset>
<fieldset data-weight="3">
<input type="number" step="any" min="0" max="100">
<input type="number" step="any" min="0" max="100">
<input type="number" step="any" min="0" max="100">
<input type="number" step="any" min="0" max="100">
<input type="number" step="any" min="0" max="100">
<output></output>
</fieldset>
<fieldset data-weight="1">
<legend>Writing</legend>
<input type="number" step="any" min="0" max="100">
<input type="number" step="any" min="0" max="100">
<input type="number" step="any" min="0" max="100">
<input type="number" step="any" min="0" max="100">
<input type="number" step="any" min="0" max="100">
<output></output>
</fieldset>
<div>
<button type="button">Calculate</button>
<output></output>
<button type="reset">Reset</button>
</div>
</form>
</main>
<footer>
Share on <a href="https://t.me/share/url?url=https%3A%2F%2Fclassactivitycalculator.github.io%2F&text=Class%20Activity%20Calculator%3A%20Calculate%20the%20class%20activity%20grades%20of%20the%20ILI%20students." title="Telegram: Share Web Page">Telegram</a> |
</footer>
<script>
function setOutputValues() {
var totalWeightedAverage = 0;
var totalWeight = 0;
var fieldsets = form.querySelectorAll('fieldset');
for (var fieldset of fieldsets) {
var average = averageInputValues(fieldset);
var fieldsetOutput = fieldset.querySelector('output');
if (average == undefined) {
fieldsetOutput.value = 'You may only enter 0 to 100.';
} else if (isNaN(average)) {
} else {
fieldsetOutput.value = 'avg: ' + average.toFixed(1);
}
totalWeightedAverage += average * fieldset.dataset.weight;
totalWeight += Number(fieldset.dataset.weight);
}
var classActivity = totalWeightedAverage / totalWeight;
var divOutput = form.querySelector('div output');
if (isNaN(classActivity)) {
divOutput.value = '';
} else {
divOutput.value = 'CA: ' + classActivity.toFixed(1);
}
}
</script>
<script src="global.js"></script>
</body>

</html>

style.css

html,
body {
margin: 0;
}

text-align: center;
}

img {
width: 36px;
height: 36px;
vertical-align: bottom;
}

h1 {
font-size: 1.125rem;
font-family: 'Times New Roman';
color: #FFF;
letter-spacing: 1px;
}

nav {
display: flex;
justify-content: center;
background: #333;
border-top: 2px solid;
}

a {
color: #FFF;
}

nav a {
font: bold 0.75rem Verdana;
text-decoration: none;
}

nav a:not(:last-child) {
margin-right: 2px;
}

nav a:hover,
nav a:focus,
#current {
outline: 0;
border-top: 2px solid;
margin-top: -2px;
}

main,
div {
display: flex;
}

form {
margin: 32px auto;
}

fieldset {
margin: 0 0 16px;
border: 1px solid #CCC;
}

legend,
input,
output,
button {
font-family: Arial;
}

legend,
button {
color: #333;
}

legend {
font-size: 0.875rem;
}

input,
button,
div output {
font-size: 0.833rem;
}

input {
width: 4em;
}

input:invalid {
outline: 1px solid red;
}

output {
color: #C00;
}

fieldset output {
display: block;
margin: 8px 0 8px 6px;
font-size: 0.75rem;
}

fieldset output::after {
content: "\00A0";
}
/* a placeholder */

div output {
margin: auto auto auto 6px;
}

footer {
background: #333;
font: 0.75rem Arial;
color: #FFF;
}

float: right;
}

global.js

var form = document.querySelector('form');

function averageInputValues(fieldset) {
var totalValue = 0;
var totalNumber = 0;
var inputs = fieldset.querySelectorAll('input');
for (var input of inputs) {
if (!input.validity.valid) {
return;
}
totalValue += Number(input.value);
totalNumber += Boolean(input.value);
}
}

function detectChange() {
var inputs = form.querySelectorAll('input');
for (var input of inputs) {
if (input.value) {
return true;
}
}
}

if (detectChange() && !confirm('Your changes will be lost.\nAre you sure you want to reset?')) {
event.preventDefault();
}
});

if (detectChange()) {
event.returnValue = 'Your changes may be lost.';
}
});

Overall Assessment

Overall this code is fine but it has a lot of excess DOM queries. Those could be optimized using the tips below.

I noticed that if I calculate an average and then click the reset button, it still asks me to confirm when leaving the page. Should it still prompt to confirm even if the user has calculated an (overall) average?

More specific feedback

It is confusing having setOutputValues() defined in the inline script whereas all other functions are declared in global.js. It would make more sense to have everything together, unless something is being abstracted into a module. But I see your comment that explains that setOutputValues will be different on each page. I would question what actually changes within that function depending on the page - is it the range values and/or strings? If so, perhaps those could be output as variables or else as hidden DOM elements.

I see some features used, like for..of, which means other features from that specification could be used as well. For instance, any variable that doesn't get re-assigned could be declared with const and any value that does get re-assigned can be declared with let.

I see the code queries for the form element:

var form = document.querySelector('form');

I know that you were told "querySelectorAll is your friend."1. However, use it with caution. There are other DOM query functions that run quicker than it. One could add an id attribute to the element and query for it using document.getElementById()2. But then again, there is a reference to the forms property of document, which could remove the need to query the DOM altogether. The first form could be referenced via document.forms[0] or a name attribute could also be applied to allow reference by name.

For example, you could add the name attribute to the form:

<form autocomplete="off" name="activityCalc">

Then utilize that name when referencing document.forms:

const form = document.forms.activityCalc;

That way there is no need to query the DOM with a function like querySelector.

The same is true for the <output> elements - a name could be added to the last one and then there is no need to query for that element when displaying the cumulative average, as well as the button labeled Calculate.

And instead of using querySelectorAll() to get the elements under a fieldset, you could use .getElementsByTagName() since the selector is just a tag name. As this SO answer explains:

getElementsByTagName is probably faster, since it is simpler, but that is unlikely to have a noticeable impact on anything you do with it.

I see that a handler for click events on the reset button is added via

this can be simplified by using the form event reset.

When loading a simple document like this with modern browsers it may be unlikely that the DOM would not be ready before the JavaScript code runs (depending on where it is included) but it is still wise to wait for the DOM to be ready before accessing DOM elements. This can be done with document.addEventListener() for the 'DOMContentLoaded` event. This also allows the scope of the variables to be limited to a callback function instead of global variables.

It is a best practice to use strict equality when comparing values.

On this line:

if (average == undefined) {

the value for average is assigned the return value from averageInputValues() which will likely either be undefined or a floating point number that is the result of a division operation. Using strict equality comparison eliminates the need for the types to be checked. Use the strict equality operator here and anywhere else there is no need to convert types when comparing:

if (average === undefined) {

Rewritten code

The code below uses advice from above to simplify some parts of the code.

//DOM queries/accesses run once
const form = document.forms.activityCalc;
const fieldsets = form.getElementsByTagName('fieldset');
const inputs = form.getElementsByTagName('input');
const divOutput = form.elements.classActivity;

function setOutputValues() {
let totalWeightedAverage = 0;
let totalWeight = 0;
for (const fieldset of fieldsets) {
const average = averageInputValues(fieldset);

// should there be handling for no output element found below?
const fieldsetOutput = fieldset.getElementsByTagName('output')[0];
if (average === undefined) {
fieldsetOutput.value = 'You may only enter 0 to 100.';
} else if (isNaN(average)) {
} else {
fieldsetOutput.value = 'avg: ' + average.toFixed(1);
}
totalWeightedAverage += average * fieldset.dataset.weight;
totalWeight += Number(fieldset.dataset.weight);
}
const classActivity = totalWeightedAverage / totalWeight;
if (isNaN(classActivity)) {
divOutput.value = '';
} else {
divOutput.value = 'CA: ' + classActivity.toFixed(1);
}
}

function averageInputValues(fieldset) {
let totalValue = 0;
let totalNumber = 0;
const inputs = fieldset.getElementsByTagName('input');
for (const input of inputs) {
if (!input.validity.valid) {
return;
}
totalValue += Number(input.value);
totalNumber += Boolean(input.value);
}
}

function detectChange() {
for (const input of inputs) {
if (input.value) {
return true;
}
}
}

if (detectChange() && !confirm('Your changes will be lost.\nAre you sure you want to reset?')) {
event.preventDefault();
}
});

if (detectChange()) {
event.returnValue = 'Your changes may be lost.';
}
});
});
html,
body {
margin: 0;
}

text-align: center;
}

img {
vertical-align: bottom;
}

h1 {
font-size: 1.125rem;
font-family: 'Times New Roman';
color: #FFF;
letter-spacing: 1px;
}

nav {
display: flex;
justify-content: center;
background: #333;
border-top: 2px solid;
}

a {
color: #FFF;
}

nav a {
font: bold 0.75rem Verdana;
text-decoration: none;
}

nav a:not(:last-child) {
margin-right: 2px;
}

nav a:hover,
nav a:focus,
#current {
outline: 0;
border-top: 2px solid;
margin-top: -2px;
}

main,
div {
display: flex;
}

form {
margin: 32px auto;
}

fieldset {
margin: 0 0 16px;
border: 1px solid #CCC;
}

legend,
input,
output,
button {
font-family: Arial;
}

legend,
button {
color: #333;
}

legend {
font-size: 0.875rem;
}

input,
button,
div output {
font-size: 0.833rem;
}

input {
width: 4em;
}

input:invalid {
outline: 1px solid red;
}

output {
color: #C00;
}

fieldset output {
display: block;
margin: 8px 0 8px 6px;
font-size: 0.75rem;
}

fieldset output::after {
content: "\00A0";
}

/* a placeholder */

div output {
margin: auto auto auto 6px;
}

footer {
background: #333;
font: 0.75rem Arial;
color: #FFF;
}

float: right;
}
<img src="https://cdn.sstatic.net/Sites/codereview/img/logo.svg?v=0dfb1294dc6e" alt="Logo">
<h1>Class Activity Calculator</h1>
<nav>
<a href="kids.html">Kids</a>
</nav>
<main>
<form autocomplete="off" name="activityCalc">
<fieldset data-weight="4">
<legend>Listening & Speaking</legend>
<input type="number" step="any" min="0" max="100">
<input type="number" step="any" min="0" max="100">
<input type="number" step="any" min="0" max="100">
<input type="number" step="any" min="0" max="100">
<input type="number" step="any" min="0" max="100">
<output></output>
</fieldset>
<fieldset data-weight="3">
<input type="number" step="any" min="0" max="100">
<input type="number" step="any" min="0" max="100">
<input type="number" step="any" min="0" max="100">
<input type="number" step="any" min="0" max="100">
<input type="number" step="any" min="0" max="100">
<output></output>
</fieldset>
<fieldset data-weight="1">
<legend>Writing</legend>
<input type="number" step="any" min="0" max="100">
<input type="number" step="any" min="0" max="100">
<input type="number" step="any" min="0" max="100">
<input type="number" step="any" min="0" max="100">
<input type="number" step="any" min="0" max="100">
<output></output>
</fieldset>
<div>
<button type="button" name="calculate">Calculate</button>
<output name="classActivity"></output>
<button type="reset">Reset</button>
</div>
</form>
</main>
<footer>
Share on <a href="https://t.me/share/url?url=https%3A%2F%2Fclassactivitycalculator.github.io%2F&text=Class%20Activity%20Calculator%3A%20Calculate%20the%20class%20activity%20grades%20of%20the%20ILI%20students." title="Telegram: Share Web Page">Telegram</a>  |
</footer>

• Thanks for the detailed review! "what actually changes within that function depending on the page - is it the range values and/or strings?" The max and weight values (no weights on two pages), and the CA ranges. I can't find a short way to generalize setOutputValues(). "should there be handling for no output element found below?" I'm not sure I understand what you mean. Each fieldset has an output.
– Mori
Commented May 15, 2019 at 8:29
• No weight and a weight == 1 is the same so you could go with this (if you don't want to test if weight exist in setOutputValues). For the max you really just need to do exactly like you did for the weight (set a data-max and use it). The CA range is taking care of itself (with your for), in fact setOutputValues() is already mostly generalized really. For the output part he wanted to know if there was a need to test if output existed (you answered it). Commented May 16, 2019 at 16:21
• @Nomis: "No weight and a weight == 1 is the same so you could go with this" Please see the updates.
– Mori
Commented May 19, 2019 at 7:39

Edit : as asked, I've edited my code to make a good proof of concept. I've also adapted it to the reworked version of the script, since it doesn't really matter.

Well, I'm lazy and I love to let my script do all the work for me. And you should too !

var listForm={
'Listening & Speaking':{'weight':4,'fields':5,'max':100},
'Writing':{'weight':1,'fields':5,'max':100}
},
'Listening':{'weight':4,'fields':4,'max':5},
'Speaking':{'weight':3,'fields':4,'max':5},
'Writing':{'weight':1,'fields':4,'max':5},
}
};
var form = document.querySelector('form');

function toggleForm(formSelected){
let myForm=listForm[formSelected];
let formContent='';
for(activity in myForm){
var myActivity=myForm[activity];
formContent+='<fieldset data-weight="'+myActivity['weight']+'">';
formContent+='<legend>'+activity+'</legend>';
for(i=0;i<myActivity['fields'];i++)formContent+='<input type="number" step="any" min="0" max="'+myActivity['max']+'">';
formContent+='<output></output></fieldset>';
}
document.getElementById('classActivity').innerHTML=formContent;
}

function averageInputValues(fieldset) {
var totalValue = 0;
var totalNumber = 0;
var inputs = fieldset.querySelectorAll('input');
for (var input of inputs) {
if (!input.validity.valid) {
return;
}
totalValue += Number(input.value);
totalNumber += Boolean(input.value);
}
}

function setOutputValues() {
var max = form.querySelector('input').max;
var totalWeightedAverage = 0;
var totalWeight = 0;
var fieldsets = form.querySelectorAll('fieldset');
for (var fieldset of fieldsets) {
var average = averageInputValues(fieldset);
var fieldsetOutput = fieldset.querySelector('output');
if (average == undefined) {
fieldsetOutput.value = 'You may only enter 0 to ' + max + '.';
} else if (isNaN(average)) {
} else {
fieldsetOutput.value = 'avg: ' + average.toFixed(1);
}
var weight = fieldset.dataset.weight;
if (!weight) {
weight = 1;
}
totalWeightedAverage += average * weight;
totalWeight += Number(weight);
}
var classActivity = totalWeightedAverage / totalWeight;
var divOutput = document.getElementById('total_output');
if (isNaN(classActivity)) {
divOutput.value = '';
} else if (max == 5) { // Adults: New
divOutput.value = 'CA: ' + (classActivity / (max / 100)).toFixed(1); // The class activity grade must be calculated out of 100.
} else {
divOutput.value = 'CA: ' + classActivity.toFixed(1);
}
}

function detectChange() {
var inputs = form.querySelectorAll('input');
for (var input of inputs) {
if (input.value) {
return true;
}
}
}

var nav_items=document.querySelectorAll('.nav_item');
for (var nav_item of nav_items) {
document.querySelector('.current').classList.remove('current');
toggleForm(this.id);
});
}

if (detectChange() && !confirm('Your changes will be lost.\nAre you sure you want to reset?')) {
event.preventDefault();
}
});

if (detectChange()) {
event.returnValue = 'Your changes may be lost.';
}
});
html,
body {
margin: 0;
}

text-align: center;
}

img {
width: 36px;
height: 36px;
vertical-align: bottom;
}

h1 {
font-size: 1.125rem;
font-family: 'Times New Roman';
color: #FFF;
letter-spacing: 1px;
}

nav {
display: flex;
justify-content: center;
background: #333;
border-top: 2px solid;
}

span {
color: #FFF;
}

nav span {
font: bold 0.75rem Verdana;
text-decoration: none;
cursor:pointer;
}

nav span:not(:last-child) {
margin-right: 2px;
}

nav span:hover,
nav span:focus,
.current {
outline: 0;
border-top: 2px solid;
margin-top: -2px;
}

main,
div {
display: flex;
}

form {
margin: 32px auto;
}

fieldset {
margin: 0 0 16px;
border: 1px solid #CCC;
}

legend,
input,
output,
button {
font-family: Arial;
}

legend,
button {
color: #333;
}

legend {
font-size: 0.875rem;
}

input,
button,
div output {
font-size: 0.833rem;
}

input {
width: 4em;
}

input:invalid {
outline: 1px solid red;
}

output {
color: #C00;
}

fieldset output {
display: block;
margin: 8px 0 8px 6px;
font-size: 0.75rem;
}

fieldset output::after {
content: "\00A0";
}
/* a placeholder */

div output {
margin: auto auto auto 6px;
}

footer {
background: #333;
font: 0.75rem Arial;
color: #FFF;
}

float: right;
}
<!DOCTYPE html>
<html lang="en">

<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="Calculate the class activity grades of the ILI students.">
<title>Class Activity Calculator</title>

<body>
<img src="logo.png" alt="Logo">
<h1>Class Activity Calculator</h1>
<nav>
</nav>
<main>
<form autocomplete="off">
<div id="classActivity" style="display:block;"></div><!--didn't want to touch the css-->
<div>
<button type="button">Calculate</button>
<output id="total_output"></output>
<button type="reset">Reset</button>
</div>
</form>
</main>
<footer>
Share on <a href="https://t.me/share/url?url=https%3A%2F%2Fclassactivitycalculator.github.io%2F&text=Class%20Activity%20Calculator%3A%20Calculate%20the%20class%20activity%20grades%20of%20the%20ILI%20students." title="Telegram: Share Web Page">Telegram</a> |
</footer>
</body>

</html>

That way, you'll only need to change the object at the start if an activity (or class, or evaluation) is added or removed.

You should also put all your JS in global.js (or another file if you use global.js somewhere else, but I don't think so), it's good practice.

• "instead of index, adults-new, young-adults and kids having one page each, how about something like that" Using JavaScript to dynamically create content would be beneficial if I had the same HTML across my site pages. As you see on Kids, I don't have even the same number of inputs in each fieldset of the same page. "You should also put all your JS in global.js" I can't as the setOutputValues function is different on each page. My global.js file includes only what the pages have in common.
– Mori
Commented May 14, 2019 at 8:16
• Err, guess I should have called fields input_number instead, my bad ^^ I just swapped adult_old and adult_young (and changed the fields number) in my example as to show the number of inputs adapting. Likewise, if your only difference in setOutputValues is the max value of each input, you can just add another attribute (max) in the listForm object. Commented May 14, 2019 at 8:38

• Simplified
to
Thanks @Sᴀᴍ Onᴇᴌᴀ

• Generalized setOutputValues() mainly by adding a weight to the rest of the fieldsets:
var weight = fieldset.dataset.weight;
if (!weight) {weight = 1;}
Thanks @Nomis

• Simplified
divOutput.value = 'CA: ' + (classActivity / (max / 100)).toFixed(1);
to
divOutput.value = 'CA: ' + (classActivity * 100 / max).toFixed(1);

script.js

var form = document.querySelector('form');

function averageInputValues(fieldset) {
var totalValue = 0;
var totalNumber = 0;
var inputs = fieldset.querySelectorAll('input');
for (var input of inputs) {
if (!input.validity.valid) {
return;
}
totalValue += Number(input.value);
totalNumber += Boolean(input.value);
}
}

function setOutputValues() {
var max = form.querySelector('input').max;
var totalWeightedAverage = 0;
var totalWeight = 0;
var fieldsets = form.querySelectorAll('fieldset');
for (var fieldset of fieldsets) {
var average = averageInputValues(fieldset);
var fieldsetOutput = fieldset.querySelector('output');
if (average == undefined) {
fieldsetOutput.value = 'You may only enter 0 to ' + max + '.';
} else if (isNaN(average)) {
} else {
fieldsetOutput.value = 'avg: ' + average.toFixed(1);
}
var weight = fieldset.dataset.weight;
if (!weight) {
weight = 1;
}
totalWeightedAverage += average * weight;
totalWeight += Number(weight);
}
var classActivity = totalWeightedAverage / totalWeight;
var divOutput = form.querySelector('div output');
if (isNaN(classActivity)) {
divOutput.value = '';
} else if (max == 5) { // Adults: New
divOutput.value = 'CA: ' + (classActivity * 100 / max).toFixed(1); // The class activity grade must be calculated out of 100.
} else {
divOutput.value = 'CA: ' + classActivity.toFixed(1);
}
}

function detectChange() {
var inputs = form.querySelectorAll('input');
for (var input of inputs) {
if (input.value) {
return true;
}
}
}

if (detectChange() && !confirm('Your changes will be lost.\nAre you sure you want to reset?')) {
event.preventDefault();
}
});

if (detectChange()) {
event.returnValue = 'Your changes may be lost.';
}
});

<!DOCTYPE html>
<html lang="en">

<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="Calculate the class activity grades of the ILI students.">
<title>Class Activity Calculator</title>

<body>
<img src="logo.png" alt="Logo">
<h1>Class Activity Calculator</h1>
<nav>
<a href="kids.html">Kids</a>
</nav>
<main>
<form autocomplete="off">
<fieldset data-weight="4">
<legend>Listening & Speaking</legend>
<input type="number" step="any" min="0" max="100">
<input type="number" step="any" min="0" max="100">
<input type="number" step="any" min="0" max="100">
<input type="number" step="any" min="0" max="100">
<input type="number" step="any" min="0" max="100">
<output></output>
</fieldset>
<fieldset data-weight="3">
<input type="number" step="any" min="0" max="100">
<input type="number" step="any" min="0" max="100">
<input type="number" step="any" min="0" max="100">
<input type="number" step="any" min="0" max="100">
<input type="number" step="any" min="0" max="100">
<output></output>
</fieldset>
<fieldset>
<legend>Writing</legend>
<input type="number" step="any" min="0" max="100">
<input type="number" step="any" min="0" max="100">
<input type="number" step="any" min="0" max="100">
<input type="number" step="any" min="0" max="100">
<input type="number" step="any" min="0" max="100">
<output></output>
</fieldset>
<div>
<button type="button">Calculate</button>
<output></output>
<button type="reset">Reset</button>
</div>
</form>
</main>
<footer>
Share on <a href="https://t.me/share/url?url=https%3A%2F%2Fclassactivitycalculator.github.io%2F&text=Class%20Activity%20Calculator%3A%20Calculate%20the%20class%20activity%20grades%20of%20the%20ILI%20students." title="Telegram: Share Web Page">Telegram</a> |