codeapp-js 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/.github/instructions/wyattdave.instructions.md +39 -0
  2. package/codeApp/dist/codeapp.js +235 -4
  3. package/codeApp/dist/index.js +1 -1
  4. package/codeApp/dist/power-apps-data.js +170 -591
  5. package/dev files/dataverse.js +7 -22
  6. package/dev files/environmentVar.js +1 -1
  7. package/dev files/office365groups.js +1 -1
  8. package/dev files/office365users.js +1 -1
  9. package/dev files/outlook.js +219 -10
  10. package/dev files/sharepoint.js +1 -1
  11. package/examples/combined demo/dist/codeapp.js +1098 -1098
  12. package/examples/combined demo/dist/index.js +470 -470
  13. package/examples/combined demo/dist/power-apps-data.js +3006 -3006
  14. package/examples/combined demo/power.config.json +42 -42
  15. package/examples/dataverse Demo/dist/codeapp.js +1085 -1085
  16. package/examples/dataverse Demo/dist/index.html +54 -54
  17. package/examples/dataverse Demo/dist/index.js +82 -82
  18. package/examples/dataverse Demo/dist/power-apps-data.js +2911 -2911
  19. package/examples/dataverse Demo/power.config.json +34 -34
  20. package/examples/dataverse Demo/readme.md +79 -79
  21. package/examples/groups Demo/dist/codeapp.js +1085 -1085
  22. package/examples/groups Demo/dist/index.js +113 -113
  23. package/examples/groups Demo/dist/power-apps-data.js +2911 -2911
  24. package/examples/kanban/dist/dataverse.js +94 -94
  25. package/examples/kanban/dist/environmentVar.js +55 -55
  26. package/examples/kanban/dist/office365groups.js +97 -97
  27. package/examples/kanban/dist/office365users.js +169 -169
  28. package/examples/kanban/dist/outlook.js +162 -162
  29. package/examples/kanban/dist/power-apps-data.js +2953 -2953
  30. package/examples/kanban/dist/sharepoint.js +339 -339
  31. package/examples/myProfile/dist/index.html +184 -184
  32. package/examples/myProfile/dist/index.js +141 -141
  33. package/examples/myProfile/dist/office365users.js +169 -169
  34. package/examples/myProfile/dist/power-apps-data.js +2953 -2953
  35. package/examples/myProfile/power.config.json +22 -22
  36. package/examples/myProfile/readme.md +79 -79
  37. package/examples/outlook Demo/dist/codeapp.js +1085 -1085
  38. package/examples/outlook Demo/dist/index.html +35 -35
  39. package/examples/outlook Demo/dist/index.js +170 -170
  40. package/examples/outlook Demo/dist/outlook.js +121 -121
  41. package/examples/outlook Demo/dist/power-apps-data.js +2911 -2911
  42. package/examples/outlook Demo/dist/styles.css +84 -84
  43. package/examples/outlook Demo/readme.md +82 -82
  44. package/examples/outlook Demo2/OutlookDemo_1_0_0_1.zip +0 -0
  45. package/examples/outlook Demo2/agent/decision-log.md +7 -0
  46. package/examples/outlook Demo2/dist/codeapp.js +1334 -0
  47. package/examples/outlook Demo2/dist/icon-512.png +0 -0
  48. package/examples/outlook Demo2/dist/index.html +98 -0
  49. package/examples/outlook Demo2/dist/index.js +346 -0
  50. package/{dev files → examples/outlook Demo2/dist}/power-apps-data.js +73 -18
  51. package/examples/outlook Demo2/dist/styles.css +639 -0
  52. package/examples/outlook Demo2/power.config.json +23 -0
  53. package/examples/outlook Demo2/src/generated/index.ts +14 -0
  54. package/examples/outlook Demo2/src/generated/models/Office365GroupsModel.ts +363 -0
  55. package/examples/outlook Demo2/src/generated/models/Office365OutlookModel.ts +2046 -0
  56. package/examples/outlook Demo2/src/generated/models/Office365UsersModel.ts +254 -0
  57. package/examples/outlook Demo2/src/generated/services/Office365GroupsService.ts +326 -0
  58. package/examples/outlook Demo2/src/generated/services/Office365OutlookService.ts +2476 -0
  59. package/examples/outlook Demo2/src/generated/services/Office365UsersService.ts +358 -0
  60. package/examples/planning Poker/.vscode/settings.json +4 -4
  61. package/examples/planning Poker/additional files/customizations (tables).xml +6428 -6428
  62. package/examples/planning Poker/additional files/dataverse-tables.json +165 -165
  63. package/examples/planning Poker/additional files/readme.md +122 -122
  64. package/examples/planning Poker/dist/dataverse.js +78 -78
  65. package/examples/planning Poker/dist/index.html +198 -198
  66. package/examples/planning Poker/dist/index.js +954 -954
  67. package/examples/planning Poker/dist/power-apps-data.js +2953 -2953
  68. package/examples/planning Poker/dist/styles.css +815 -815
  69. package/examples/sharePoint Demo/agent/decision-log.md +5 -5
  70. package/examples/sharePoint Demo/dist/codeapp.js +1085 -1085
  71. package/examples/sharePoint Demo/dist/index.js +262 -262
  72. package/examples/sharePoint Demo/dist/power-apps-data.js +2911 -2911
  73. package/examples/sharePoint Demo/power.config.json +22 -22
  74. package/examples/solution explorer/agent/decision-log.md +27 -27
  75. package/examples/solution explorer/agent/mockup-01-swiss-grid.html +452 -452
  76. package/examples/solution explorer/agent/mockup-02-dark-glass.html +496 -496
  77. package/examples/solution explorer/agent/mockup-03-paper-console.html +510 -510
  78. package/examples/solution explorer/agent/mockup-04-neon-noir.html +546 -546
  79. package/examples/solution explorer/agent/mockup-05-zen-garden.html +534 -534
  80. package/examples/solution explorer/dist/codeapp.js +1098 -1098
  81. package/examples/solution explorer/dist/index.html +80 -80
  82. package/examples/solution explorer/dist/index.js +735 -735
  83. package/examples/solution explorer/dist/power-apps-data.js +3006 -3006
  84. package/examples/solution explorer/dist/styles.css +571 -571
  85. package/examples/solution explorer/power.config.json +150 -150
  86. package/examples/todo/dist/dataverse.js +64 -64
  87. package/examples/todo/dist/index.html +75 -75
  88. package/examples/todo/dist/index.js +8 -8
  89. package/examples/todo/dist/power-apps-data.js +2953 -2953
  90. package/examples/todo/dist/renderer.js +375 -375
  91. package/examples/todo/dist/styles.css +691 -691
  92. package/examples/todo/power.config.json +34 -34
  93. package/package.json +1 -1
  94. package/scripts/build-power-sdk.mjs +69 -0
  95. package/dev files/customConnector.js +0 -98
