codeapp-js 0.3.0 → 1.0.1

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 (158) hide show
  1. package/AI/codeapp.agent.md +105 -0
  2. package/AI/skills/connections/SKILL.md +47 -0
  3. package/AI/skills/dataverse/SKILL.md +99 -0
  4. package/AI/skills/environment-variables/SKILL.md +89 -0
  5. package/AI/skills/frontend-design/SKILL.md +34 -0
  6. package/AI/skills/jira/SKILL.md +81 -0
  7. package/AI/skills/office365-groups/SKILL.md +61 -0
  8. package/AI/skills/office365-outlook/SKILL.md +52 -0
  9. package/AI/skills/office365-users/SKILL.md +78 -0
  10. package/AI/skills/sharepoint/SKILL.md +77 -0
  11. package/AI/skills/sql/SKILL.md +85 -0
  12. package/AI/skills/start/SKILL.md +46 -0
  13. package/AI/skills/teams/SKILL.md +55 -0
  14. package/{examples/combined demo/.power/schemas/office365groups/office365groups.Schema.json → codeApp/.power/schemas/office365groups/office365groups.Schema.json} +2203 -2203
  15. package/codeApp/dist/codeapp.js +95 -1792
  16. package/codeApp/dist/connectors/azureKeyvault.js +459 -0
  17. package/codeApp/dist/connectors/jira.js +1247 -0
  18. package/codeApp/dist/connectors/office365groups.js +642 -0
  19. package/codeApp/dist/connectors/office365users.js +513 -0
  20. package/codeApp/dist/connectors/outlook.js +1393 -0
  21. package/codeApp/dist/connectors/sharepoint.js +466 -0
  22. package/codeApp/dist/connectors/sql.js +149 -0
  23. package/codeApp/dist/connectors/teams.js +280 -0
  24. package/codeApp/dist/power-apps-data.js +16 -2
  25. package/examples/{kanban → apps/kanban}/dist/dataverse.js +94 -94
  26. package/examples/{kanban → apps/kanban}/dist/environmentVar.js +55 -55
  27. package/examples/{kanban → apps/kanban}/dist/index.css +605 -605
  28. package/examples/{kanban → apps/kanban}/dist/index.html +21 -21
  29. package/examples/{kanban → apps/kanban}/dist/index.js +860 -860
  30. package/examples/{kanban → apps/kanban}/dist/office365groups.js +97 -97
  31. package/examples/apps/kanban/dist/office365users.js +451 -0
  32. package/examples/{kanban → apps/kanban}/dist/outlook.js +162 -162
  33. package/examples/{planning Poker/dist/power-apps-data.js → apps/kanban/dist/power-apps-data.js} +2953 -2953
  34. package/examples/{kanban → apps/kanban}/dist/sharepoint.js +435 -339
  35. package/examples/{kanban → apps/kanban}/power.config.json +35 -35
  36. package/examples/{planning Poker → apps/planning Poker}/additional files/customizations (tables).xml +6428 -6428
  37. package/examples/{planning Poker → apps/planning Poker}/additional files/dataverse-tables.json +165 -165
  38. package/examples/{planning Poker → apps/planning Poker}/additional files/readme.md +122 -122
  39. package/examples/{planning Poker → apps/planning Poker}/dist/dataverse.js +78 -78
  40. package/examples/{planning Poker → apps/planning Poker}/dist/index.html +198 -198
  41. package/examples/{planning Poker → apps/planning Poker}/dist/index.js +954 -954
  42. package/examples/{todo/dist/power-apps-data.js → apps/planning Poker/dist/power-apps-data.js } +2953 -2953
  43. package/examples/{planning Poker → apps/planning Poker}/dist/styles.css +815 -815
  44. package/examples/{planning Poker → apps/planning Poker}/power.config.json +50 -50
  45. package/examples/{outlook Demo2 → apps/solution explorer}/dist/codeapp.js +9 -245
  46. package/examples/apps/solution explorer/dist/index.html +80 -0
  47. package/examples/apps/solution explorer/dist/index.js +735 -0
  48. package/examples/apps/solution explorer/dist/styles.css +571 -0
  49. package/examples/apps/solution explorer/power.config.json +151 -0
  50. package/examples/{todo → apps/todo}/dist/dataverse.js +64 -64
  51. package/examples/{todo → apps/todo}/dist/index.html +75 -75
  52. package/examples/{todo → apps/todo}/dist/index.js +8 -8
  53. package/examples/{kanban → apps/todo}/dist/power-apps-data.js +2953 -2953
  54. package/examples/{todo → apps/todo}/dist/renderer.js +375 -375
  55. package/examples/{todo → apps/todo}/dist/styles.css +691 -691
  56. package/examples/{todo → apps/todo}/power.config.json +34 -34
  57. package/examples/combined demo/.power/schemas/appschemas/dataSourcesInfo.ts +6275 -7830
  58. package/examples/combined demo/.power/schemas/jira/jira.Schema.json +6903 -0
  59. package/examples/combined demo/.power/schemas/keyvault/keyvault.Schema.json +1600 -0
  60. package/examples/combined demo/.power/schemas/teams/teams.Schema.json +11112 -0
  61. package/examples/combined demo/dist/codeapp.js +394 -1098
  62. package/examples/{outlook Demo2/OutlookDemo_1_0_0_1.zip → combined demo/dist/icon-512.png} +0 -0
  63. package/examples/combined demo/dist/index.html +29 -511
  64. package/examples/combined demo/dist/index.js +490 -470
  65. package/examples/combined demo/dist/office365users.js +513 -0
  66. package/examples/combined demo/dist/outlook.js +1393 -0
  67. package/examples/combined demo/dist/power-apps-data.js +3079 -3006
  68. package/examples/combined demo/dist/styles.css +483 -0
  69. package/examples/combined demo/power.config.json +33 -42
  70. package/examples/combined demo/src/generated/index.ts +12 -14
  71. package/examples/combined demo/src/generated/models/AzureKeyVaultModel.ts +107 -0
  72. package/examples/combined demo/src/generated/models/JiraModel.ts +501 -0
  73. package/examples/combined demo/src/generated/services/AzureKeyVaultService.ts +257 -0
  74. package/examples/combined demo/src/generated/services/JiraService.ts +1124 -0
  75. package/examples/dataverse Demo/dist/codeapp.js +394 -1085
  76. package/examples/dataverse Demo/dist/icon-512.png +0 -0
  77. package/examples/dataverse Demo/dist/index.html +146 -54
  78. package/examples/dataverse Demo/dist/index.js +693 -83
  79. package/examples/dataverse Demo/dist/power-apps-data.js +3079 -2911
  80. package/examples/dataverse Demo/dist/styles.css +528 -0
  81. package/examples/dataverse Demo/power.config.json +41 -35
  82. package/examples/dataverse Demo/readme.md +79 -79
  83. package/examples/groups Demo/dist/codeapp.js +394 -1085
  84. package/examples/groups Demo/dist/icon-512.png +0 -0
  85. package/examples/groups Demo/dist/index.html +21 -25
  86. package/examples/groups Demo/dist/index.js +304 -113
  87. package/examples/groups Demo/dist/office365groups.js +642 -0
  88. package/examples/groups Demo/dist/power-apps-data.js +3079 -2911
  89. package/examples/groups Demo/dist/styles.css +509 -0
  90. package/examples/groups Demo/power.config.json +25 -25
  91. package/examples/myProfile/dist/codeapp.js +398 -0
  92. package/examples/myProfile/dist/index.html +21 -184
  93. package/examples/myProfile/dist/index.js +324 -141
  94. package/examples/myProfile/dist/office365users.js +517 -169
  95. package/examples/myProfile/dist/power-apps-data.js +3080 -2953
  96. package/examples/myProfile/dist/styles.css +458 -0
  97. package/examples/myProfile/power.config.json +24 -23
  98. package/examples/outlook Demo/dist/codeapp.js +394 -1085
  99. package/examples/outlook Demo/dist/index.html +150 -35
  100. package/examples/outlook Demo/dist/index.js +516 -170
  101. package/examples/outlook Demo/dist/outlook.js +1393 -121
  102. package/examples/outlook Demo/dist/power-apps-data.js +3079 -2911
  103. package/examples/outlook Demo/dist/styles.css +408 -84
  104. package/examples/outlook Demo/power.config.json +24 -23
  105. package/examples/outlook Demo/readme.md +92 -82
  106. package/examples/sharePoint Demo/dist/codeapp.js +394 -1085
  107. package/examples/sharePoint Demo/dist/icon-512.png +0 -0
  108. package/examples/sharePoint Demo/dist/index.html +22 -255
  109. package/examples/sharePoint Demo/dist/index.js +899 -262
  110. package/examples/sharePoint Demo/dist/power-apps-data.js +3079 -2911
  111. package/{dev files → examples/sharePoint Demo/dist}/sharepoint.js +239 -112
  112. package/examples/sharePoint Demo/dist/styles.css +587 -0
  113. package/examples/sharePoint Demo/power.config.json +23 -22
  114. package/package.json +1 -1
  115. package/readme.md +465 -76
  116. package/.vscode/settings.json +0 -6
  117. package/dev files/customConnector.js +0 -98
  118. package/dev files/dataverse.js +0 -120
  119. package/dev files/environmentVar.js +0 -55
  120. package/dev files/office365groups.js +0 -65
  121. package/dev files/office365users.js +0 -169
  122. package/dev files/outlook.js +0 -330
  123. package/dev files/power-apps-data.js +0 -2952
  124. package/examples/combined demo/.power/schemas/office365/office365.Schema.json +0 -21098
  125. package/examples/combined demo/.power/schemas/office365users/office365users.Schema.json +0 -2094
  126. package/examples/kanban/agent/decision-log.md +0 -9
  127. package/examples/kanban/agent/mockup-01-editorial-glass.html +0 -159
  128. package/examples/kanban/agent/mockup-02-dark-rail.html +0 -147
  129. package/examples/kanban/agent/mockup-03-paper-grid.html +0 -114
  130. package/examples/kanban/agent/mockup-04-neon-minimal.html +0 -141
  131. package/examples/kanban/agent/mockup-05-mono-architect.html +0 -119
  132. package/examples/kanban/dist/office365users.js +0 -169
  133. package/examples/kanban/src/generated/index.ts +0 -14
  134. package/examples/kanban/src/generated/models/Office365GroupsModel.ts +0 -363
  135. package/examples/kanban/src/generated/models/Office365OutlookModel.ts +0 -2046
  136. package/examples/kanban/src/generated/models/Office365UsersModel.ts +0 -254
  137. package/examples/kanban/src/generated/services/Office365GroupsService.ts +0 -326
  138. package/examples/kanban/src/generated/services/Office365OutlookService.ts +0 -2476
  139. package/examples/kanban/src/generated/services/Office365UsersService.ts +0 -358
  140. package/examples/outlook Demo2/agent/decision-log.md +0 -7
  141. package/examples/outlook Demo2/dist/index.html +0 -98
  142. package/examples/outlook Demo2/dist/index.js +0 -272
  143. package/examples/outlook Demo2/dist/styles.css +0 -639
  144. package/examples/outlook Demo2/power.config.json +0 -23
  145. package/examples/planning Poker/.vscode/settings.json +0 -5
  146. package/examples/sharePoint Demo/agent/decision-log.md +0 -17
  147. /package/examples/{outlook Demo2 → apps/kanban}/src/generated/index.ts +0 -0
  148. /package/examples/{outlook Demo2 → apps/kanban}/src/generated/models/Office365GroupsModel.ts +0 -0
  149. /package/examples/{outlook Demo2 → apps/kanban}/src/generated/models/Office365OutlookModel.ts +0 -0
  150. /package/examples/{outlook Demo2 → apps/kanban}/src/generated/models/Office365UsersModel.ts +0 -0
  151. /package/examples/{outlook Demo2 → apps/kanban}/src/generated/services/Office365GroupsService.ts +0 -0
  152. /package/examples/{outlook Demo2 → apps/kanban}/src/generated/services/Office365OutlookService.ts +0 -0
  153. /package/examples/{outlook Demo2 → apps/kanban}/src/generated/services/Office365UsersService.ts +0 -0
  154. /package/examples/{planning Poker → apps/planning Poker}/additional files/AgilePoker_1_0_0_1.zip +0 -0
  155. /package/examples/{planning Poker → apps/planning Poker}/additional files/PokerTables_1_0_0_1.zip +0 -0
  156. /package/examples/{outlook Demo2 → apps/solution explorer}/dist/icon-512.png +0 -0
  157. /package/examples/{outlook Demo2 → apps/solution explorer}/dist/power-apps-data.js +0 -0
  158. /package/examples/{todo → apps/todo}/dist/icon192.png +0 -0
@@ -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();