allow scores reset through CSV init
[westcastle.git] / js / index.js
CommitLineData
7a00c409
BA
1new Vue({
2 el: "#mahjong",
3 data: {
4 players: [], //array of objects, filled later
5 display: "players",
6 },
7 components: {
8 'my-players': {
48bee368 9 props: ['players','initPlayers'],
7a00c409
BA
10 template: `
11 <div id="players">
1369a09d 12 <div class="left">
7a00c409
BA
13 <p>Présents</p>
14 <table class="list">
15 <tr v-for="p in sortedPlayers" v-if="p.available" @click="toggleAvailability(p.index)">
16 <td>{{ p.prenom }}</td>
17 <td>{{ p.nom }}</td>
18 </tr>
19 </table>
20 </div>
1369a09d 21 <div id="inactive" class="right">
7a00c409
BA
22 <p>Absents</p>
23 <table class="list">
fd4a69e4 24 <tr v-for="p in sortedPlayers" v-if="!p.available && p.nom!=''" @click="toggleAvailability(p.index)">
7a00c409
BA
25 <td>{{ p.prenom }}</td>
26 <td>{{ p.nom }}</td>
27 </tr>
28 </table>
29 </div>
48bee368
BA
30 <div class="clear">
31 <input class="hide" id="upload" type="file" @change="upload"/>
32 <button class="btn block cancel" @click="uploadTrigger()" title="Charge la liste des joueurs, en principe en début de tournoi">
33 (Ré)initialiser
34 </button>
35 </div>
7a00c409
BA
36 </div>
37 `,
38 computed: {
39 sortedPlayers: function() {
40 return this.players
41 .map( (p,i) => { return Object.assign({}, p, {index: i}); })
42 .sort( (a,b) => {
43 return a.nom.localeCompare(b.nom);
44 });
45 },
46 },
47 methods: {
48 toggleAvailability: function(i) {
49 this.players[i].available = 1 - this.players[i].available;
48bee368
BA
50 },
51 uploadTrigger: function() {
52 document.getElementById("upload").click();
53 },
54 upload: function(e) {
55 let file = (e.target.files || e.dataTransfer.files)[0];
56 var reader = new FileReader();
57 reader.onloadend = ev => {
58 this.initPlayers(ev.currentTarget.result);
59 };
60 reader.readAsText(file);
7a00c409
BA
61 },
62 },
63 },
7a00c409 64 'my-pairings': {
48bee368 65 props: ['players','commitScores'],
7a00c409
BA
66 data: function() {
67 return {
68 unpaired: [],
69 tables: [], //array of arrays of players indices
1d2d7593 70 sessions: [], //"mini-points" for each table
7a00c409 71 currentIndex: -1, //table index for scoring
1369a09d 72 scored: [], //boolean for each table index
7a00c409
BA
73 };
74 },
75 template: `
76 <div id="pairings">
77 <div v-show="currentIndex < 0">
48bee368
BA
78 <div class="button-container-horizontal">
79 <button class="btn validate" :class="{hide: tables.length==0}" @click="commitScores()" title="Valide l'état actuel des scores (cf. rubrique Classement) en mémoire. Cette action est nécessaire au moins une fois en fin de tournoi après toutes les parties, et est recommandée après chaque ronde">
80 Valider
81 </button>
82 <button id="doPairings" class="btn" :class="{cancel: tables.length>0}" @click="doPairings()" title="Répartit les joueurs actifs aléatoirement sur les tables">
83 Nouvelle ronde
84 </button>
85 </div>
1369a09d 86 <div class="pairing" v-for="(table,index) in tables" :class="{scored: scored[index]}"
7a00c409
BA
87 @click="showScoreForm(table,index)">
88 <p>Table {{ index+1 }}</p>
89 <table>
90 <tr v-for="(i,j) in table">
fd4a69e4 91 <td :class="{toto: players[i].prenom=='Toto'}">{{ players[i].prenom }} {{ players[i].nom }}</td>
1d2d7593 92 <td class="score"><span v-show="sessions[index].length > 0">{{ sessions[index][j] }}</span></td>
7a00c409 93 </tr>
7a00c409
BA
94 </table>
95 </div>
96 <div v-if="unpaired.length>0" class="pairing unpaired">
97 <p>Exempts</p>
98 <div v-for="i in unpaired">
99 {{ players[i].prenom }} {{ players[i].nom }}
100 </div>
101 </div>
102 </div>
103 <div id="scoreInput" v-if="currentIndex >= 0">
104 <table>
105 <tr v-for="(index,i) in tables[currentIndex]">
fd4a69e4
BA
106 <td :class="{toto: players[tables[currentIndex][i]].prenom=='Toto'}">
107 {{ players[tables[currentIndex][i]].prenom }} {{ players[tables[currentIndex][i]].nom }}
108 </td>
48bee368 109 <td><input type="text" v-model="sessions[currentIndex][i]" :disabled="scored[currentIndex]"/></td>
7a00c409
BA
110 </tr>
111 </table>
1369a09d 112 <div class="button-container-horizontal">
48bee368
BA
113 <button :class="{hide:scored[currentIndex]}" class="btn validate" @click="setScore()" title="Enregistre le score dans la base (la rubrique Classement est mise à jour)">
114 Enregistrer
115 </button>
116 <button :class="{hide:!scored[currentIndex]}" class="btn cancel" @click="resetScore()" title="Annule le score précédemment enregistré : l'action est visible dans la rubrique Classement">
117 Annuler
118 </button>
119 <button class="btn" @click="closeScoreForm()">Fermer</button>
7a00c409
BA
120 </div>
121 </div>
122 </div>
123 `,
124 methods: {
125 doPairings: function() {
126 // Simple case first: 4 by 4
127 let tables = [];
128 let currentTable = [];
ade10194 129 let ordering = _.shuffle(_.range(this.players.length));
7a00c409
BA
130 for (let i=0; i<ordering.length; i++)
131 {
132 if ( ! this.players[ordering[i]].available )
133 continue;
134 if (currentTable.length >= 4)
135 {
136 tables.push(currentTable);
137 currentTable = [];
138 }
139 currentTable.push(ordering[i]);
140 }
141 // Analyse remainder
142 this.unpaired = [];
143 if (currentTable.length != 0)
144 {
145 if (currentTable.length < 3)
146 {
147 let missingPlayers = 3 - currentTable.length;
148 // Pick players from 'missingPlayers' random different tables, if possible
149 if (tables.length >= missingPlayers)
150 {
151 let tblNums = _.sample(_.range(tables.length), missingPlayers);
152 tblNums.forEach( num => {
153 currentTable.push(tables[num].pop());
154 });
155 }
156 }
157 if (currentTable.length >= 3)
158 tables.push(currentTable);
159 else
160 this.unpaired = currentTable;
161 }
fd4a69e4
BA
162 // Ensure that all tables have 4 players
163 tables.forEach( t => {
164 if (t.length < 4)
165 t.push(0); //index of "Toto", ghost player
166 });
7a00c409 167 this.tables = tables;
1d2d7593 168 this.sessions = tables.map( t => { return []; }); //empty sessions
1369a09d
BA
169 this.scored = tables.map( t => { return false; }); //nothing scored yet
170 this.currentIndex = -1; //required if reset while scoring
7a00c409
BA
171 },
172 showScoreForm: function(table,index) {
1369a09d
BA
173 if (this.sessions[index].length == 0)
174 this.sessions[index] = _.times(table.length, _.constant(0));
7a00c409
BA
175 this.currentIndex = index;
176 },
48bee368
BA
177 closeScoreForm: function() {
178 if (!this.scored[this.currentIndex])
179 this.sessions[this.currentIndex] = [];
180 this.currentIndex = -1;
181 },
182 getPdts: function() {
1d2d7593 183 let sortedSessions = this.sessions[this.currentIndex]
ce0473b4
BA
184 .map( (s,i) => { return {value:parseInt(s), index:i}; })
185 .sort( (a,b) => { return b.value - a.value; });
48bee368 186 const ref_pdts = [4, 2, 1, 0];
1369a09d
BA
187 // NOTE: take care of ex-aequos (spread points subtotal)
188 let curSum = 0, curCount = 0, start = 0;
48bee368 189 let sortedPdts = [];
1369a09d 190 for (let i=0; i<4; i++)
7a00c409 191 {
48bee368 192 curSum += ref_pdts[i];
1369a09d
BA
193 curCount++;
194 if (i==3 || sortedSessions[i].value > sortedSessions[i+1].value)
195 {
196 let pdt = curSum / curCount;
197 for (let j=start; j<=i; j++)
48bee368 198 sortedPdts.push(pdt);
1369a09d
BA
199 curSum = 0;
200 curCount = 0;
201 start = i+1;
202 }
48bee368
BA
203 }
204 // Re-order pdts to match table order
205 let pdts = [0, 0, 0, 0];
206 for (let i=0; i<4; i++)
207 pdts[sortedSessions[i].index] = sortedPdts[i];
208 return pdts;
209 },
210 setScore: function() {
211 let pdts = this.getPdts();
212 for (let i=0; i<4; i++)
213 {
214 this.players[this.tables[this.currentIndex][i]].pdt += pdts[i];
1d2d7593 215 this.players[this.tables[this.currentIndex][i]].session += parseInt(this.sessions[this.currentIndex][i]);
7a00c409 216 }
48bee368 217 Vue.set(this.scored, this.currentIndex, true);
7a00c409 218 this.currentIndex = -1;
7a00c409 219 },
48bee368
BA
220 resetScore: function() {
221 let pdts = this.getPdts();
222 for (let i=0; i<4; i++)
223 {
224 this.players[this.tables[this.currentIndex][i]].pdt -= pdts[i];
225 this.players[this.tables[this.currentIndex][i]].session -= parseInt(this.sessions[this.currentIndex][i]);
226 }
227 Vue.set(this.scored, this.currentIndex, false);
6d7bb9ad 228 },
7a00c409
BA
229 },
230 },
2caa3889
BA
231 'my-timer': {
232 data: function() {
233 return {
234 time: 0, //remaining time, in seconds
235 running: false,
236 };
237 },
238 template: `
8d4d2300 239 <div id="timer" :style="{lineHeight: divHeight + 'px', fontSize: 0.66*divHeight + 'px', width: divWidth + 'px', height: divHeight + 'px'}">
2caa3889
BA
240 <div @click.left="pauseResume()" @click.right.prevent="reset()" :class="{timeout:time==0}">
241 {{ formattedTime }}
242 </div>
243 <img class="close-cross" src="img/cross.svg" @click="$emit('clockover')"/>
244 </div>
245 `,
246 computed: {
247 formattedTime: function() {
248 let seconds = this.time % 60;
249 let minutes = Math.floor(this.time / 60);
250 return this.padToZero(minutes) + ":" + this.padToZero(seconds);
251 },
8d4d2300 252 divHeight: function() {
6d7bb9ad
BA
253 return screen.height;
254 },
8d4d2300
BA
255 divWidth: function() {
256 return screen.width;
257 },
2caa3889
BA
258 },
259 methods: {
260 padToZero: function(a) {
261 if (a < 10)
262 return "0" + a;
263 return a;
264 },
265 pauseResume: function() {
266 this.running = !this.running;
267 if (this.running)
268 this.start();
269 },
270 reset: function(e) {
271 this.running = false;
48b3a536 272 this.time = 5400; //1:30
2caa3889
BA
273 },
274 start: function() {
275 if (!this.running)
276 return;
277 if (this.time == 0)
278 {
279 new Audio("sounds/gong.mp3").play();
280 this.running = false;
281 return;
282 }
283 setTimeout(() => {
284 if (this.running)
285 this.time--;
286 this.start();
287 }, 1000);
288 },
289 },
290 created: function() {
291 this.reset();
292 },
293 },
48b3a536 294 'my-ranking': {
48bee368 295 props: ['players','sortByScore','commitScores'],
48b3a536
BA
296 template: `
297 <div id="ranking">
298 <table class="ranking">
299 <tr class="title">
300 <th>Rang</th>
301 <th>Joueur</th>
302 <th>Points</th>
303 <th>Mini-pts</th>
304 </tr>
305 <tr v-for="p in sortedPlayers">
306 <td>{{ p.rank }}</td>
307 <td>{{ p.prenom }} {{ p.nom }}</td>
308 <td>{{ p.pdt }}</td>
309 <td>{{ p.session }}</td>
310 </tr>
311 </table>
312 <div class="button-container-vertical" style="width:200px">
48bee368
BA
313 <a id="download" href="#"></a>
314 <button class="btn" @click="download()" title="Télécharge le classement courant au format CSV">Télécharger</button>
315 <button class="btn cancel" @click="resetPlayers()" title="Réinitialise les scores à zéro. ATTENTION: action irréversible">
316 Réinitialiser
317 </button>
48b3a536
BA
318 </div>
319 </div>
320 `,
321 computed: {
322 sortedPlayers: function() {
323 let res = this.rankPeople();
324 // Add rank information (taking care of ex-aequos)
325 let rank = 1;
326 for (let i=0; i<res.length; i++)
327 {
328 if (i==0 || this.sortByScore(res[i],res[i-1]) == 0)
329 res[i].rank = rank;
330 else //strictly lower scoring
331 res[i].rank = ++rank;
332 }
333 return res;
334 },
335 },
336 methods: {
337 rankPeople: function() {
338 return this.players
339 .slice(1) //discard Toto
48b3a536
BA
340 .sort(this.sortByScore);
341 },
342 resetPlayers: function() {
48bee368
BA
343 if (confirm('Êtes-vous sûr ?'))
344 {
345 this.players
346 .slice(1) //discard Toto
347 .forEach( p => {
348 p.pdt = 0;
349 p.session = 0;
350 p.available = 1;
351 });
352 this.commitScores();
353 document.getElementById("doPairings").click();
354 }
355 },
356 download: function() {
357 // Prepare file content
358 let content = "prénom,nom,pdt,session\n";
48b3a536
BA
359 this.players
360 .slice(1) //discard Toto
48bee368 361 .sort(this.sortByScore)
48b3a536 362 .forEach( p => {
48bee368 363 content += p.prenom + "," + p.nom + "," + p.pdt + "," + p.session + "\n";
48b3a536 364 });
48bee368
BA
365 // Prepare and trigger download link
366 let downloadAnchor = document.getElementById("download");
367 downloadAnchor.setAttribute("download", "classement.csv");
368 downloadAnchor.href = "data:text/plain;charset=utf-8," + encodeURIComponent(content);
369 downloadAnchor.click();
48b3a536
BA
370 },
371 },
372 },
7a00c409
BA
373 },
374 created: function() {
48bee368
BA
375 let players = JSON.parse(localStorage.getItem("players"));
376 if (players !== null)
377 {
378 this.addToto(players);
379 this.players = players;
380 }
7a00c409 381 },
ade10194 382 methods: {
48bee368
BA
383 addToto: function(array) {
384 array.unshift({ //add ghost 4th player for 3-players tables
385 prenom: "Toto",
386 nom: "",
387 pdt: 0,
388 session: 0,
389 available: 0,
390 });
391 },
1369a09d 392 // Used both in ranking and pairings:
ade10194
BA
393 sortByScore: function(a,b) {
394 return b.pdt - a.pdt + (Math.atan(b.session - a.session) / (Math.PI/2)) / 2;
395 },
48bee368
BA
396 commitScores: function() {
397 localStorage.setItem(
398 "players",
399 JSON.stringify(this.players.slice(1)) //discard Toto
400 );
401 },
402 // Used in players, reinit players array
403 initPlayers: function(csv) {
404 const allLines = csv
405 .split(/\r\n|\n|\r/) //line breaks
406 .splice(1); //discard header
407 let players = allLines
408 .filter( line => { return line.length > 0; }) //remove empty lines
409 .map( line => {
410 let parts = line.split(",");
d940eb68
BA
411 let p = { prenom: parts[0], nom: parts[1] };
412 p.pdt = parts.length > 2 ? parseFloat(parts[2]) : 0;
413 p.session = parts.length > 3 ? parseInt(parts[3]) : 0;
414 p.available = parts.length > 4 ? parts[4] : 1;
415 return p;
48bee368
BA
416 });
417 this.addToto(players);
418 this.players = players;
55043689 419 this.commitScores(); //save players in memory
1369a09d 420 },
ade10194 421 },
7a00c409 422});