kalvium-worklog 1.0.0

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.
@@ -0,0 +1,387 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
6
+ <meta name="apple-mobile-web-app-capable" content="yes">
7
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
8
+ <meta name="apple-mobile-web-app-title" content="Worklog">
9
+ <meta name="theme-color" content="#0f172a">
10
+ <title>Kalvium Worklog</title>
11
+ <style>
12
+ * { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
13
+ body {
14
+ font-family: -apple-system, sans-serif;
15
+ background: #0f172a; color: #f8fafc;
16
+ min-height: 100vh; display: flex; flex-direction: column;
17
+ align-items: center; justify-content: center; padding: 20px;
18
+ -webkit-user-select: none; user-select: none;
19
+ }
20
+ .card {
21
+ background: #1e293b; border-radius: 20px; padding: 28px 22px;
22
+ width: 100%; max-width: 380px; box-shadow: 0 10px 40px rgba(0,0,0,0.4);
23
+ }
24
+ .header { display: flex; align-items: center; gap: 10px; margin-bottom: 20px; }
25
+ .logo { width: 36px; height: 36px; border-radius: 10px; background: #3b82f6;
26
+ display: flex; align-items: center; justify-content: center;
27
+ font-weight: 800; font-size: 18px; color: white; flex-shrink: 0; }
28
+ h1 { font-size: 20px; font-weight: 700; }
29
+ .sub { color: #64748b; font-size: 13px; margin-bottom: 20px; }
30
+ textarea {
31
+ width: 100%; min-height: 80px; border-radius: 12px;
32
+ border: 2px solid #334155; background: #0f172a; color: #f8fafc;
33
+ padding: 14px; font-size: 16px; resize: none; margin-bottom: 14px;
34
+ -webkit-user-select: text; user-select: text; font-family: inherit;
35
+ }
36
+ textarea:focus { outline: none; border-color: #3b82f6; }
37
+ .btn {
38
+ width: 100%; padding: 15px; border-radius: 12px; border: none;
39
+ font-size: 16px; font-weight: 600; cursor: pointer; transition: 0.15s;
40
+ -webkit-user-select: none; user-select: none;
41
+ }
42
+ .btn-submit { background: #3b82f6; color: white; margin-bottom: 8px; }
43
+ .btn-submit:active { transform: scale(0.97); background: #2563eb; }
44
+ .btn-submit:disabled { opacity: 0.4; }
45
+ .btn-status { background: #334155; color: #94a3b8; font-size: 14px; }
46
+ .btn-status:active { transform: scale(0.97); }
47
+ .btn-setup { background: transparent; color: #64748b; font-size: 13px;
48
+ margin-top: 12px; text-decoration: underline; }
49
+ #result {
50
+ margin-top: 14px; padding: 12px 14px; border-radius: 10px;
51
+ font-size: 14px; display: none; line-height: 1.4;
52
+ }
53
+ .success { background: #064e3b; color: #6ee7b7; }
54
+ .error { background: #7f1d1d; color: #fca5a5; }
55
+ .loading { background: #1e293b; color: #94a3b8; }
56
+ .spinner { display: inline-block; width: 14px; height: 14px;
57
+ border: 2px solid #64748b; border-top-color: #f8fafc;
58
+ border-radius: 50%; animation: spin 0.8s linear infinite;
59
+ vertical-align: middle; margin-right: 6px; }
60
+ @keyframes spin { to { transform: rotate(360deg); } }
61
+ .setup-panel { display: none; margin-top: 16px; }
62
+ .setup-panel textarea { min-height: 120px; font-size: 12px; }
63
+ .token-status { font-size: 12px; color: #64748b; text-align: center; margin-top: 10px; }
64
+ .qr-section { margin-top: 16px; text-align: center; }
65
+ .qr-section canvas { margin: 10px auto; display: block; }
66
+ .qr-hint { font-size: 12px; color: #64748b; margin-top: 8px; }
67
+ </style>
68
+ </head>
69
+ <body>
70
+ <div class="card">
71
+ <div class="header">
72
+ <div class="logo">K</div>
73
+ <div>
74
+ <h1>Kalvium Worklog</h1>
75
+ <div class="sub" id="dateDisplay"></div>
76
+ </div>
77
+ </div>
78
+
79
+ <textarea id="text" placeholder="What did you work on today?">working</textarea>
80
+
81
+ <button class="btn btn-submit" id="submitBtn" onclick="submitWorklog()">Submit Worklog</button>
82
+ <button class="btn btn-status" onclick="checkStatus()">Check Status</button>
83
+ <button class="btn btn-setup" onclick="toggleSetup()">Setup / Token</button>
84
+
85
+ <div class="setup-panel" id="setupPanel">
86
+ <p class="sub" style="margin-bottom:10px">Paste token JSON from computer, or scan QR code:</p>
87
+ <textarea id="tokenInput" placeholder='Paste refresh token JSON here'></textarea>
88
+ <button class="btn btn-submit" onclick="saveToken()" style="margin-top:8px">Save Token</button>
89
+
90
+ <div class="qr-section" id="qrSection">
91
+ <p class="sub">Or scan QR code from computer:</p>
92
+ <button class="btn btn-status" onclick="scanQR()">Scan QR Code</button>
93
+ </div>
94
+ </div>
95
+
96
+ <div class="token-status" id="tokenStatus"></div>
97
+ <div id="result"></div>
98
+ </div>
99
+
100
+ <script>
101
+ // ─── Config ──────────────────────────────────────────────────────────────
102
+ const KEYCLOAK_URL = "https://auth.kalvium.community/auth/realms/kalvium/protocol/openid-connect/token";
103
+ const CLIENT_ID = "login_client";
104
+ const WORK_STATUS = "Working on-site (Company location)";
105
+ const CATEGORY = "on_site";
106
+ const API_BASE = "https://student-api.kalvium.community/api/internships/worklogs";
107
+
108
+ // ─── JWT decode (extract position_id from token) ─────────────────────────
109
+ function decodeJwt(token) {
110
+ try {
111
+ const payload = token.split('.')[1];
112
+ const decoded = atob(payload.replace(/-/g, '+').replace(/_/g, '/'));
113
+ return JSON.parse(decoded);
114
+ } catch (e) {
115
+ return null;
116
+ }
117
+ }
118
+
119
+ function getPositionId() {
120
+ const tokens = getTokenData();
121
+ if (!tokens || !tokens.access_token) return null;
122
+ const payload = decodeJwt(tokens.access_token);
123
+ return payload ? payload.sub : null;
124
+ }
125
+
126
+ function getWorklogApi() {
127
+ const posId = getPositionId();
128
+ if (!posId) return null;
129
+ return `${API_BASE}/${posId}`;
130
+ }
131
+
132
+ // ─── Token Management ────────────────────────────────────────────────────
133
+ function getTokenData() {
134
+ try { return JSON.parse(localStorage.getItem("kalvium_tokens")); }
135
+ catch { return null; }
136
+ }
137
+
138
+ function saveTokenData(data) {
139
+ localStorage.setItem("kalvium_tokens", JSON.stringify(data));
140
+ }
141
+
142
+ // Track pending action for retry after re-login
143
+ let pendingAction = null; // 'submit' or 'status'
144
+
145
+ async function refreshAccessToken() {
146
+ const tokens = getTokenData();
147
+ if (!tokens || !tokens.refresh_token) {
148
+ const err = new Error("No token stored");
149
+ err.code = "NO_TOKEN";
150
+ throw err;
151
+ }
152
+
153
+ const body = new URLSearchParams({
154
+ grant_type: "refresh_token",
155
+ client_id: CLIENT_ID,
156
+ refresh_token: tokens.refresh_token
157
+ });
158
+
159
+ const res = await fetch(KEYCLOAK_URL, {
160
+ method: "POST",
161
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
162
+ body: body
163
+ });
164
+
165
+ const data = await res.json();
166
+ if (data.error) {
167
+ const err = new Error(`Token expired (${data.error})`);
168
+ err.code = "TOKEN_EXPIRED";
169
+ err.data = data;
170
+ throw err;
171
+ }
172
+
173
+ // Save new tokens (refresh token rotates)
174
+ saveTokenData(data);
175
+ updateTokenStatus();
176
+ return data.access_token;
177
+ }
178
+
179
+ // Show "token expired" UI and prompt user to paste a new one
180
+ function showTokenExpiredUI(originalError) {
181
+ const result = document.getElementById("result");
182
+ result.style.display = "block";
183
+ result.className = "error";
184
+ result.innerHTML = `
185
+ ✗ ${originalError.message}<br><br>
186
+ <b>Token expired.</b> Get a new token from your computer:<br>
187
+ 1. Run <code>python ~/.kalvium/capture_token.py</code> on your computer<br>
188
+ 2. Copy the token JSON<br>
189
+ 3. Tap "Setup / Token" below and paste it<br>
190
+ 4. Your action will retry automatically
191
+ `;
192
+ // Auto-open the setup panel
193
+ document.getElementById("setupPanel").style.display = "block";
194
+ document.getElementById("tokenInput").focus();
195
+ }
196
+
197
+ // ─── Submit Worklog ──────────────────────────────────────────────────────
198
+ async function submitWorklog() {
199
+ const text = document.getElementById("text").value.trim();
200
+ if (!text) return alert("Enter some text first");
201
+
202
+ pendingAction = { type: 'submit', text: text };
203
+ await doSubmit(text);
204
+ }
205
+
206
+ async function doSubmit(text) {
207
+ const btn = document.getElementById("submitBtn");
208
+ const result = document.getElementById("result");
209
+ btn.disabled = true;
210
+ result.style.display = "block";
211
+ result.className = "loading";
212
+ result.innerHTML = '<span class="spinner"></span>Refreshing token...';
213
+
214
+ try {
215
+ const accessToken = await refreshAccessToken();
216
+ const worklogApi = getWorklogApi();
217
+ if (!worklogApi) throw new Error("Could not determine position ID from token");
218
+
219
+ result.innerHTML = '<span class="spinner"></span>Submitting worklog...';
220
+
221
+ const body = JSON.stringify({
222
+ title: WORK_STATUS,
223
+ description: WORK_STATUS,
224
+ category: CATEGORY,
225
+ timeSpent: 0,
226
+ priorityLevel: "medium",
227
+ blockers: "",
228
+ worklogs: JSON.stringify({ content: `<p>${text}</p>` })
229
+ });
230
+
231
+ const res = await fetch(worklogApi, {
232
+ method: "PUT",
233
+ headers: {
234
+ "authorization": `Bearer ${accessToken}`,
235
+ "content-type": "application/json",
236
+ "origin": "https://kalvium.community",
237
+ "referer": "https://kalvium.community/"
238
+ },
239
+ body: body
240
+ });
241
+
242
+ const data = await res.json();
243
+
244
+ if (res.ok && data.worklogId) {
245
+ result.className = "success";
246
+ result.innerHTML = `✓ Submitted!<br>ID: ${data.worklogId} | Status: ${data.status}`;
247
+ pendingAction = null; // Clear on success
248
+ } else if (res.status === 401 || res.status === 403) {
249
+ // Token invalid at API level — treat as expired
250
+ const err = new Error("Token rejected by server");
251
+ err.code = "TOKEN_EXPIRED";
252
+ throw err;
253
+ } else if (res.status === 404) {
254
+ result.className = "success";
255
+ result.innerHTML = "✓ Already submitted today!";
256
+ pendingAction = null;
257
+ } else {
258
+ result.className = "error";
259
+ result.innerHTML = `✗ ${data.message || "Failed"}`;
260
+ }
261
+ } catch (e) {
262
+ if (e.code === "TOKEN_EXPIRED" || e.code === "NO_TOKEN") {
263
+ showTokenExpiredUI(e);
264
+ } else {
265
+ result.className = "error";
266
+ result.innerHTML = `✗ ${e.message}`;
267
+ }
268
+ }
269
+ btn.disabled = false;
270
+ }
271
+
272
+ // ─── Check Status ────────────────────────────────────────────────────────
273
+ async function checkStatus() {
274
+ pendingAction = { type: 'status' };
275
+ await doCheckStatus();
276
+ }
277
+
278
+ async function doCheckStatus() {
279
+ const result = document.getElementById("result");
280
+ result.style.display = "block";
281
+ result.className = "loading";
282
+ result.innerHTML = '<span class="spinner"></span>Checking...';
283
+
284
+ try {
285
+ const accessToken = await refreshAccessToken();
286
+ const worklogApi = getWorklogApi();
287
+ if (!worklogApi) throw new Error("Could not determine position ID from token");
288
+
289
+ const res = await fetch(worklogApi, {
290
+ headers: { "authorization": `Bearer ${accessToken}` }
291
+ });
292
+
293
+ if (res.status === 401 || res.status === 403) {
294
+ const err = new Error("Token rejected by server");
295
+ err.code = "TOKEN_EXPIRED";
296
+ throw err;
297
+ }
298
+
299
+ const worklogs = await res.json();
300
+ const today = new Date().toISOString().slice(0, 10);
301
+
302
+ let html = "";
303
+ for (const w of worklogs.slice(0, 5)) {
304
+ const date = (w.worklogDate || "").slice(0, 10);
305
+ const status = w.status || "?";
306
+ let content = "";
307
+ if (w.worklogs) content = (w.worklogs.content || "").replace(/<\/?p>/g, "");
308
+ const marker = date === today ? " ← TODAY" : "";
309
+ html += `${date} | ${status} | ${content}${marker}<br>`;
310
+ }
311
+ result.className = "success";
312
+ result.innerHTML = html;
313
+ pendingAction = null; // Clear on success
314
+ } catch (e) {
315
+ if (e.code === "TOKEN_EXPIRED" || e.code === "NO_TOKEN") {
316
+ showTokenExpiredUI(e);
317
+ } else {
318
+ result.className = "error";
319
+ result.innerHTML = `✗ ${e.message}`;
320
+ }
321
+ }
322
+ }
323
+
324
+ // ─── Setup ───────────────────────────────────────────────────────────────
325
+ function toggleSetup() {
326
+ const panel = document.getElementById("setupPanel");
327
+ panel.style.display = panel.style.display === "block" ? "none" : "block";
328
+ }
329
+
330
+ function saveToken() {
331
+ const input = document.getElementById("tokenInput").value.trim();
332
+ try {
333
+ const data = JSON.parse(input);
334
+ if (data.refresh_token) {
335
+ saveTokenData(data);
336
+ updateTokenStatus();
337
+ document.getElementById("tokenInput").value = "";
338
+ document.getElementById("setupPanel").style.display = "none";
339
+
340
+ // Auto-retry the pending action if there was one
341
+ if (pendingAction) {
342
+ if (pendingAction.type === 'submit') {
343
+ alert("Token saved! Retrying your submission...");
344
+ doSubmit(pendingAction.text);
345
+ } else if (pendingAction.type === 'status') {
346
+ alert("Token saved! Retrying status check...");
347
+ doCheckStatus();
348
+ }
349
+ } else {
350
+ alert("Token saved! You can now submit worklogs.");
351
+ }
352
+ } else {
353
+ alert("Invalid token JSON — no refresh_token found");
354
+ }
355
+ } catch (e) {
356
+ alert("Invalid JSON: " + e.message);
357
+ }
358
+ }
359
+
360
+ function scanQR() {
361
+ // Open camera for QR scanning using a simple approach
362
+ // We use a public QR scanner that returns the data via URL
363
+ const qrScannerUrl = "https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=";
364
+ alert("To scan QR:\n1. Run 'python generate_webapp.py' on your computer\n2. It will show a QR code\n3. Scan it with your phone camera\n4. Copy the text and paste it above");
365
+ }
366
+
367
+ function updateTokenStatus() {
368
+ const tokens = getTokenData();
369
+ const el = document.getElementById("tokenStatus");
370
+ if (tokens && tokens.refresh_token) {
371
+ const posId = getPositionId();
372
+ el.textContent = `✓ Token stored${posId ? ' (Position: ' + posId.slice(0,8) + '...)' : ''}`;
373
+ el.style.color = "#6ee7b7";
374
+ } else {
375
+ el.textContent = "⚠ No token — tap Setup to add";
376
+ el.style.color = "#fca5a5";
377
+ }
378
+ }
379
+
380
+ // ─── Init ────────────────────────────────────────────────────────────────
381
+ document.getElementById("dateDisplay").textContent = new Date().toLocaleDateString("en-US", {
382
+ weekday: "long", year: "numeric", month: "long", day: "numeric"
383
+ });
384
+ updateTokenStatus();
385
+ </script>
386
+ </body>
387
+ </html>