@@ -1,955 +1,955 @@
1
- import { createItem, listItems, updateItem, getItem, deleteItem } from './dataverse.js';
2
-
3
- /* ═══════════════════════════════════════════
4
- Planning Poker — Main Application Logic
5
- ═══════════════════════════════════════════ */
6
-
7
- // ── Table & Key Constants ──────────────────
8
- const S_TBL_SESSION = 'wd_pokersessions';
9
- const S_TBL_PARTICIPANT = 'wd_pokerparticipants';
10
- const S_TBL_ROUND = 'wd_pokerrounds';
11
- const S_TBL_VOTE = 'wd_pokervotes';
12
- const S_PK_SESSION = 'wd_pokersessionid';
13
- const S_PK_PARTICIPANT = 'wd_pokerparticipantid';
14
- const S_PK_ROUND = 'wd_pokerroundid';
15
- const S_PK_VOTE = 'wd_pokervoteid';
16
-
17
- // ── Card Values (Fibonacci + specials) ─────
18
- const A_CARD_VALUES = [
19
- { sValue: '0', sDisplay: '0' },
20
- { sValue: '1', sDisplay: '1' },
21
- { sValue: '2', sDisplay: '2' },
22
- { sValue: '3', sDisplay: '3' },
23
- { sValue: '5', sDisplay: '5' },
24
- { sValue: '8', sDisplay: '8' },
25
- { sValue: '13', sDisplay: '13' },
26
- { sValue: '21', sDisplay: '21' },
27
- { sValue: '34', sDisplay: '34' },
28
- { sValue: '?', sDisplay: '?' },
29
- { sValue: 'coffee', sDisplay: '\u2615' },
30
- { sValue: 'infinity', sDisplay: '\u221E' }
31
- ];
32
-
33
- // ── App State ──────────────────────────────
34
- let sSessionId = '';
35
- let sSessionCode = '';
36
- let sSessionName = '';
37
- let sParticipantId = '';
38
- let sDisplayName = '';
39
- let bIsOwner = false;
40
- let sCurrentRoundId = '';
41
- let sCurrentRoundIdentifier = '';
42
- let sCurrentRoundDescription = '';
43
- let sSelectedCard = '';
44
- let bHasVoted = false;
45
- let iCurrentRoundStatus = -1;
46
- let iPollInterval = null;
47
- let aParticipants = [];
48
- let aCurrentVotes = [];
49
- let sDismissedRoundId = '';
50
-
51
- // ── DOM References ─────────────────────────
52
- let eViewWelcome, eViewSession, eViewResults;
53
- let eSessionInfo, eSessionBadge, eSessionNameText, eParticipantsCount, eVoteStatus;
54
- let eNewRoundPanel, eWaitingPanel, eActiveRoundPanel;
55
- let eRoundBadge, eRoundTitle, eRoundDescription;
56
- let eParticipantsStrip, eCardHand, eCardSection;
57
- let eBtnSubmit, eBtnReveal, eVotedMessage, eVoteActions;
58
- let eResultsBadge, eResultsTitle, eStatAverage, eStatMedian, eVotesGrid;
59
- let eBtnNewRound, eBtnWaitingNext;
60
- let eFooterInfo, eErrorMessage;
61
- let eBtnEndSession, eBtnShare;
62
- let ePreviousRoundsPanel, ePreviousRoundsBody;
63
- let aPreviousRounds = [];
64
- let sSortCol = '';
65
- let sSortDir = '';
66
-
67
- function cacheDomElements() {
68
- eViewWelcome = document.getElementById('view-welcome');
69
- eViewSession = document.getElementById('view-session');
70
- eViewResults = document.getElementById('view-results');
71
-
72
- eSessionInfo = document.getElementById('session-info');
73
- eSessionBadge = document.getElementById('session-badge');
74
- eSessionNameText = document.getElementById('session-name-text');
75
- eParticipantsCount = document.getElementById('participants-count');
76
- eVoteStatus = document.getElementById('vote-status');
77
-
78
- eNewRoundPanel = document.getElementById('new-round-panel');
79
- eWaitingPanel = document.getElementById('waiting-panel');
80
- eActiveRoundPanel = document.getElementById('active-round-panel');
81
-
82
- eRoundBadge = document.getElementById('round-badge');
83
- eRoundTitle = document.getElementById('round-title');
84
- eRoundDescription = document.getElementById('round-description');
85
-
86
- eParticipantsStrip = document.getElementById('participants-strip');
87
- eCardHand = document.getElementById('card-hand');
88
- eCardSection = document.getElementById('card-section');
89
-
90
- eBtnSubmit = document.getElementById('btn-submit');
91
- eBtnReveal = document.getElementById('btn-reveal');
92
- eVotedMessage = document.getElementById('voted-message');
93
- eVoteActions = document.getElementById('vote-actions');
94
-
95
- eResultsBadge = document.getElementById('results-badge');
96
- eResultsTitle = document.getElementById('results-title');
97
- eStatAverage = document.getElementById('stat-average');
98
- eStatMedian = document.getElementById('stat-median');
99
- eVotesGrid = document.getElementById('votes-grid');
100
-
101
- eBtnNewRound = document.getElementById('btn-new-round');
102
- eBtnWaitingNext = document.getElementById('btn-waiting-next');
103
-
104
- eFooterInfo = document.getElementById('footer-info');
105
- eErrorMessage = document.getElementById('error-message');
106
- eBtnEndSession = document.getElementById('btn-end-session');
107
- eBtnShare = document.getElementById('btn-share');
108
- ePreviousRoundsPanel = document.getElementById('previous-rounds-panel');
109
- ePreviousRoundsBody = document.getElementById('previous-rounds-body');
110
- }
111
-
112
- // ── Boot ───────────────────────────────────
113
- async function boot() {
114
- cacheDomElements();
115
- setupEventListeners();
116
- renderCardHand();
117
-
118
- // Resume session from storage if available
119
- const sStoredSession = sessionStorage.getItem('pp_sessionId');
120
- const sStoredParticipant = sessionStorage.getItem('pp_participantId');
121
-
122
- if (sStoredSession && sStoredParticipant) {
123
- try {
124
- const oSession = await getItem(S_TBL_SESSION, S_PK_SESSION, sStoredSession);
125
- if (oSession && oSession.wd_isactive) {
126
- const oParticipant = await getItem(S_TBL_PARTICIPANT, S_PK_PARTICIPANT, sStoredParticipant);
127
- if (!oParticipant || !oParticipant[S_PK_PARTICIPANT]) {
128
- throw new Error('Participant not found');
129
- }
130
- sSessionId = sStoredSession;
131
- sParticipantId = sStoredParticipant;
132
- bIsOwner = !!(oSession._createdby_value && oParticipant._createdby_value && oSession._createdby_value === oParticipant._createdby_value);
133
- sSessionCode = oSession.wd_sessioncode;
134
- sSessionName = oSession.wd_name;
135
- sDisplayName = sessionStorage.getItem('pp_displayName') || '';
136
- enterSession();
137
- return;
138
- }
139
- } catch (oErr) {
140
- clearSessionStorage();
141
- }
142
- }
143
-
144
- // Check for ?session= param in URL
145
- var oParams = new URLSearchParams(window.location.search);
146
- var sJoinCode = oParams.get('session');
147
- if (sJoinCode) {
148
- document.getElementById('inp-join-code').value = sJoinCode.toUpperCase();
149
- document.getElementById('inp-join-name').focus();
150
- }
151
-
152
- showView('welcome');
153
- loadPreviousRounds();
154
- }
155
-
156
- // ── View Management ────────────────────────
157
- function showView(sView) {
158
- eViewWelcome.style.display = sView === 'welcome' ? '' : 'none';
159
- eViewSession.style.display = sView === 'session' ? '' : 'none';
160
- eViewResults.style.display = sView === 'results' ? '' : 'none';
161
-
162
- if (sView === 'welcome') {
163
- eSessionInfo.style.display = 'none';
164
- eParticipantsCount.style.display = 'none';
165
- }
166
- }
167
-
168
- async function enterSession() {
169
- showView('session');
170
- updateHeaderForSession();
171
-
172
- // Hide all sub-panels — pollSessionState will show the correct one
173
- eActiveRoundPanel.style.display = 'none';
174
- eNewRoundPanel.style.display = 'none';
175
- eWaitingPanel.style.display = 'none';
176
-
177
- await pollSessionState();
178
-
179
- // Fallback: if poll failed silently and no panel was shown, show defaults
180
- if (eActiveRoundPanel.style.display === 'none' &&
181
- eNewRoundPanel.style.display === 'none' &&
182
- eWaitingPanel.style.display === 'none' &&
183
- eViewResults.style.display === 'none') {
184
- if (bIsOwner) {
185
- eNewRoundPanel.style.display = '';
186
- } else {
187
- eWaitingPanel.style.display = '';
188
- }
189
- }
190
-
191
- stopPolling();
192
- iPollInterval = setInterval(pollSessionState, 3000);
193
- }
194
-
195
- function updateHeaderForSession() {
196
- eSessionInfo.style.display = '';
197
- eSessionBadge.textContent = sSessionCode;
198
- eSessionNameText.textContent = sSessionName;
199
- eParticipantsCount.style.display = '';
200
- document.getElementById('btn-leave').style.display = '';
201
- eBtnShare.style.display = '';
202
- eBtnEndSession.style.display = bIsOwner ? '' : 'none';
203
- eFooterInfo.textContent = 'Session: ' + sSessionName + ' \u2022 Code: ' + sSessionCode;
204
- }
205
-
206
- // ── Event Listeners ────────────────────────
207
- function setupEventListeners() {
208
- document.getElementById('btn-create').addEventListener('click', handleCreateSession);
209
- document.getElementById('btn-join').addEventListener('click', handleJoinSession);
210
- document.getElementById('btn-start-round').addEventListener('click', handleStartRound);
211
- document.getElementById('btn-leave').addEventListener('click', handleLeaveSession);
212
- eBtnEndSession.addEventListener('click', handleEndSession);
213
- eBtnShare.addEventListener('click', handleShareSession);
214
-
215
- eCardHand.addEventListener('click', (oEvent) => {
216
- const eTarget = oEvent.target.closest('.poker-card');
217
- if (eTarget && !bHasVoted) {
218
- selectCard(eTarget);
219
- }
220
- });
221
-
222
- eBtnSubmit.addEventListener('click', handleSubmitVote);
223
- eBtnReveal.addEventListener('click', handleRevealVotes);
224
- eBtnNewRound.addEventListener('click', handleNewRound);
225
- }
226
-
227
- // ── Card Rendering & Selection ─────────────
228
- function renderCardHand() {
229
- let sHtml = '';
230
- A_CARD_VALUES.forEach((oCard) => {
231
- const sSpecial = (oCard.sValue === '?' || oCard.sValue === 'coffee' || oCard.sValue === 'infinity') ? ' special' : '';
232
- sHtml = sHtml + '<div class="poker-card' + sSpecial + '" data-value="' + escapeAttr(oCard.sValue) + '">' + escapeHtml(oCard.sDisplay) + '</div>';
233
- });
234
- eCardHand.innerHTML = sHtml;
235
- }
236
-
237
- function selectCard(eCard) {
238
- const aCards = eCardHand.querySelectorAll('.poker-card');
239
- aCards.forEach((eC) => {
240
- eC.classList.remove('selected');
241
- });
242
- eCard.classList.add('selected');
243
- sSelectedCard = eCard.getAttribute('data-value');
244
- eBtnSubmit.disabled = false;
245
- }
246
-
247
- // ── Find or Create Participant (upsert) ────
248
- async function findOrCreateParticipant(sTargetSessionId, sUserName) {
249
- // Create a participant so we can identify the current user via _createdby_value
250
- const oNewParticipant = await createItem(S_TBL_PARTICIPANT, S_PK_PARTICIPANT, {
251
- 'wd_session@odata.bind': '/' + S_TBL_SESSION + '(' + sTargetSessionId + ')',
252
- wd_newcolumn: sUserName,
253
- wd_initials: getInitials(sUserName)
254
- });
255
- const sNewId = oNewParticipant[S_PK_PARTICIPANT];
256
-
257
- // Read it back to get the system _createdby_value
258
- const oFull = await getItem(S_TBL_PARTICIPANT, S_PK_PARTICIPANT, sNewId);
259
- const sUserId = oFull._createdby_value;
260
-
261
- if (!sUserId) {
262
- return sNewId;
263
- }
264
-
265
- // Check if this user already had a participant in this session
266
- const oExisting = await listItems(S_TBL_PARTICIPANT, S_PK_PARTICIPANT, {
267
- filter: '_wd_session_value eq ' + sTargetSessionId
268
- });
269
- const aOthers = (oExisting.entities || []).filter(function(oP) {
270
- return oP._createdby_value === sUserId && oP[S_PK_PARTICIPANT] !== sNewId;
271
- });
272
-
273
- if (aOthers.length > 0) {
274
- // Keep the original participant (it has votes linked to it), update its name
275
- const oKeep = aOthers[0];
276
- await updateItem(S_TBL_PARTICIPANT, S_PK_PARTICIPANT, oKeep[S_PK_PARTICIPANT], {
277
- wd_newcolumn: sUserName,
278
- wd_initials: getInitials(sUserName)
279
- });
280
- // Remove the duplicate we just created
281
- try { await deleteItem(S_TBL_PARTICIPANT, S_PK_PARTICIPANT, sNewId); } catch (e) { /* non-critical */ }
282
- return oKeep[S_PK_PARTICIPANT];
283
- }
284
-
285
- return sNewId;
286
- }
287
-
288
- // ── Create Session ─────────────────────────
289
- async function handleCreateSession() {
290
- const sName = document.getElementById('inp-session-name').value.trim();
291
- const sCode = document.getElementById('inp-session-code').value.trim().toUpperCase();
292
- const sUserName = document.getElementById('inp-create-name').value.trim();
293
-
294
- if (sName === '' || sCode === '' || sUserName === '') {
295
- showError('Please fill in all fields');
296
- return;
297
- }
298
-
299
- try {
300
- const oSession = await createItem(S_TBL_SESSION, S_PK_SESSION, {
301
- wd_name: sName,
302
- wd_sessioncode: sCode,
303
- wd_isactive: true
304
- });
305
-
306
- sSessionId = oSession[S_PK_SESSION];
307
- sSessionCode = sCode;
308
- sSessionName = sName;
309
- sDisplayName = sUserName;
310
- bIsOwner = true;
311
-
312
- sParticipantId = await findOrCreateParticipant(sSessionId, sUserName);
313
- saveSessionStorage();
314
- enterSession();
315
- } catch (oErr) {
316
- showError('Create session: ' + (oErr.message || oErr));
317
- }
318
- }
319
-
320
- // ── Join Session ───────────────────────────
321
- async function handleJoinSession() {
322
- const sCode = document.getElementById('inp-join-code').value.trim().toUpperCase();
323
- const sUserName = document.getElementById('inp-join-name').value.trim();
324
-
325
- if (sCode === '' || sUserName === '') {
326
- showError('Please fill in all fields');
327
- return;
328
- }
329
-
330
- try {
331
- const oResult = await listItems(S_TBL_SESSION, S_PK_SESSION, {
332
- filter: 'wd_sessioncode eq \'' + sCode + '\' and wd_isactive eq true'
333
- });
334
-
335
- if (!oResult.entities || oResult.entities.length === 0) {
336
- showError('Session not found. Check the code and try again.');
337
- return;
338
- }
339
-
340
- const oSession = oResult.entities[0];
341
- sSessionId = oSession[S_PK_SESSION];
342
- sSessionCode = oSession.wd_sessioncode;
343
- sSessionName = oSession.wd_name;
344
- sDisplayName = sUserName;
345
-
346
- sParticipantId = await findOrCreateParticipant(sSessionId, sUserName);
347
-
348
- // Determine ownership by comparing session creator with current user
349
- const oParticipantFull = await getItem(S_TBL_PARTICIPANT, S_PK_PARTICIPANT, sParticipantId);
350
- bIsOwner = !!(oSession._createdby_value && oParticipantFull._createdby_value && oSession._createdby_value === oParticipantFull._createdby_value);
351
-
352
- saveSessionStorage();
353
- enterSession();
354
- } catch (oErr) {
355
- showError('Join session: ' + (oErr.message || oErr));
356
- }
357
- }
358
-
359
- // ── Start Round ────────────────────────────
360
- async function handleStartRound() {
361
- const sRoundId = document.getElementById('inp-round-id').value.trim();
362
- const sDesc = document.getElementById('inp-round-desc').value.trim();
363
-
364
- if (sRoundId === '') {
365
- showError('Please enter a round ID');
366
- return;
367
- }
368
-
369
- try {
370
- const oRound = await createItem(S_TBL_ROUND, S_PK_ROUND, {
371
- 'wd_session@odata.bind': '/' + S_TBL_SESSION + '(' + sSessionId + ')',
372
- wd_roundidentifier: sRoundId,
373
- wd_description: sDesc,
374
- wd_newcolumn: '0'
375
- });
376
-
377
- sCurrentRoundId = oRound[S_PK_ROUND];
378
- sCurrentRoundIdentifier = sRoundId;
379
- sCurrentRoundDescription = sDesc;
380
- iCurrentRoundStatus = 0;
381
- bHasVoted = false;
382
- sSelectedCard = '';
383
- aCurrentVotes = [];
384
-
385
- document.getElementById('inp-round-id').value = '';
386
- document.getElementById('inp-round-desc').value = '';
387
-
388
- showActiveRound();
389
- } catch (oErr) {
390
- showError('Start round: ' + (oErr.message || oErr));
391
- }
392
- }
393
-
394
- function showActiveRound() {
395
- eNewRoundPanel.style.display = 'none';
396
- eWaitingPanel.style.display = 'none';
397
- eActiveRoundPanel.style.display = '';
398
-
399
- eRoundBadge.textContent = sCurrentRoundIdentifier;
400
- eRoundTitle.textContent = sCurrentRoundDescription || sCurrentRoundIdentifier;
401
- eRoundDescription.textContent = sCurrentRoundDescription;
402
-
403
- // Reset voting UI
404
- eCardSection.style.display = '';
405
- eVoteActions.style.display = '';
406
- eVotedMessage.style.display = 'none';
407
- eBtnSubmit.style.display = '';
408
- eBtnSubmit.disabled = true;
409
- eBtnSubmit.textContent = 'Submit Vote';
410
- eBtnReveal.style.display = bIsOwner ? '' : 'none';
411
-
412
- // Reset card selection
413
- const aCards = eCardHand.querySelectorAll('.poker-card');
414
- aCards.forEach((eC) => {
415
- eC.classList.remove('selected');
416
- });
417
- sSelectedCard = '';
418
- bHasVoted = false;
419
- }
420
-
421
- // ── Submit Vote ────────────────────────────
422
- async function handleSubmitVote() {
423
- if (sSelectedCard === '' || bHasVoted) {
424
- return;
425
- }
426
-
427
- try {
428
- // Check for an existing vote by this participant in this round
429
- const oExistingVotes = await listItems(S_TBL_VOTE, S_PK_VOTE, {
430
- filter: '_wd_round_value eq ' + sCurrentRoundId + ' and _wd_participant_value eq ' + sParticipantId
431
- });
432
-
433
- if (oExistingVotes.entities && oExistingVotes.entities.length > 0) {
434
- // Update existing vote
435
- await updateItem(S_TBL_VOTE, S_PK_VOTE, oExistingVotes.entities[0][S_PK_VOTE], {
436
- wd_score: sSelectedCard
437
- });
438
- } else {
439
- // Create new vote
440
- await createItem(S_TBL_VOTE, S_PK_VOTE, {
441
- 'wd_round@odata.bind': '/' + S_TBL_ROUND + '(' + sCurrentRoundId + ')',
442
- 'wd_participant@odata.bind': '/' + S_TBL_PARTICIPANT + '(' + sParticipantId + ')',
443
- wd_score: sSelectedCard
444
- });
445
- }
446
-
447
- bHasVoted = true;
448
- eCardSection.style.display = 'none';
449
- eVoteActions.style.display = bIsOwner ? '' : 'none';
450
- eVotedMessage.style.display = '';
451
- eBtnSubmit.style.display = 'none';
452
- } catch (oErr) {
453
- showError('Submit vote: ' + (oErr.message || oErr));
454
- }
455
- }
456
-
457
- // ── Reveal Votes ───────────────────────────
458
- async function handleRevealVotes() {
459
- if (!bIsOwner) return;
460
-
461
- try {
462
- const oVoteResult = await listItems(S_TBL_VOTE, S_PK_VOTE, {
463
- filter: '_wd_round_value eq ' + sCurrentRoundId
464
- });
465
- aCurrentVotes = oVoteResult.entities || [];
466
-
467
- const oStats = calculateStats(aCurrentVotes);
468
-
469
- await updateItem(S_TBL_ROUND, S_PK_ROUND, sCurrentRoundId, {
470
- wd_newcolumn: '1',
471
- wd_averagescore: oStats.iAverage,
472
- wd_medianscore: oStats.iMedian
473
- });
474
-
475
- iCurrentRoundStatus = 1;
476
- showResults(aCurrentVotes, oStats);
477
- } catch (oErr) {
478
- showError('Reveal votes: ' + (oErr.message || oErr));
479
- }
480
- }
481
-
482
- // ── New Round ──────────────────────────────
483
- function handleNewRound() {
484
- sDismissedRoundId = sCurrentRoundId;
485
- sCurrentRoundId = '';
486
- sCurrentRoundIdentifier = '';
487
- sCurrentRoundDescription = '';
488
- iCurrentRoundStatus = -1;
489
- bHasVoted = false;
490
- sSelectedCard = '';
491
- aCurrentVotes = [];
492
-
493
- showView('session');
494
- updateHeaderForSession();
495
- eActiveRoundPanel.style.display = 'none';
496
-
497
- if (bIsOwner) {
498
- eNewRoundPanel.style.display = '';
499
- eWaitingPanel.style.display = 'none';
500
- } else {
501
- eNewRoundPanel.style.display = 'none';
502
- eWaitingPanel.style.display = '';
503
- }
504
- }
505
-
506
- // ── Results Display ────────────────────────
507
- function showResults(aVotes, oStats) {
508
- showView('results');
509
- updateHeaderForSession();
510
-
511
- eResultsBadge.textContent = sCurrentRoundIdentifier;
512
- eResultsTitle.textContent = sCurrentRoundDescription || sCurrentRoundIdentifier;
513
- eStatAverage.textContent = oStats.iAverage !== null ? oStats.iAverage.toFixed(1) : '\u2014';
514
- eStatMedian.textContent = oStats.iMedian !== null ? oStats.iMedian.toFixed(1) : '\u2014';
515
-
516
- let sHtml = '';
517
- aVotes.forEach((oVote) => {
518
- const oParticipant = aParticipants.find((oP) => oP[S_PK_PARTICIPANT] === oVote._wd_participant_value);
519
- const sName = oParticipant ? oParticipant.wd_newcolumn : 'Unknown';
520
- const sInitials = oParticipant ? oParticipant.wd_initials : '??';
521
- const sScore = formatScoreDisplay(oVote.wd_score);
522
-
523
- sHtml = sHtml + '<div class="vote-card">' +
524
- '<div class="vote-card-avatar">' + escapeHtml(sInitials) + '</div>' +
525
- '<div class="vote-card-info">' +
526
- '<span class="vote-card-name">' + escapeHtml(sName) + '</span>' +
527
- '<span class="vote-card-value">' + escapeHtml(sScore) + '</span>' +
528
- '</div>' +
529
- '</div>';
530
- });
531
- eVotesGrid.innerHTML = sHtml;
532
-
533
- if (bIsOwner) {
534
- eBtnNewRound.style.display = '';
535
- eBtnWaitingNext.style.display = 'none';
536
- } else {
537
- eBtnNewRound.style.display = 'none';
538
- eBtnWaitingNext.style.display = '';
539
- }
540
- }
541
-
542
- // ── Polling ────────────────────────────────
543
- function startPolling() {
544
- stopPolling();
545
- pollSessionState();
546
- iPollInterval = setInterval(pollSessionState, 3000);
547
- }
548
-
549
- function stopPolling() {
550
- if (iPollInterval) {
551
- clearInterval(iPollInterval);
552
- iPollInterval = null;
553
- }
554
- }
555
-
556
- // Determine if a round is still open (no results yet)
557
- function isRoundOpen(oRound) {
558
- // A round is revealed if it has average/median scores OR wd_newcolumn status is '1'
559
- if (oRound.wd_averagescore != null) return false;
560
- if (oRound.wd_medianscore != null) return false;
561
- if (String(oRound.wd_newcolumn) === '1') return false;
562
- return true;
563
- }
564
-
565
- async function pollSessionState() {
566
- try {
567
- // Fetch participants
568
- const oParticipantResult = await listItems(S_TBL_PARTICIPANT, S_PK_PARTICIPANT, {
569
- filter: '_wd_session_value eq ' + sSessionId
570
- });
571
- aParticipants = oParticipantResult.entities || [];
572
-
573
- // Fetch ALL rounds for this session
574
- const oRoundResult = await listItems(S_TBL_ROUND, S_PK_ROUND, {
575
- filter: '_wd_session_value eq ' + sSessionId
576
- });
577
- const aAllSessionRounds = oRoundResult.entities || [];
578
-
579
- // Sort by createdon descending so we always pick the most recent
580
- aAllSessionRounds.sort(function (a, b) {
581
- return new Date(b.createdon) - new Date(a.createdon);
582
- });
583
-
584
- // Split into open rounds (no average/median) and revealed rounds
585
- // Because the array is sorted newest-first, the first match is the most recent
586
- var oOpenRound = null;
587
- var oRevealedRound = null;
588
-
589
- for (var i = 0; i < aAllSessionRounds.length; i++) {
590
- var oR = aAllSessionRounds[i];
591
- if (isRoundOpen(oR)) {
592
- if (!oOpenRound) oOpenRound = oR;
593
- } else {
594
- if (!oRevealedRound) oRevealedRound = oR;
595
- }
596
- }
597
-
598
- // If a new open round appears, clear the dismissed marker
599
- if (oOpenRound) {
600
- sDismissedRoundId = '';
601
- }
602
-
603
- // Skip re-showing a revealed round the user already dismissed
604
- if (!oOpenRound && oRevealedRound && oRevealedRound[S_PK_ROUND] === sDismissedRoundId) {
605
- oRevealedRound = null;
606
- }
607
-
608
- // Prefer showing an open round; otherwise show the latest revealed round
609
- var oRound = oOpenRound || oRevealedRound;
610
-
611
- if (oRound) {
612
- var sRoundId = oRound[S_PK_ROUND];
613
- var bIsOpen = isRoundOpen(oRound);
614
-
615
- if (bIsOpen) {
616
- // ── Active voting round ──
617
- if (sRoundId !== sCurrentRoundId || iCurrentRoundStatus !== 0) {
618
- // New or re-discovered open round — set it up
619
- sCurrentRoundId = sRoundId;
620
- sCurrentRoundIdentifier = oRound.wd_roundidentifier || '';
621
- sCurrentRoundDescription = oRound.wd_description || '';
622
- iCurrentRoundStatus = 0;
623
- bHasVoted = false;
624
- sSelectedCard = '';
625
- aCurrentVotes = [];
626
-
627
- // Check if this user already voted (handles page refresh)
628
- try {
629
- var oExisting = await listItems(S_TBL_VOTE, S_PK_VOTE, {
630
- filter: '_wd_round_value eq ' + sCurrentRoundId + ' and _wd_participant_value eq ' + sParticipantId
631
- });
632
- if (oExisting.entities && oExisting.entities.length > 0) {
633
- bHasVoted = true;
634
- }
635
- } catch (oVoteErr) {
636
- console.error('Vote check error:', oVoteErr);
637
- }
638
-
639
- showView('session');
640
- updateHeaderForSession();
641
- showActiveRound();
642
-
643
- if (bHasVoted) {
644
- eCardSection.style.display = 'none';
645
- eVoteActions.style.display = bIsOwner ? '' : 'none';
646
- eVotedMessage.style.display = '';
647
- eBtnSubmit.style.display = 'none';
648
- }
649
- } else {
650
- // Same open round — just refresh vote counts
651
- var oVoteResult = await listItems(S_TBL_VOTE, S_PK_VOTE, {
652
- filter: '_wd_round_value eq ' + sCurrentRoundId
653
- });
654
- aCurrentVotes = oVoteResult.entities || [];
655
- renderParticipantStrip();
656
- updateVoteCount();
657
- }
658
- } else {
659
- // ── Revealed round (has average/median) ──
660
- if (sRoundId !== sCurrentRoundId || iCurrentRoundStatus !== 1) {
661
- sCurrentRoundId = sRoundId;
662
- sCurrentRoundIdentifier = oRound.wd_roundidentifier || '';
663
- sCurrentRoundDescription = oRound.wd_description || '';
664
- iCurrentRoundStatus = 1;
665
-
666
- var oVoteResult = await listItems(S_TBL_VOTE, S_PK_VOTE, {
667
- filter: '_wd_round_value eq ' + sCurrentRoundId
668
- });
669
- aCurrentVotes = oVoteResult.entities || [];
670
-
671
- var oStats = calculateStats(aCurrentVotes);
672
- showResults(aCurrentVotes, oStats);
673
- } else {
674
- renderParticipantStrip();
675
- }
676
- }
677
- } else {
678
- // No rounds at all — show waiting/new round panel
679
- if (eViewResults.style.display !== 'none') {
680
- // Stay on results view until owner starts new round
681
- renderParticipantStrip();
682
- return;
683
- }
684
-
685
- showView('session');
686
- updateHeaderForSession();
687
- eActiveRoundPanel.style.display = 'none';
688
-
689
- if (bIsOwner) {
690
- eNewRoundPanel.style.display = '';
691
- eWaitingPanel.style.display = 'none';
692
- } else {
693
- eNewRoundPanel.style.display = 'none';
694
- eWaitingPanel.style.display = '';
695
- }
696
-
697
- renderParticipantStrip();
698
- }
699
- } catch (oErr) {
700
- console.error('pollSessionState error:', oErr);
701
- }
702
- }
703
-
704
- // ── Render Participants ────────────────────
705
- function renderParticipantStrip() {
706
- let sHtml = '';
707
- aParticipants.forEach((oP) => {
708
- const bVoted = aCurrentVotes.some((oV) => oV._wd_participant_value === oP[S_PK_PARTICIPANT]);
709
- const sVotedClass = bVoted ? 'voted' : 'waiting';
710
- const sOwnerClass = '';
711
- const sInitials = oP.wd_initials || '??';
712
- const sName = oP.wd_newcolumn || 'Unknown';
713
-
714
- sHtml = sHtml + '<div class="participant">' +
715
- '<div class="participant-avatar ' + sVotedClass + sOwnerClass + '">' + escapeHtml(sInitials) + '</div>' +
716
- '<span class="participant-name">' + escapeHtml(sName) + '</span>' +
717
- '</div>';
718
- });
719
- eParticipantsStrip.innerHTML = sHtml;
720
- }
721
-
722
- function updateVoteCount() {
723
- const iTotal = aParticipants.length;
724
- const iVoted = aCurrentVotes.length;
725
- eVoteStatus.textContent = iVoted + '/' + iTotal + ' voted';
726
- }
727
-
728
- // ── Statistics ─────────────────────────────
729
- function calculateStats(aVotes) {
730
- const aNumeric = aVotes
731
- .map((oV) => parseFloat(oV.wd_score))
732
- .filter((iVal) => !isNaN(iVal));
733
-
734
- if (aNumeric.length === 0) {
735
- return { iAverage: null, iMedian: null };
736
- }
737
-
738
- const iSum = aNumeric.reduce((iAcc, iVal) => iAcc + iVal, 0);
739
- const iAverage = iSum / aNumeric.length;
740
-
741
- const aSorted = aNumeric.slice().sort((iA, iB) => iA - iB);
742
- const iMid = Math.floor(aSorted.length / 2);
743
- let iMedian;
744
- if (aSorted.length % 2 === 0) {
745
- iMedian = (aSorted[iMid - 1] + aSorted[iMid]) / 2;
746
- } else {
747
- iMedian = aSorted[iMid];
748
- }
749
-
750
- return { iAverage, iMedian };
751
- }
752
-
753
- // ── Utility Functions ──────────────────────
754
- function getInitials(sName) {
755
- const aParts = sName.trim().split(new RegExp('\\s+'));
756
- if (aParts.length >= 2) {
757
- return (aParts[0][0] + aParts[aParts.length - 1][0]).toUpperCase();
758
- }
759
- return sName.substring(0, 2).toUpperCase();
760
- }
761
-
762
- function formatScoreDisplay(sScore) {
763
- if (sScore === 'coffee') return '\u2615';
764
- if (sScore === 'infinity') return '\u221E';
765
- return sScore;
766
- }
767
-
768
- function escapeHtml(sText) {
769
- const eDiv = document.createElement('div');
770
- eDiv.textContent = sText;
771
- return eDiv.innerHTML;
772
- }
773
-
774
- function escapeAttr(sText) {
775
- return sText.replace(new RegExp('&', 'g'), '&amp;')
776
- .replace(new RegExp('"', 'g'), '&quot;')
777
- .replace(new RegExp('<', 'g'), '&lt;')
778
- .replace(new RegExp('>', 'g'), '&gt;');
779
- }
780
-
781
- function showError(sMessage) {
782
- if (!eErrorMessage) return;
783
- eErrorMessage.textContent = sMessage;
784
- eErrorMessage.style.display = '';
785
- setTimeout(() => {
786
- eErrorMessage.style.display = 'none';
787
- }, 4000);
788
- }
789
- // ── Share Session ───────────────────────────
790
- async function handleShareSession() {
791
- var oUrl = new URL(window.location.href);
792
- oUrl.searchParams.set('session', sSessionCode);
793
- try {
794
- await navigator.clipboard.writeText(oUrl.toString());
795
- eBtnShare.textContent = '\u2713 Copied!';
796
- setTimeout(function () { eBtnShare.textContent = '\uD83D\uDD17 Share'; }, 2000);
797
- } catch (oErr) {
798
- showError('Could not copy link');
799
- }
800
- }
801
- // ── End Session (Owner) ────────────────────
802
- async function handleEndSession() {
803
- if (!bIsOwner) return;
804
-
805
- try {
806
- await updateItem(S_TBL_SESSION, S_PK_SESSION, sSessionId, {
807
- wd_isactive: false
808
- });
809
- handleLeaveSession();
810
- } catch (oErr) {
811
- showError('End session: ' + (oErr.message || oErr));
812
- }
813
- }
814
-
815
- // ── Leave Session ──────────────────────────
816
- function handleLeaveSession() {
817
- stopPolling();
818
- clearSessionStorage();
819
-
820
- sSessionId = '';
821
- sSessionCode = '';
822
- sSessionName = '';
823
- sParticipantId = '';
824
- sDisplayName = '';
825
- bIsOwner = false;
826
- sCurrentRoundId = '';
827
- sCurrentRoundIdentifier = '';
828
- sCurrentRoundDescription = '';
829
- sSelectedCard = '';
830
- bHasVoted = false;
831
- iCurrentRoundStatus = -1;
832
- aParticipants = [];
833
- aCurrentVotes = [];
834
- sDismissedRoundId = '';
835
-
836
- document.getElementById('btn-leave').style.display = 'none';
837
- eBtnShare.style.display = 'none';
838
- eBtnEndSession.style.display = 'none';
839
- showView('welcome');
840
- }
841
-
842
- function saveSessionStorage() {
843
- sessionStorage.setItem('pp_sessionId', sSessionId);
844
- sessionStorage.setItem('pp_participantId', sParticipantId);
845
- sessionStorage.setItem('pp_displayName', sDisplayName);
846
- }
847
-
848
- function clearSessionStorage() {
849
- sessionStorage.removeItem('pp_sessionId');
850
- sessionStorage.removeItem('pp_participantId');
851
- sessionStorage.removeItem('pp_displayName');
852
- }
853
-
854
- // ── Previous Rounds Table ──────────────────
855
- async function loadPreviousRounds() {
856
- try {
857
- var oRoundResult = await listItems(S_TBL_ROUND, S_PK_ROUND, {});
858
- var aAllRounds = oRoundResult.entities || [];
859
- var aRounds = aAllRounds.filter(function (oR) { return !isRoundOpen(oR); });
860
- if (aRounds.length === 0) {
861
- ePreviousRoundsPanel.style.display = 'none';
862
- return;
863
- }
864
-
865
- // Fetch session names for display
866
- var aSessionIds = [];
867
- aRounds.forEach(function (oR) {
868
- if (oR._wd_session_value && aSessionIds.indexOf(oR._wd_session_value) === -1) {
869
- aSessionIds.push(oR._wd_session_value);
870
- }
871
- });
872
-
873
- var oSessionMap = {};
874
- for (var i = 0; i < aSessionIds.length; i++) {
875
- try {
876
- var oSess = await getItem(S_TBL_SESSION, S_PK_SESSION, aSessionIds[i], [S_PK_SESSION, 'wd_name']);
877
- oSessionMap[aSessionIds[i]] = oSess.wd_name || 'Unknown';
878
- } catch (e) {
879
- oSessionMap[aSessionIds[i]] = 'Unknown';
880
- }
881
- }
882
-
883
- aPreviousRounds = aRounds.map(function (oR) {
884
- return {
885
- session: oSessionMap[oR._wd_session_value] || 'Unknown',
886
- roundName: oR.wd_roundidentifier || '',
887
- description: oR.wd_description || '',
888
- average: oR.wd_averagescore != null ? parseFloat(oR.wd_averagescore) : null,
889
- median: oR.wd_medianscore != null ? parseFloat(oR.wd_medianscore) : null
890
- };
891
- });
892
-
893
- renderPreviousRoundsTable();
894
- ePreviousRoundsPanel.style.display = '';
895
- setupSortButtons();
896
- } catch (oErr) {
897
- showError('Previous rounds: ' + (oErr.message || oErr));
898
- }
899
- }
900
-
901
- function renderPreviousRoundsTable() {
902
- var sHtml = '';
903
- aPreviousRounds.forEach(function (oRow) {
904
- sHtml += '<tr>' +
905
- '<td>' + escapeHtml(oRow.session) + '</td>' +
906
- '<td>' + escapeHtml(oRow.roundName) + '</td>' +
907
- '<td>' + escapeHtml(oRow.description) + '</td>' +
908
- '<td>' + (oRow.average !== null ? oRow.average.toFixed(1) : '\u2014') + '</td>' +
909
- '<td>' + (oRow.median !== null ? oRow.median.toFixed(1) : '\u2014') + '</td>' +
910
- '</tr>';
911
- });
912
- ePreviousRoundsBody.innerHTML = sHtml;
913
- }
914
-
915
- function setupSortButtons() {
916
- var aBtns = document.querySelectorAll('.sort-btn');
917
- aBtns.forEach(function (eBtn) {
918
- eBtn.addEventListener('click', function () {
919
- var sCol = eBtn.getAttribute('data-col');
920
- var sDir = eBtn.getAttribute('data-dir');
921
- var sNewDir = sDir === 'asc' ? 'desc' : 'asc';
922
-
923
- // Reset all buttons
924
- aBtns.forEach(function (eB) {
925
- eB.setAttribute('data-dir', '');
926
- eB.className = 'sort-btn';
927
- });
928
-
929
- eBtn.setAttribute('data-dir', sNewDir);
930
- eBtn.className = 'sort-btn ' + sNewDir;
931
- sSortCol = sCol;
932
- sSortDir = sNewDir;
933
-
934
- sortPreviousRounds();
935
- renderPreviousRoundsTable();
936
- });
937
- });
938
- }
939
-
940
- function sortPreviousRounds() {
941
- var iDir = sSortDir === 'desc' ? -1 : 1;
942
- aPreviousRounds.sort(function (oA, oB) {
943
- var vA = oA[sSortCol];
944
- var vB = oB[sSortCol];
945
- if (vA === null && vB === null) return 0;
946
- if (vA === null) return 1;
947
- if (vB === null) return -1;
948
- if (typeof vA === 'string') {
949
- return iDir * vA.localeCompare(vB);
950
- }
951
- return iDir * (vA - vB);
952
- });
953
- }
954
-
1
+ import { createItem, listItems, updateItem, getItem, deleteItem } from './dataverse.js';
2
+
3
+ /* ═══════════════════════════════════════════
4
+ Planning Poker — Main Application Logic
5
+ ═══════════════════════════════════════════ */
6
+
7
+ // ── Table & Key Constants ──────────────────
8
+ const S_TBL_SESSION = 'wd_pokersessions';
9
+ const S_TBL_PARTICIPANT = 'wd_pokerparticipants';
10
+ const S_TBL_ROUND = 'wd_pokerrounds';
11
+ const S_TBL_VOTE = 'wd_pokervotes';
12
+ const S_PK_SESSION = 'wd_pokersessionid';
13
+ const S_PK_PARTICIPANT = 'wd_pokerparticipantid';
14
+ const S_PK_ROUND = 'wd_pokerroundid';
15
+ const S_PK_VOTE = 'wd_pokervoteid';
16
+
17
+ // ── Card Values (Fibonacci + specials) ─────
18
+ const A_CARD_VALUES = [
19
+ { sValue: '0', sDisplay: '0' },
20
+ { sValue: '1', sDisplay: '1' },
21
+ { sValue: '2', sDisplay: '2' },
22
+ { sValue: '3', sDisplay: '3' },
23
+ { sValue: '5', sDisplay: '5' },
24
+ { sValue: '8', sDisplay: '8' },
25
+ { sValue: '13', sDisplay: '13' },
26
+ { sValue: '21', sDisplay: '21' },
27
+ { sValue: '34', sDisplay: '34' },
28
+ { sValue: '?', sDisplay: '?' },
29
+ { sValue: 'coffee', sDisplay: '\u2615' },
30
+ { sValue: 'infinity', sDisplay: '\u221E' }
31
+ ];
32
+
33
+ // ── App State ──────────────────────────────
34
+ let sSessionId = '';
35
+ let sSessionCode = '';
36
+ let sSessionName = '';
37
+ let sParticipantId = '';
38
+ let sDisplayName = '';
39
+ let bIsOwner = false;
40
+ let sCurrentRoundId = '';
41
+ let sCurrentRoundIdentifier = '';
42
+ let sCurrentRoundDescription = '';
43
+ let sSelectedCard = '';
44
+ let bHasVoted = false;
45
+ let iCurrentRoundStatus = -1;
46
+ let iPollInterval = null;
47
+ let aParticipants = [];
48
+ let aCurrentVotes = [];
49
+ let sDismissedRoundId = '';
50
+
51
+ // ── DOM References ─────────────────────────
52
+ let eViewWelcome, eViewSession, eViewResults;
53
+ let eSessionInfo, eSessionBadge, eSessionNameText, eParticipantsCount, eVoteStatus;
54
+ let eNewRoundPanel, eWaitingPanel, eActiveRoundPanel;
55
+ let eRoundBadge, eRoundTitle, eRoundDescription;
56
+ let eParticipantsStrip, eCardHand, eCardSection;
57
+ let eBtnSubmit, eBtnReveal, eVotedMessage, eVoteActions;
58
+ let eResultsBadge, eResultsTitle, eStatAverage, eStatMedian, eVotesGrid;
59
+ let eBtnNewRound, eBtnWaitingNext;
60
+ let eFooterInfo, eErrorMessage;
61
+ let eBtnEndSession, eBtnShare;
62
+ let ePreviousRoundsPanel, ePreviousRoundsBody;
63
+ let aPreviousRounds = [];
64
+ let sSortCol = '';
65
+ let sSortDir = '';
66
+
67
+ function cacheDomElements() {
68
+ eViewWelcome = document.getElementById('view-welcome');
69
+ eViewSession = document.getElementById('view-session');
70
+ eViewResults = document.getElementById('view-results');
71
+
72
+ eSessionInfo = document.getElementById('session-info');
73
+ eSessionBadge = document.getElementById('session-badge');
74
+ eSessionNameText = document.getElementById('session-name-text');
75
+ eParticipantsCount = document.getElementById('participants-count');
76
+ eVoteStatus = document.getElementById('vote-status');
77
+
78
+ eNewRoundPanel = document.getElementById('new-round-panel');
79
+ eWaitingPanel = document.getElementById('waiting-panel');
80
+ eActiveRoundPanel = document.getElementById('active-round-panel');
81
+
82
+ eRoundBadge = document.getElementById('round-badge');
83
+ eRoundTitle = document.getElementById('round-title');
84
+ eRoundDescription = document.getElementById('round-description');
85
+
86
+ eParticipantsStrip = document.getElementById('participants-strip');
87
+ eCardHand = document.getElementById('card-hand');
88
+ eCardSection = document.getElementById('card-section');
89
+
90
+ eBtnSubmit = document.getElementById('btn-submit');
91
+ eBtnReveal = document.getElementById('btn-reveal');
92
+ eVotedMessage = document.getElementById('voted-message');
93
+ eVoteActions = document.getElementById('vote-actions');
94
+
95
+ eResultsBadge = document.getElementById('results-badge');
96
+ eResultsTitle = document.getElementById('results-title');
97
+ eStatAverage = document.getElementById('stat-average');
98
+ eStatMedian = document.getElementById('stat-median');
99
+ eVotesGrid = document.getElementById('votes-grid');
100
+
101
+ eBtnNewRound = document.getElementById('btn-new-round');
102
+ eBtnWaitingNext = document.getElementById('btn-waiting-next');
103
+
104
+ eFooterInfo = document.getElementById('footer-info');
105
+ eErrorMessage = document.getElementById('error-message');
106
+ eBtnEndSession = document.getElementById('btn-end-session');
107
+ eBtnShare = document.getElementById('btn-share');
108
+ ePreviousRoundsPanel = document.getElementById('previous-rounds-panel');
109
+ ePreviousRoundsBody = document.getElementById('previous-rounds-body');
110
+ }
111
+
112
+ // ── Boot ───────────────────────────────────
113
+ async function boot() {
114
+ cacheDomElements();
115
+ setupEventListeners();
116
+ renderCardHand();
117
+
118
+ // Resume session from storage if available
119
+ const sStoredSession = sessionStorage.getItem('pp_sessionId');
120
+ const sStoredParticipant = sessionStorage.getItem('pp_participantId');
121
+
122
+ if (sStoredSession && sStoredParticipant) {
123
+ try {
124
+ const oSession = await getItem(S_TBL_SESSION, S_PK_SESSION, sStoredSession);
125
+ if (oSession && oSession.wd_isactive) {
126
+ const oParticipant = await getItem(S_TBL_PARTICIPANT, S_PK_PARTICIPANT, sStoredParticipant);
127
+ if (!oParticipant || !oParticipant[S_PK_PARTICIPANT]) {
128
+ throw new Error('Participant not found');
129
+ }
130
+ sSessionId = sStoredSession;
131
+ sParticipantId = sStoredParticipant;
132
+ bIsOwner = !!(oSession._createdby_value && oParticipant._createdby_value && oSession._createdby_value === oParticipant._createdby_value);
133
+ sSessionCode = oSession.wd_sessioncode;
134
+ sSessionName = oSession.wd_name;
135
+ sDisplayName = sessionStorage.getItem('pp_displayName') || '';
136
+ enterSession();
137
+ return;
138
+ }
139
+ } catch (oErr) {
140
+ clearSessionStorage();
141
+ }
142
+ }
143
+
144
+ // Check for ?session= param in URL
145
+ var oParams = new URLSearchParams(window.location.search);
146
+ var sJoinCode = oParams.get('session');
147
+ if (sJoinCode) {
148
+ document.getElementById('inp-join-code').value = sJoinCode.toUpperCase();
149
+ document.getElementById('inp-join-name').focus();
150
+ }
151
+
152
+ showView('welcome');
153
+ loadPreviousRounds();
154
+ }
155
+
156
+ // ── View Management ────────────────────────
157
+ function showView(sView) {
158
+ eViewWelcome.style.display = sView === 'welcome' ? '' : 'none';
159
+ eViewSession.style.display = sView === 'session' ? '' : 'none';
160
+ eViewResults.style.display = sView === 'results' ? '' : 'none';
161
+
162
+ if (sView === 'welcome') {
163
+ eSessionInfo.style.display = 'none';
164
+ eParticipantsCount.style.display = 'none';
165
+ }
166
+ }
167
+
168
+ async function enterSession() {
169
+ showView('session');
170
+ updateHeaderForSession();
171
+
172
+ // Hide all sub-panels — pollSessionState will show the correct one
173
+ eActiveRoundPanel.style.display = 'none';
174
+ eNewRoundPanel.style.display = 'none';
175
+ eWaitingPanel.style.display = 'none';
176
+
177
+ await pollSessionState();
178
+
179
+ // Fallback: if poll failed silently and no panel was shown, show defaults
180
+ if (eActiveRoundPanel.style.display === 'none' &&
181
+ eNewRoundPanel.style.display === 'none' &&
182
+ eWaitingPanel.style.display === 'none' &&
183
+ eViewResults.style.display === 'none') {
184
+ if (bIsOwner) {
185
+ eNewRoundPanel.style.display = '';
186
+ } else {
187
+ eWaitingPanel.style.display = '';
188
+ }
189
+ }
190
+
191
+ stopPolling();
192
+ iPollInterval = setInterval(pollSessionState, 3000);
193
+ }
194
+
195
+ function updateHeaderForSession() {
196
+ eSessionInfo.style.display = '';
197
+ eSessionBadge.textContent = sSessionCode;
198
+ eSessionNameText.textContent = sSessionName;
199
+ eParticipantsCount.style.display = '';
200
+ document.getElementById('btn-leave').style.display = '';
201
+ eBtnShare.style.display = '';
202
+ eBtnEndSession.style.display = bIsOwner ? '' : 'none';
203
+ eFooterInfo.textContent = 'Session: ' + sSessionName + ' \u2022 Code: ' + sSessionCode;
204
+ }
205
+
206
+ // ── Event Listeners ────────────────────────
207
+ function setupEventListeners() {
208
+ document.getElementById('btn-create').addEventListener('click', handleCreateSession);
209
+ document.getElementById('btn-join').addEventListener('click', handleJoinSession);
210
+ document.getElementById('btn-start-round').addEventListener('click', handleStartRound);
211
+ document.getElementById('btn-leave').addEventListener('click', handleLeaveSession);
212
+ eBtnEndSession.addEventListener('click', handleEndSession);
213
+ eBtnShare.addEventListener('click', handleShareSession);
214
+
215
+ eCardHand.addEventListener('click', (oEvent) => {
216
+ const eTarget = oEvent.target.closest('.poker-card');
217
+ if (eTarget && !bHasVoted) {
218
+ selectCard(eTarget);
219
+ }
220
+ });
221
+
222
+ eBtnSubmit.addEventListener('click', handleSubmitVote);
223
+ eBtnReveal.addEventListener('click', handleRevealVotes);
224
+ eBtnNewRound.addEventListener('click', handleNewRound);
225
+ }
226
+
227
+ // ── Card Rendering & Selection ─────────────
228
+ function renderCardHand() {
229
+ let sHtml = '';
230
+ A_CARD_VALUES.forEach((oCard) => {
231
+ const sSpecial = (oCard.sValue === '?' || oCard.sValue === 'coffee' || oCard.sValue === 'infinity') ? ' special' : '';
232
+ sHtml = sHtml + '<div class="poker-card' + sSpecial + '" data-value="' + escapeAttr(oCard.sValue) + '">' + escapeHtml(oCard.sDisplay) + '</div>';
233
+ });
234
+ eCardHand.innerHTML = sHtml;
235
+ }
236
+
237
+ function selectCard(eCard) {
238
+ const aCards = eCardHand.querySelectorAll('.poker-card');
239
+ aCards.forEach((eC) => {
240
+ eC.classList.remove('selected');
241
+ });
242
+ eCard.classList.add('selected');
243
+ sSelectedCard = eCard.getAttribute('data-value');
244
+ eBtnSubmit.disabled = false;
245
+ }
246
+
247
+ // ── Find or Create Participant (upsert) ────
248
+ async function findOrCreateParticipant(sTargetSessionId, sUserName) {
249
+ // Create a participant so we can identify the current user via _createdby_value
250
+ const oNewParticipant = await createItem(S_TBL_PARTICIPANT, S_PK_PARTICIPANT, {
251
+ 'wd_session@odata.bind': '/' + S_TBL_SESSION + '(' + sTargetSessionId + ')',
252
+ wd_newcolumn: sUserName,
253
+ wd_initials: getInitials(sUserName)
254
+ });
255
+ const sNewId = oNewParticipant[S_PK_PARTICIPANT];
256
+
257
+ // Read it back to get the system _createdby_value
258
+ const oFull = await getItem(S_TBL_PARTICIPANT, S_PK_PARTICIPANT, sNewId);
259
+ const sUserId = oFull._createdby_value;
260
+
261
+ if (!sUserId) {
262
+ return sNewId;
263
+ }
264
+
265
+ // Check if this user already had a participant in this session
266
+ const oExisting = await listItems(S_TBL_PARTICIPANT, S_PK_PARTICIPANT, {
267
+ filter: '_wd_session_value eq ' + sTargetSessionId
268
+ });
269
+ const aOthers = (oExisting.entities || []).filter(function(oP) {
270
+ return oP._createdby_value === sUserId && oP[S_PK_PARTICIPANT] !== sNewId;
271
+ });
272
+
273
+ if (aOthers.length > 0) {
274
+ // Keep the original participant (it has votes linked to it), update its name
275
+ const oKeep = aOthers[0];
276
+ await updateItem(S_TBL_PARTICIPANT, S_PK_PARTICIPANT, oKeep[S_PK_PARTICIPANT], {
277
+ wd_newcolumn: sUserName,
278
+ wd_initials: getInitials(sUserName)
279
+ });
280
+ // Remove the duplicate we just created
281
+ try { await deleteItem(S_TBL_PARTICIPANT, S_PK_PARTICIPANT, sNewId); } catch (e) { /* non-critical */ }
282
+ return oKeep[S_PK_PARTICIPANT];
283
+ }
284
+
285
+ return sNewId;
286
+ }
287
+
288
+ // ── Create Session ─────────────────────────
289
+ async function handleCreateSession() {
290
+ const sName = document.getElementById('inp-session-name').value.trim();
291
+ const sCode = document.getElementById('inp-session-code').value.trim().toUpperCase();
292
+ const sUserName = document.getElementById('inp-create-name').value.trim();
293
+
294
+ if (sName === '' || sCode === '' || sUserName === '') {
295
+ showError('Please fill in all fields');
296
+ return;
297
+ }
298
+
299
+ try {
300
+ const oSession = await createItem(S_TBL_SESSION, S_PK_SESSION, {
301
+ wd_name: sName,
302
+ wd_sessioncode: sCode,
303
+ wd_isactive: true
304
+ });
305
+
306
+ sSessionId = oSession[S_PK_SESSION];
307
+ sSessionCode = sCode;
308
+ sSessionName = sName;
309
+ sDisplayName = sUserName;
310
+ bIsOwner = true;
311
+
312
+ sParticipantId = await findOrCreateParticipant(sSessionId, sUserName);
313
+ saveSessionStorage();
314
+ enterSession();
315
+ } catch (oErr) {
316
+ showError('Create session: ' + (oErr.message || oErr));
317
+ }
318
+ }
319
+
320
+ // ── Join Session ───────────────────────────
321
+ async function handleJoinSession() {
322
+ const sCode = document.getElementById('inp-join-code').value.trim().toUpperCase();
323
+ const sUserName = document.getElementById('inp-join-name').value.trim();
324
+
325
+ if (sCode === '' || sUserName === '') {
326
+ showError('Please fill in all fields');
327
+ return;
328
+ }
329
+
330
+ try {
331
+ const oResult = await listItems(S_TBL_SESSION, S_PK_SESSION, {
332
+ filter: 'wd_sessioncode eq \'' + sCode + '\' and wd_isactive eq true'
333
+ });
334
+
335
+ if (!oResult.entities || oResult.entities.length === 0) {
336
+ showError('Session not found. Check the code and try again.');
337
+ return;
338
+ }
339
+
340
+ const oSession = oResult.entities[0];
341
+ sSessionId = oSession[S_PK_SESSION];
342
+ sSessionCode = oSession.wd_sessioncode;
343
+ sSessionName = oSession.wd_name;
344
+ sDisplayName = sUserName;
345
+
346
+ sParticipantId = await findOrCreateParticipant(sSessionId, sUserName);
347
+
348
+ // Determine ownership by comparing session creator with current user
349
+ const oParticipantFull = await getItem(S_TBL_PARTICIPANT, S_PK_PARTICIPANT, sParticipantId);
350
+ bIsOwner = !!(oSession._createdby_value && oParticipantFull._createdby_value && oSession._createdby_value === oParticipantFull._createdby_value);
351
+
352
+ saveSessionStorage();
353
+ enterSession();
354
+ } catch (oErr) {
355
+ showError('Join session: ' + (oErr.message || oErr));
356
+ }
357
+ }
358
+
359
+ // ── Start Round ────────────────────────────
360
+ async function handleStartRound() {
361
+ const sRoundId = document.getElementById('inp-round-id').value.trim();
362
+ const sDesc = document.getElementById('inp-round-desc').value.trim();
363
+
364
+ if (sRoundId === '') {
365
+ showError('Please enter a round ID');
366
+ return;
367
+ }
368
+
369
+ try {
370
+ const oRound = await createItem(S_TBL_ROUND, S_PK_ROUND, {
371
+ 'wd_session@odata.bind': '/' + S_TBL_SESSION + '(' + sSessionId + ')',
372
+ wd_roundidentifier: sRoundId,
373
+ wd_description: sDesc,
374
+ wd_newcolumn: '0'
375
+ });
376
+
377
+ sCurrentRoundId = oRound[S_PK_ROUND];
378
+ sCurrentRoundIdentifier = sRoundId;
379
+ sCurrentRoundDescription = sDesc;
380
+ iCurrentRoundStatus = 0;
381
+ bHasVoted = false;
382
+ sSelectedCard = '';
383
+ aCurrentVotes = [];
384
+
385
+ document.getElementById('inp-round-id').value = '';
386
+ document.getElementById('inp-round-desc').value = '';
387
+
388
+ showActiveRound();
389
+ } catch (oErr) {
390
+ showError('Start round: ' + (oErr.message || oErr));
391
+ }
392
+ }
393
+
394
+ function showActiveRound() {
395
+ eNewRoundPanel.style.display = 'none';
396
+ eWaitingPanel.style.display = 'none';
397
+ eActiveRoundPanel.style.display = '';
398
+
399
+ eRoundBadge.textContent = sCurrentRoundIdentifier;
400
+ eRoundTitle.textContent = sCurrentRoundDescription || sCurrentRoundIdentifier;
401
+ eRoundDescription.textContent = sCurrentRoundDescription;
402
+
403
+ // Reset voting UI
404
+ eCardSection.style.display = '';
405
+ eVoteActions.style.display = '';
406
+ eVotedMessage.style.display = 'none';
407
+ eBtnSubmit.style.display = '';
408
+ eBtnSubmit.disabled = true;
409
+ eBtnSubmit.textContent = 'Submit Vote';
410
+ eBtnReveal.style.display = bIsOwner ? '' : 'none';
411
+
412
+ // Reset card selection
413
+ const aCards = eCardHand.querySelectorAll('.poker-card');
414
+ aCards.forEach((eC) => {
415
+ eC.classList.remove('selected');
416
+ });
417
+ sSelectedCard = '';
418
+ bHasVoted = false;
419
+ }
420
+
421
+ // ── Submit Vote ────────────────────────────
422
+ async function handleSubmitVote() {
423
+ if (sSelectedCard === '' || bHasVoted) {
424
+ return;
425
+ }
426
+
427
+ try {
428
+ // Check for an existing vote by this participant in this round
429
+ const oExistingVotes = await listItems(S_TBL_VOTE, S_PK_VOTE, {
430
+ filter: '_wd_round_value eq ' + sCurrentRoundId + ' and _wd_participant_value eq ' + sParticipantId
431
+ });
432
+
433
+ if (oExistingVotes.entities && oExistingVotes.entities.length > 0) {
434
+ // Update existing vote
435
+ await updateItem(S_TBL_VOTE, S_PK_VOTE, oExistingVotes.entities[0][S_PK_VOTE], {
436
+ wd_score: sSelectedCard
437
+ });
438
+ } else {
439
+ // Create new vote
440
+ await createItem(S_TBL_VOTE, S_PK_VOTE, {
441
+ 'wd_round@odata.bind': '/' + S_TBL_ROUND + '(' + sCurrentRoundId + ')',
442
+ 'wd_participant@odata.bind': '/' + S_TBL_PARTICIPANT + '(' + sParticipantId + ')',
443
+ wd_score: sSelectedCard
444
+ });
445
+ }
446
+
447
+ bHasVoted = true;
448
+ eCardSection.style.display = 'none';
449
+ eVoteActions.style.display = bIsOwner ? '' : 'none';
450
+ eVotedMessage.style.display = '';
451
+ eBtnSubmit.style.display = 'none';
452
+ } catch (oErr) {
453
+ showError('Submit vote: ' + (oErr.message || oErr));
454
+ }
455
+ }
456
+
457
+ // ── Reveal Votes ───────────────────────────
458
+ async function handleRevealVotes() {
459
+ if (!bIsOwner) return;
460
+
461
+ try {
462
+ const oVoteResult = await listItems(S_TBL_VOTE, S_PK_VOTE, {
463
+ filter: '_wd_round_value eq ' + sCurrentRoundId
464
+ });
465
+ aCurrentVotes = oVoteResult.entities || [];
466
+
467
+ const oStats = calculateStats(aCurrentVotes);
468
+
469
+ await updateItem(S_TBL_ROUND, S_PK_ROUND, sCurrentRoundId, {
470
+ wd_newcolumn: '1',
471
+ wd_averagescore: oStats.iAverage,
472
+ wd_medianscore: oStats.iMedian
473
+ });
474
+
475
+ iCurrentRoundStatus = 1;
476
+ showResults(aCurrentVotes, oStats);
477
+ } catch (oErr) {
478
+ showError('Reveal votes: ' + (oErr.message || oErr));
479
+ }
480
+ }
481
+
482
+ // ── New Round ──────────────────────────────
483
+ function handleNewRound() {
484
+ sDismissedRoundId = sCurrentRoundId;
485
+ sCurrentRoundId = '';
486
+ sCurrentRoundIdentifier = '';
487
+ sCurrentRoundDescription = '';
488
+ iCurrentRoundStatus = -1;
489
+ bHasVoted = false;
490
+ sSelectedCard = '';
491
+ aCurrentVotes = [];
492
+
493
+ showView('session');
494
+ updateHeaderForSession();
495
+ eActiveRoundPanel.style.display = 'none';
496
+
497
+ if (bIsOwner) {
498
+ eNewRoundPanel.style.display = '';
499
+ eWaitingPanel.style.display = 'none';
500
+ } else {
501
+ eNewRoundPanel.style.display = 'none';
502
+ eWaitingPanel.style.display = '';
503
+ }
504
+ }
505
+
506
+ // ── Results Display ────────────────────────
507
+ function showResults(aVotes, oStats) {
508
+ showView('results');
509
+ updateHeaderForSession();
510
+
511
+ eResultsBadge.textContent = sCurrentRoundIdentifier;
512
+ eResultsTitle.textContent = sCurrentRoundDescription || sCurrentRoundIdentifier;
513
+ eStatAverage.textContent = oStats.iAverage !== null ? oStats.iAverage.toFixed(1) : '\u2014';
514
+ eStatMedian.textContent = oStats.iMedian !== null ? oStats.iMedian.toFixed(1) : '\u2014';
515
+
516
+ let sHtml = '';
517
+ aVotes.forEach((oVote) => {
518
+ const oParticipant = aParticipants.find((oP) => oP[S_PK_PARTICIPANT] === oVote._wd_participant_value);
519
+ const sName = oParticipant ? oParticipant.wd_newcolumn : 'Unknown';
520
+ const sInitials = oParticipant ? oParticipant.wd_initials : '??';
521
+ const sScore = formatScoreDisplay(oVote.wd_score);
522
+
523
+ sHtml = sHtml + '<div class="vote-card">' +
524
+ '<div class="vote-card-avatar">' + escapeHtml(sInitials) + '</div>' +
525
+ '<div class="vote-card-info">' +
526
+ '<span class="vote-card-name">' + escapeHtml(sName) + '</span>' +
527
+ '<span class="vote-card-value">' + escapeHtml(sScore) + '</span>' +
528
+ '</div>' +
529
+ '</div>';
530
+ });
531
+ eVotesGrid.innerHTML = sHtml;
532
+
533
+ if (bIsOwner) {
534
+ eBtnNewRound.style.display = '';
535
+ eBtnWaitingNext.style.display = 'none';
536
+ } else {
537
+ eBtnNewRound.style.display = 'none';
538
+ eBtnWaitingNext.style.display = '';
539
+ }
540
+ }
541
+
542
+ // ── Polling ────────────────────────────────
543
+ function startPolling() {
544
+ stopPolling();
545
+ pollSessionState();
546
+ iPollInterval = setInterval(pollSessionState, 3000);
547
+ }
548
+
549
+ function stopPolling() {
550
+ if (iPollInterval) {
551
+ clearInterval(iPollInterval);
552
+ iPollInterval = null;
553
+ }
554
+ }
555
+
556
+ // Determine if a round is still open (no results yet)
557
+ function isRoundOpen(oRound) {
558
+ // A round is revealed if it has average/median scores OR wd_newcolumn status is '1'
559
+ if (oRound.wd_averagescore != null) return false;
560
+ if (oRound.wd_medianscore != null) return false;
561
+ if (String(oRound.wd_newcolumn) === '1') return false;
562
+ return true;
563
+ }
564
+
565
+ async function pollSessionState() {
566
+ try {
567
+ // Fetch participants
568
+ const oParticipantResult = await listItems(S_TBL_PARTICIPANT, S_PK_PARTICIPANT, {
569
+ filter: '_wd_session_value eq ' + sSessionId
570
+ });
571
+ aParticipants = oParticipantResult.entities || [];
572
+
573
+ // Fetch ALL rounds for this session
574
+ const oRoundResult = await listItems(S_TBL_ROUND, S_PK_ROUND, {
575
+ filter: '_wd_session_value eq ' + sSessionId
576
+ });
577
+ const aAllSessionRounds = oRoundResult.entities || [];
578
+
579
+ // Sort by createdon descending so we always pick the most recent
580
+ aAllSessionRounds.sort(function (a, b) {
581
+ return new Date(b.createdon) - new Date(a.createdon);
582
+ });
583
+
584
+ // Split into open rounds (no average/median) and revealed rounds
585
+ // Because the array is sorted newest-first, the first match is the most recent
586
+ var oOpenRound = null;
587
+ var oRevealedRound = null;
588
+
589
+ for (var i = 0; i < aAllSessionRounds.length; i++) {
590
+ var oR = aAllSessionRounds[i];
591
+ if (isRoundOpen(oR)) {
592
+ if (!oOpenRound) oOpenRound = oR;
593
+ } else {
594
+ if (!oRevealedRound) oRevealedRound = oR;
595
+ }
596
+ }
597
+
598
+ // If a new open round appears, clear the dismissed marker
599
+ if (oOpenRound) {
600
+ sDismissedRoundId = '';
601
+ }
602
+
603
+ // Skip re-showing a revealed round the user already dismissed
604
+ if (!oOpenRound && oRevealedRound && oRevealedRound[S_PK_ROUND] === sDismissedRoundId) {
605
+ oRevealedRound = null;
606
+ }
607
+
608
+ // Prefer showing an open round; otherwise show the latest revealed round
609
+ var oRound = oOpenRound || oRevealedRound;
610
+
611
+ if (oRound) {
612
+ var sRoundId = oRound[S_PK_ROUND];
613
+ var bIsOpen = isRoundOpen(oRound);
614
+
615
+ if (bIsOpen) {
616
+ // ── Active voting round ──
617
+ if (sRoundId !== sCurrentRoundId || iCurrentRoundStatus !== 0) {
618
+ // New or re-discovered open round — set it up
619
+ sCurrentRoundId = sRoundId;
620
+ sCurrentRoundIdentifier = oRound.wd_roundidentifier || '';
621
+ sCurrentRoundDescription = oRound.wd_description || '';
622
+ iCurrentRoundStatus = 0;
623
+ bHasVoted = false;
624
+ sSelectedCard = '';
625
+ aCurrentVotes = [];
626
+
627
+ // Check if this user already voted (handles page refresh)
628
+ try {
629
+ var oExisting = await listItems(S_TBL_VOTE, S_PK_VOTE, {
630
+ filter: '_wd_round_value eq ' + sCurrentRoundId + ' and _wd_participant_value eq ' + sParticipantId
631
+ });
632
+ if (oExisting.entities && oExisting.entities.length > 0) {
633
+ bHasVoted = true;
634
+ }
635
+ } catch (oVoteErr) {
636
+ console.error('Vote check error:', oVoteErr);
637
+ }
638
+
639
+ showView('session');
640
+ updateHeaderForSession();
641
+ showActiveRound();
642
+
643
+ if (bHasVoted) {
644
+ eCardSection.style.display = 'none';
645
+ eVoteActions.style.display = bIsOwner ? '' : 'none';
646
+ eVotedMessage.style.display = '';
647
+ eBtnSubmit.style.display = 'none';
648
+ }
649
+ } else {
650
+ // Same open round — just refresh vote counts
651
+ var oVoteResult = await listItems(S_TBL_VOTE, S_PK_VOTE, {
652
+ filter: '_wd_round_value eq ' + sCurrentRoundId
653
+ });
654
+ aCurrentVotes = oVoteResult.entities || [];
655
+ renderParticipantStrip();
656
+ updateVoteCount();
657
+ }
658
+ } else {
659
+ // ── Revealed round (has average/median) ──
660
+ if (sRoundId !== sCurrentRoundId || iCurrentRoundStatus !== 1) {
661
+ sCurrentRoundId = sRoundId;
662
+ sCurrentRoundIdentifier = oRound.wd_roundidentifier || '';
663
+ sCurrentRoundDescription = oRound.wd_description || '';
664
+ iCurrentRoundStatus = 1;
665
+
666
+ var oVoteResult = await listItems(S_TBL_VOTE, S_PK_VOTE, {
667
+ filter: '_wd_round_value eq ' + sCurrentRoundId
668
+ });
669
+ aCurrentVotes = oVoteResult.entities || [];
670
+
671
+ var oStats = calculateStats(aCurrentVotes);
672
+ showResults(aCurrentVotes, oStats);
673
+ } else {
674
+ renderParticipantStrip();
675
+ }
676
+ }
677
+ } else {
678
+ // No rounds at all — show waiting/new round panel
679
+ if (eViewResults.style.display !== 'none') {
680
+ // Stay on results view until owner starts new round
681
+ renderParticipantStrip();
682
+ return;
683
+ }
684
+
685
+ showView('session');
686
+ updateHeaderForSession();
687
+ eActiveRoundPanel.style.display = 'none';
688
+
689
+ if (bIsOwner) {
690
+ eNewRoundPanel.style.display = '';
691
+ eWaitingPanel.style.display = 'none';
692
+ } else {
693
+ eNewRoundPanel.style.display = 'none';
694
+ eWaitingPanel.style.display = '';
695
+ }
696
+
697
+ renderParticipantStrip();
698
+ }
699
+ } catch (oErr) {
700
+ console.error('pollSessionState error:', oErr);
701
+ }
702
+ }
703
+
704
+ // ── Render Participants ────────────────────
705
+ function renderParticipantStrip() {
706
+ let sHtml = '';
707
+ aParticipants.forEach((oP) => {
708
+ const bVoted = aCurrentVotes.some((oV) => oV._wd_participant_value === oP[S_PK_PARTICIPANT]);
709
+ const sVotedClass = bVoted ? 'voted' : 'waiting';
710
+ const sOwnerClass = '';
711
+ const sInitials = oP.wd_initials || '??';
712
+ const sName = oP.wd_newcolumn || 'Unknown';
713
+
714
+ sHtml = sHtml + '<div class="participant">' +
715
+ '<div class="participant-avatar ' + sVotedClass + sOwnerClass + '">' + escapeHtml(sInitials) + '</div>' +
716
+ '<span class="participant-name">' + escapeHtml(sName) + '</span>' +
717
+ '</div>';
718
+ });
719
+ eParticipantsStrip.innerHTML = sHtml;
720
+ }
721
+
722
+ function updateVoteCount() {
723
+ const iTotal = aParticipants.length;
724
+ const iVoted = aCurrentVotes.length;
725
+ eVoteStatus.textContent = iVoted + '/' + iTotal + ' voted';
726
+ }
727
+
728
+ // ── Statistics ─────────────────────────────
729
+ function calculateStats(aVotes) {
730
+ const aNumeric = aVotes
731
+ .map((oV) => parseFloat(oV.wd_score))
732
+ .filter((iVal) => !isNaN(iVal));
733
+
734
+ if (aNumeric.length === 0) {
735
+ return { iAverage: null, iMedian: null };
736
+ }
737
+
738
+ const iSum = aNumeric.reduce((iAcc, iVal) => iAcc + iVal, 0);
739
+ const iAverage = iSum / aNumeric.length;
740
+
741
+ const aSorted = aNumeric.slice().sort((iA, iB) => iA - iB);
742
+ const iMid = Math.floor(aSorted.length / 2);
743
+ let iMedian;
744
+ if (aSorted.length % 2 === 0) {
745
+ iMedian = (aSorted[iMid - 1] + aSorted[iMid]) / 2;
746
+ } else {
747
+ iMedian = aSorted[iMid];
748
+ }
749
+
750
+ return { iAverage, iMedian };
751
+ }
752
+
753
+ // ── Utility Functions ──────────────────────
754
+ function getInitials(sName) {
755
+ const aParts = sName.trim().split(new RegExp('\\s+'));
756
+ if (aParts.length >= 2) {
757
+ return (aParts[0][0] + aParts[aParts.length - 1][0]).toUpperCase();
758
+ }
759
+ return sName.substring(0, 2).toUpperCase();
760
+ }
761
+
762
+ function formatScoreDisplay(sScore) {
763
+ if (sScore === 'coffee') return '\u2615';
764
+ if (sScore === 'infinity') return '\u221E';
765
+ return sScore;
766
+ }
767
+
768
+ function escapeHtml(sText) {
769
+ const eDiv = document.createElement('div');
770
+ eDiv.textContent = sText;
771
+ return eDiv.innerHTML;
772
+ }
773
+
774
+ function escapeAttr(sText) {
775
+ return sText.replace(new RegExp('&', 'g'), '&amp;')
776
+ .replace(new RegExp('"', 'g'), '&quot;')
777
+ .replace(new RegExp('<', 'g'), '&lt;')
778
+ .replace(new RegExp('>', 'g'), '&gt;');
779
+ }
780
+
781
+ function showError(sMessage) {
782
+ if (!eErrorMessage) return;
783
+ eErrorMessage.textContent = sMessage;
784
+ eErrorMessage.style.display = '';
785
+ setTimeout(() => {
786
+ eErrorMessage.style.display = 'none';
787
+ }, 4000);
788
+ }
789
+ // ── Share Session ───────────────────────────
790
+ async function handleShareSession() {
791
+ var oUrl = new URL(window.location.href);
792
+ oUrl.searchParams.set('session', sSessionCode);
793
+ try {
794
+ await navigator.clipboard.writeText(oUrl.toString());
795
+ eBtnShare.textContent = '\u2713 Copied!';
796
+ setTimeout(function () { eBtnShare.textContent = '\uD83D\uDD17 Share'; }, 2000);
797
+ } catch (oErr) {
798
+ showError('Could not copy link');
799
+ }
800
+ }
801
+ // ── End Session (Owner) ────────────────────
802
+ async function handleEndSession() {
803
+ if (!bIsOwner) return;
804
+
805
+ try {
806
+ await updateItem(S_TBL_SESSION, S_PK_SESSION, sSessionId, {
807
+ wd_isactive: false
808
+ });
809
+ handleLeaveSession();
810
+ } catch (oErr) {
811
+ showError('End session: ' + (oErr.message || oErr));
812
+ }
813
+ }
814
+
815
+ // ── Leave Session ──────────────────────────
816
+ function handleLeaveSession() {
817
+ stopPolling();
818
+ clearSessionStorage();
819
+
820
+ sSessionId = '';
821
+ sSessionCode = '';
822
+ sSessionName = '';
823
+ sParticipantId = '';
824
+ sDisplayName = '';
825
+ bIsOwner = false;
826
+ sCurrentRoundId = '';
827
+ sCurrentRoundIdentifier = '';
828
+ sCurrentRoundDescription = '';
829
+ sSelectedCard = '';
830
+ bHasVoted = false;
831
+ iCurrentRoundStatus = -1;
832
+ aParticipants = [];
833
+ aCurrentVotes = [];
834
+ sDismissedRoundId = '';
835
+
836
+ document.getElementById('btn-leave').style.display = 'none';
837
+ eBtnShare.style.display = 'none';
838
+ eBtnEndSession.style.display = 'none';
839
+ showView('welcome');
840
+ }
841
+
842
+ function saveSessionStorage() {
843
+ sessionStorage.setItem('pp_sessionId', sSessionId);
844
+ sessionStorage.setItem('pp_participantId', sParticipantId);
845
+ sessionStorage.setItem('pp_displayName', sDisplayName);
846
+ }
847
+
848
+ function clearSessionStorage() {
849
+ sessionStorage.removeItem('pp_sessionId');
850
+ sessionStorage.removeItem('pp_participantId');
851
+ sessionStorage.removeItem('pp_displayName');
852
+ }
853
+
854
+ // ── Previous Rounds Table ──────────────────
855
+ async function loadPreviousRounds() {
856
+ try {
857
+ var oRoundResult = await listItems(S_TBL_ROUND, S_PK_ROUND, {});
858
+ var aAllRounds = oRoundResult.entities || [];
859
+ var aRounds = aAllRounds.filter(function (oR) { return !isRoundOpen(oR); });
860
+ if (aRounds.length === 0) {
861
+ ePreviousRoundsPanel.style.display = 'none';
862
+ return;
863
+ }
864
+
865
+ // Fetch session names for display
866
+ var aSessionIds = [];
867
+ aRounds.forEach(function (oR) {
868
+ if (oR._wd_session_value && aSessionIds.indexOf(oR._wd_session_value) === -1) {
869
+ aSessionIds.push(oR._wd_session_value);
870
+ }
871
+ });
872
+
873
+ var oSessionMap = {};
874
+ for (var i = 0; i < aSessionIds.length; i++) {
875
+ try {
876
+ var oSess = await getItem(S_TBL_SESSION, S_PK_SESSION, aSessionIds[i], [S_PK_SESSION, 'wd_name']);
877
+ oSessionMap[aSessionIds[i]] = oSess.wd_name || 'Unknown';
878
+ } catch (e) {
879
+ oSessionMap[aSessionIds[i]] = 'Unknown';
880
+ }
881
+ }
882
+
883
+ aPreviousRounds = aRounds.map(function (oR) {
884
+ return {
885
+ session: oSessionMap[oR._wd_session_value] || 'Unknown',
886
+ roundName: oR.wd_roundidentifier || '',
887
+ description: oR.wd_description || '',
888
+ average: oR.wd_averagescore != null ? parseFloat(oR.wd_averagescore) : null,
889
+ median: oR.wd_medianscore != null ? parseFloat(oR.wd_medianscore) : null
890
+ };
891
+ });
892
+
893
+ renderPreviousRoundsTable();
894
+ ePreviousRoundsPanel.style.display = '';
895
+ setupSortButtons();
896
+ } catch (oErr) {
897
+ showError('Previous rounds: ' + (oErr.message || oErr));
898
+ }
899
+ }
900
+
901
+ function renderPreviousRoundsTable() {
902
+ var sHtml = '';
903
+ aPreviousRounds.forEach(function (oRow) {
904
+ sHtml += '<tr>' +
905
+ '<td>' + escapeHtml(oRow.session) + '</td>' +
906
+ '<td>' + escapeHtml(oRow.roundName) + '</td>' +
907
+ '<td>' + escapeHtml(oRow.description) + '</td>' +
908
+ '<td>' + (oRow.average !== null ? oRow.average.toFixed(1) : '\u2014') + '</td>' +
909
+ '<td>' + (oRow.median !== null ? oRow.median.toFixed(1) : '\u2014') + '</td>' +
910
+ '</tr>';
911
+ });
912
+ ePreviousRoundsBody.innerHTML = sHtml;
913
+ }
914
+
915
+ function setupSortButtons() {
916
+ var aBtns = document.querySelectorAll('.sort-btn');
917
+ aBtns.forEach(function (eBtn) {
918
+ eBtn.addEventListener('click', function () {
919
+ var sCol = eBtn.getAttribute('data-col');
920
+ var sDir = eBtn.getAttribute('data-dir');
921
+ var sNewDir = sDir === 'asc' ? 'desc' : 'asc';
922
+
923
+ // Reset all buttons
924
+ aBtns.forEach(function (eB) {
925
+ eB.setAttribute('data-dir', '');
926
+ eB.className = 'sort-btn';
927
+ });
928
+
929
+ eBtn.setAttribute('data-dir', sNewDir);
930
+ eBtn.className = 'sort-btn ' + sNewDir;
931
+ sSortCol = sCol;
932
+ sSortDir = sNewDir;
933
+
934
+ sortPreviousRounds();
935
+ renderPreviousRoundsTable();
936
+ });
937
+ });
938
+ }
939
+
940
+ function sortPreviousRounds() {
941
+ var iDir = sSortDir === 'desc' ? -1 : 1;
942
+ aPreviousRounds.sort(function (oA, oB) {
943
+ var vA = oA[sSortCol];
944
+ var vB = oB[sSortCol];
945
+ if (vA === null && vB === null) return 0;
946
+ if (vA === null) return 1;
947
+ if (vB === null) return -1;
948
+ if (typeof vA === 'string') {
949
+ return iDir * vA.localeCompare(vB);
950
+ }
951
+ return iDir * (vA - vB);
952
+ });
953
+ }
954
+
955
955
  boot();