@mideind/netskrafl-react 1.0.2 → 1.1.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.
package/dist/esm/index.js CHANGED
@@ -15,6 +15,8 @@ const saveAuthSettings = (settings) => {
15
15
  userEmail: settings.userEmail, // Required field
16
16
  };
17
17
  // Only add optional fields if they are defined
18
+ if (settings.account !== undefined)
19
+ filteredSettings.account = settings.account;
18
20
  if (settings.userId !== undefined)
19
21
  filteredSettings.userId = settings.userId;
20
22
  if (settings.userNick !== undefined)
@@ -83,6 +85,7 @@ const applyPersistedSettings = (state) => {
83
85
  return {
84
86
  ...state,
85
87
  // Only apply persisted values if current values are defaults
88
+ account: state.account || persisted.account || state.userId, // Use userId as fallback
86
89
  userId: state.userId || persisted.userId || state.userId,
87
90
  userNick: state.userNick || persisted.userNick || state.userNick,
88
91
  firebaseAPIKey: state.firebaseAPIKey || persisted.firebaseAPIKey || state.firebaseAPIKey,
@@ -100,6 +103,7 @@ const DEFAULT_STATE = {
100
103
  firebaseSenderId: "",
101
104
  firebaseAppId: "",
102
105
  measurementId: "",
106
+ account: "",
103
107
  userEmail: "",
104
108
  userId: "",
105
109
  userNick: "",
@@ -23529,6 +23533,9 @@ let View$1 = class View {
23529
23533
  function viewGetServerCache(view) {
23530
23534
  return view.viewCache_.serverCache.getNode();
23531
23535
  }
23536
+ function viewGetCompleteNode(view) {
23537
+ return viewCacheGetCompleteEventSnap(view.viewCache_);
23538
+ }
23532
23539
  function viewGetCompleteServerCache(view, path) {
23533
23540
  const cache = viewCacheGetCompleteServerSnap(view.viewCache_);
23534
23541
  if (cache) {
@@ -24190,6 +24197,33 @@ function syncTreeCalcCompleteEventCache(syncTree, path, writeIdsToExclude) {
24190
24197
  });
24191
24198
  return writeTreeCalcCompleteEventCache(writeTree, path, serverCache, writeIdsToExclude, includeHiddenSets);
24192
24199
  }
24200
+ function syncTreeGetServerValue(syncTree, query) {
24201
+ const path = query._path;
24202
+ let serverCache = null;
24203
+ // Any covering writes will necessarily be at the root, so really all we need to find is the server cache.
24204
+ // Consider optimizing this once there's a better understanding of what actual behavior will be.
24205
+ syncTree.syncPointTree_.foreachOnPath(path, (pathToSyncPoint, sp) => {
24206
+ const relativePath = newRelativePath(pathToSyncPoint, path);
24207
+ serverCache =
24208
+ serverCache || syncPointGetCompleteServerCache(sp, relativePath);
24209
+ });
24210
+ let syncPoint = syncTree.syncPointTree_.get(path);
24211
+ if (!syncPoint) {
24212
+ syncPoint = new SyncPoint();
24213
+ syncTree.syncPointTree_ = syncTree.syncPointTree_.set(path, syncPoint);
24214
+ }
24215
+ else {
24216
+ serverCache =
24217
+ serverCache || syncPointGetCompleteServerCache(syncPoint, newEmptyPath());
24218
+ }
24219
+ const serverCacheComplete = serverCache != null;
24220
+ const serverCacheNode = serverCacheComplete
24221
+ ? new CacheNode(serverCache, true, false)
24222
+ : null;
24223
+ const writesCache = writeTreeChildWrites(syncTree.pendingWriteTree_, query._path);
24224
+ const view = syncPointGetView(syncPoint, query, writesCache, serverCacheComplete ? serverCacheNode.getNode() : ChildrenNode.EMPTY_NODE, serverCacheComplete);
24225
+ return viewGetCompleteNode(view);
24226
+ }
24193
24227
  /**
24194
24228
  * A helper method that visits all descendant and ancestor SyncPoints, applying the operation.
24195
24229
  *
@@ -25319,6 +25353,63 @@ function repoUpdateInfo(repo, pathString, value) {
25319
25353
  function repoGetNextWriteId(repo) {
25320
25354
  return repo.nextWriteId_++;
25321
25355
  }
25356
+ /**
25357
+ * The purpose of `getValue` is to return the latest known value
25358
+ * satisfying `query`.
25359
+ *
25360
+ * This method will first check for in-memory cached values
25361
+ * belonging to active listeners. If they are found, such values
25362
+ * are considered to be the most up-to-date.
25363
+ *
25364
+ * If the client is not connected, this method will wait until the
25365
+ * repo has established a connection and then request the value for `query`.
25366
+ * If the client is not able to retrieve the query result for another reason,
25367
+ * it reports an error.
25368
+ *
25369
+ * @param query - The query to surface a value for.
25370
+ */
25371
+ function repoGetValue(repo, query, eventRegistration) {
25372
+ // Only active queries are cached. There is no persisted cache.
25373
+ const cached = syncTreeGetServerValue(repo.serverSyncTree_, query);
25374
+ if (cached != null) {
25375
+ return Promise.resolve(cached);
25376
+ }
25377
+ return repo.server_.get(query).then(payload => {
25378
+ const node = nodeFromJSON(payload).withIndex(query._queryParams.getIndex());
25379
+ /**
25380
+ * Below we simulate the actions of an `onlyOnce` `onValue()` event where:
25381
+ * Add an event registration,
25382
+ * Update data at the path,
25383
+ * Raise any events,
25384
+ * Cleanup the SyncTree
25385
+ */
25386
+ syncTreeAddEventRegistration(repo.serverSyncTree_, query, eventRegistration, true);
25387
+ let events;
25388
+ if (query._queryParams.loadsAllData()) {
25389
+ events = syncTreeApplyServerOverwrite(repo.serverSyncTree_, query._path, node);
25390
+ }
25391
+ else {
25392
+ const tag = syncTreeTagForQuery(repo.serverSyncTree_, query);
25393
+ events = syncTreeApplyTaggedQueryOverwrite(repo.serverSyncTree_, query._path, node, tag);
25394
+ }
25395
+ /*
25396
+ * We need to raise events in the scenario where `get()` is called at a parent path, and
25397
+ * while the `get()` is pending, `onValue` is called at a child location. While get() is waiting
25398
+ * for the data, `onValue` will register a new event. Then, get() will come back, and update the syncTree
25399
+ * and its corresponding serverCache, including the child location where `onValue` is called. Then,
25400
+ * `onValue` will receive the event from the server, but look at the syncTree and see that the data received
25401
+ * from the server is already at the SyncPoint, and so the `onValue` callback will never get fired.
25402
+ * Calling `eventQueueRaiseEventsForChangedPath()` is the correct way to propagate the events and
25403
+ * ensure the corresponding child events will get fired.
25404
+ */
25405
+ eventQueueRaiseEventsForChangedPath(repo.eventQueue_, query._path, events);
25406
+ syncTreeRemoveEventRegistration(repo.serverSyncTree_, query, eventRegistration, null, true);
25407
+ return node;
25408
+ }, err => {
25409
+ repoLog(repo, 'get for query ' + stringify(query) + ' failed: ' + err);
25410
+ return Promise.reject(new Error(err));
25411
+ });
25412
+ }
25322
25413
  function repoSetWithPriority(repo, path, newVal, newPriority, onComplete) {
25323
25414
  repoLog(repo, 'set', {
25324
25415
  path: path.toString(),
@@ -26735,6 +26826,22 @@ function set(ref, value) {
26735
26826
  /*priority=*/ null, deferred.wrapCallback(() => { }));
26736
26827
  return deferred.promise;
26737
26828
  }
26829
+ /**
26830
+ * Gets the most up-to-date result for this query.
26831
+ *
26832
+ * @param query - The query to run.
26833
+ * @returns A `Promise` which resolves to the resulting DataSnapshot if a value is
26834
+ * available, or rejects if the client is unable to return a value (e.g., if the
26835
+ * server is unreachable and there is nothing cached).
26836
+ */
26837
+ function get(query) {
26838
+ query = getModularInstance(query);
26839
+ const callbackContext = new CallbackContext(() => { });
26840
+ const container = new ValueEventRegistration(callbackContext);
26841
+ return repoGetValue(query._repo, query, container).then(node => {
26842
+ return new DataSnapshot(node, new ReferenceImpl(query._repo, query._path), query._queryParams.getIndex());
26843
+ });
26844
+ }
26738
26845
  /**
26739
26846
  * Represents registration for 'value' events.
26740
26847
  */
@@ -27238,6 +27345,14 @@ function logEvent(ev, params) {
27238
27345
  return;
27239
27346
  logEvent$2(analytics, ev, params);
27240
27347
  }
27348
+ async function getFirebaseData(path) {
27349
+ // Get data from a Firebase path
27350
+ if (!database)
27351
+ return null;
27352
+ const pathRef = ref(database, path);
27353
+ const snapshot = await get(pathRef);
27354
+ return snapshot.val();
27355
+ }
27241
27356
 
27242
27357
  /*
27243
27358
 
@@ -27503,50 +27618,61 @@ const ensureAuthenticated = async (state) => {
27503
27618
  await authPromise;
27504
27619
  return;
27505
27620
  }
27506
- // Start new login attempt (either forced by 401 or needed for Firebase)
27507
- authPromise = loginUserByEmail(state);
27508
- try {
27509
- const result = await authPromise;
27510
- if (result.status === "expired") {
27511
- // Token has expired, notify the React component if callback is set
27512
- state.tokenExpired && state.tokenExpired();
27513
- // Clear any persisted settings since they're no longer valid
27514
- clearAuthSettings();
27515
- throw new Error("Token expired");
27621
+ let continueTrying = true;
27622
+ while (continueTrying) {
27623
+ continueTrying = false;
27624
+ // Start new login attempt (either forced by 401 or needed for Firebase)
27625
+ authPromise = loginUserByEmail(state);
27626
+ try {
27627
+ const result = await authPromise;
27628
+ if (result.status === "expired") {
27629
+ // Token has expired, notify the React component if callback is set
27630
+ if (state.tokenExpired) {
27631
+ // We have a callback to renew the token: do it and try again
27632
+ state.tokenExpired();
27633
+ continueTrying = true; // Try logging in again
27634
+ clearAuthSettings();
27635
+ continue;
27636
+ }
27637
+ // Clear any persisted settings since they're no longer valid
27638
+ clearAuthSettings();
27639
+ throw new Error("Authentication token has expired");
27640
+ }
27641
+ else if (result.status !== "success") {
27642
+ // Clear any persisted settings on auth failure
27643
+ clearAuthSettings();
27644
+ throw new Error(`Authentication failed: ${result.message || result.status}`);
27645
+ }
27646
+ // Update the user's ID to the internal one used by the backend and Firebase
27647
+ state.userId = result.user_id || state.userId;
27648
+ state.account = result.account || state.userId;
27649
+ // Update the user's nickname
27650
+ state.userNick = result.nickname || state.userNick;
27651
+ // Use the server's Firebase API key, if provided
27652
+ state.firebaseAPIKey = result.firebase_api_key || state.firebaseAPIKey;
27653
+ // Load state flags and preferences
27654
+ state.beginner = (_b = (_a = result.prefs) === null || _a === void 0 ? void 0 : _a.beginner) !== null && _b !== void 0 ? _b : true;
27655
+ state.fairPlay = (_d = (_c = result.prefs) === null || _c === void 0 ? void 0 : _c.fairplay) !== null && _d !== void 0 ? _d : false;
27656
+ state.ready = (_f = (_e = result.prefs) === null || _e === void 0 ? void 0 : _e.ready) !== null && _f !== void 0 ? _f : true;
27657
+ state.readyTimed = (_h = (_g = result.prefs) === null || _g === void 0 ? void 0 : _g.ready_timed) !== null && _h !== void 0 ? _h : true;
27658
+ // Save the authentication settings to sessionStorage for persistence
27659
+ saveAuthSettings({
27660
+ userEmail: state.userEmail, // CRITICAL: Include email to validate ownership
27661
+ userId: state.userId,
27662
+ userNick: state.userNick,
27663
+ firebaseAPIKey: state.firebaseAPIKey,
27664
+ beginner: state.beginner,
27665
+ fairPlay: state.fairPlay,
27666
+ ready: state.ready,
27667
+ readyTimed: state.readyTimed,
27668
+ });
27669
+ // Success: Log in to Firebase with the token passed from the server
27670
+ await loginFirebase(state, result.firebase_token);
27671
+ }
27672
+ finally {
27673
+ // Reset the promise so future 401s can trigger a new login
27674
+ authPromise = null;
27516
27675
  }
27517
- else if (result.status !== "success") {
27518
- // Clear any persisted settings on auth failure
27519
- clearAuthSettings();
27520
- throw new Error(`Authentication failed: ${result.message || result.status}`);
27521
- }
27522
- // Update the user's ID to the internal one used by the backend and Firebase
27523
- state.userId = result.user_id || state.userId;
27524
- // Update the user's nickname
27525
- state.userNick = result.nickname || state.userNick;
27526
- // Use the server's Firebase API key, if provided
27527
- state.firebaseAPIKey = result.firebase_api_key || state.firebaseAPIKey;
27528
- // Load state flags and preferences
27529
- state.beginner = (_b = (_a = result.prefs) === null || _a === void 0 ? void 0 : _a.beginner) !== null && _b !== void 0 ? _b : true;
27530
- state.fairPlay = (_d = (_c = result.prefs) === null || _c === void 0 ? void 0 : _c.fairplay) !== null && _d !== void 0 ? _d : false;
27531
- state.ready = (_f = (_e = result.prefs) === null || _e === void 0 ? void 0 : _e.ready) !== null && _f !== void 0 ? _f : true;
27532
- state.readyTimed = (_h = (_g = result.prefs) === null || _g === void 0 ? void 0 : _g.ready_timed) !== null && _h !== void 0 ? _h : true;
27533
- // Save the authentication settings to sessionStorage for persistence
27534
- saveAuthSettings({
27535
- userEmail: state.userEmail, // CRITICAL: Include email to validate ownership
27536
- userId: state.userId,
27537
- userNick: state.userNick,
27538
- firebaseAPIKey: state.firebaseAPIKey,
27539
- beginner: state.beginner,
27540
- fairPlay: state.fairPlay,
27541
- ready: state.ready,
27542
- readyTimed: state.readyTimed,
27543
- });
27544
- // Success: Log in to Firebase with the token passed from the server
27545
- await loginFirebase(state, result.firebase_token);
27546
- }
27547
- finally {
27548
- // Reset the promise so future 401s can trigger a new login
27549
- authPromise = null;
27550
27676
  }
27551
27677
  };
27552
27678
  // Internal authenticated request function
@@ -34352,6 +34478,161 @@ class Game extends BaseGame {
34352
34478
  ;
34353
34479
  } // class Game
34354
34480
 
34481
+ /*
34482
+
34483
+ riddlePersistence.ts
34484
+
34485
+ Local persistence for Gáta Dagsins using localStorage
34486
+
34487
+ Copyright (C) 2025 Miðeind ehf.
34488
+
34489
+ The Creative Commons Attribution-NonCommercial 4.0
34490
+ International Public License (CC-BY-NC 4.0) applies to this software.
34491
+ For further information, see https://github.com/mideind/Netskrafl
34492
+
34493
+ */
34494
+ class RiddlePersistence {
34495
+ // Generate storage key for a specific user and date
34496
+ static getStorageKey(userId, date) {
34497
+ return `${this.STORAGE_KEY_PREFIX}${date}_${userId}`;
34498
+ }
34499
+ // Save complete move list to localStorage
34500
+ static saveLocalMoves(userId, date, moves) {
34501
+ if (!userId || !date) {
34502
+ return;
34503
+ }
34504
+ const data = {
34505
+ date,
34506
+ moves,
34507
+ timestamp: new Date().toISOString(),
34508
+ userId,
34509
+ };
34510
+ try {
34511
+ const key = this.getStorageKey(userId, date);
34512
+ localStorage.setItem(key, JSON.stringify(data));
34513
+ // Clean up old entries while we're here
34514
+ this.cleanupOldEntries();
34515
+ }
34516
+ catch (e) {
34517
+ // Handle localStorage quota errors silently
34518
+ console.error('Failed to save moves to localStorage:', e);
34519
+ }
34520
+ }
34521
+ // Load move history from localStorage
34522
+ static loadLocalMoves(userId, date) {
34523
+ if (!userId || !date) {
34524
+ return [];
34525
+ }
34526
+ try {
34527
+ const key = this.getStorageKey(userId, date);
34528
+ const stored = localStorage.getItem(key);
34529
+ if (!stored) {
34530
+ return [];
34531
+ }
34532
+ const data = JSON.parse(stored);
34533
+ // Verify that this data belongs to the correct (current) user
34534
+ if (!data.userId || data.userId !== userId) {
34535
+ return [];
34536
+ }
34537
+ return data.moves || [];
34538
+ }
34539
+ catch (e) {
34540
+ console.error('Failed to load moves from localStorage:', e);
34541
+ return [];
34542
+ }
34543
+ }
34544
+ // Check if user has achieved top score (local check)
34545
+ static hasAchievedTopScore(userId, date, topScore) {
34546
+ const moves = this.loadLocalMoves(userId, date);
34547
+ return moves.some(move => move.score >= topScore);
34548
+ }
34549
+ // Get the best move from localStorage
34550
+ static getBestLocalMove(userId, date) {
34551
+ const moves = this.loadLocalMoves(userId, date);
34552
+ if (moves.length === 0) {
34553
+ return null;
34554
+ }
34555
+ // Find the move with the highest score
34556
+ return moves.reduce((best, current) => current.score > best.score ? current : best);
34557
+ }
34558
+ // Clean up entries older than MAX_AGE_DAYS
34559
+ static cleanupOldEntries() {
34560
+ try {
34561
+ const now = new Date();
34562
+ const cutoffDate = new Date(now);
34563
+ cutoffDate.setDate(cutoffDate.getDate() - this.MAX_AGE_DAYS);
34564
+ const keysToRemove = [];
34565
+ // Iterate through localStorage keys
34566
+ for (let i = 0; i < localStorage.length; i++) {
34567
+ const key = localStorage.key(i);
34568
+ if (key && key.startsWith(this.STORAGE_KEY_PREFIX)) {
34569
+ // Extract date from key: "gata_YYYY-MM-DD_userId"
34570
+ const parts = key.split('_');
34571
+ if (parts.length >= 2) {
34572
+ const dateStr = parts[1];
34573
+ const entryDate = new Date(dateStr);
34574
+ if (!isNaN(entryDate.getTime()) && entryDate < cutoffDate) {
34575
+ keysToRemove.push(key);
34576
+ }
34577
+ }
34578
+ }
34579
+ }
34580
+ // Remove old entries
34581
+ keysToRemove.forEach(key => localStorage.removeItem(key));
34582
+ }
34583
+ catch (e) {
34584
+ console.error('Failed to cleanup old entries:', e);
34585
+ }
34586
+ }
34587
+ // Clear all persistence for a specific user
34588
+ static clearUserData(userId) {
34589
+ const keysToRemove = [];
34590
+ for (let i = 0; i < localStorage.length; i++) {
34591
+ const key = localStorage.key(i);
34592
+ if (key && key.startsWith(this.STORAGE_KEY_PREFIX) && key.endsWith(`_${userId}`)) {
34593
+ keysToRemove.push(key);
34594
+ }
34595
+ }
34596
+ keysToRemove.forEach(key => localStorage.removeItem(key));
34597
+ }
34598
+ // === Firebase Read Methods ===
34599
+ // Note: All Firebase write operations (achievements, stats, global best, leaderboard)
34600
+ // are now handled by the backend server in the /gatadagsins/submit endpoint.
34601
+ // The client only handles localStorage persistence and Firebase reads for display.
34602
+ // Get leaderboard for a specific date
34603
+ static async getLeaderboard(date, locale, limit = 10) {
34604
+ try {
34605
+ const leadersPath = `gatadagsins/${date}/${locale}/leaders`;
34606
+ const leaders = await getFirebaseData(leadersPath);
34607
+ if (!leaders) {
34608
+ return [];
34609
+ }
34610
+ // Convert object to array and sort by score
34611
+ const entries = Object.values(leaders);
34612
+ entries.sort((a, b) => b.score - a.score);
34613
+ return entries.slice(0, limit);
34614
+ }
34615
+ catch (error) {
34616
+ console.error('Failed to get leaderboard:', error);
34617
+ return [];
34618
+ }
34619
+ }
34620
+ // Get user's streak statistics
34621
+ static async getUserStats(userId, locale) {
34622
+ try {
34623
+ const statsPath = `gatadagsins/users/${locale}/${userId}/stats`;
34624
+ const stats = await getFirebaseData(statsPath);
34625
+ return stats;
34626
+ }
34627
+ catch (error) {
34628
+ console.error('Failed to get user stats:', error);
34629
+ return null;
34630
+ }
34631
+ }
34632
+ }
34633
+ RiddlePersistence.STORAGE_KEY_PREFIX = 'gata_';
34634
+ RiddlePersistence.MAX_AGE_DAYS = 30;
34635
+
34355
34636
  /*
34356
34637
 
34357
34638
  Riddle.ts
@@ -34366,8 +34647,8 @@ class Game extends BaseGame {
34366
34647
  For further information, see https://github.com/mideind/Netskrafl
34367
34648
 
34368
34649
  */
34369
- const HOT_WARM_BOUNDARY_RATIO = 0.6;
34370
- const WARM_COLD_BOUNDARY_RATIO = 0.3;
34650
+ const HOT_WARM_BOUNDARY_RATIO = 0.5;
34651
+ const WARM_COLD_BOUNDARY_RATIO = 0.25;
34371
34652
  class Riddle extends BaseGame {
34372
34653
  constructor(uuid, date, model) {
34373
34654
  if (!model.state) {
@@ -34439,6 +34720,22 @@ class Riddle extends BaseGame {
34439
34720
  this.hotBoundary = this.bestPossibleScore * HOT_WARM_BOUNDARY_RATIO;
34440
34721
  // Initialize word checker
34441
34722
  wordChecker.ingestTwoLetterWords(this.locale, this.two_letter_words[0]);
34723
+ // Load persisted player moves from localStorage
34724
+ if (state.userId) {
34725
+ const persistedMoves = RiddlePersistence.loadLocalMoves(state.userId, date);
34726
+ if (persistedMoves.length > 0) {
34727
+ // Convert from IPlayerMove to RiddleWord format, preserving timestamps
34728
+ this.playerMoves = persistedMoves.map(move => ({
34729
+ word: move.word,
34730
+ score: move.score,
34731
+ coord: move.coord,
34732
+ timestamp: move.timestamp || new Date().toISOString() // Use stored timestamp or fallback
34733
+ }));
34734
+ // Update personal best score from persisted moves
34735
+ const bestMove = persistedMoves.reduce((best, current) => current.score > best.score ? current : best);
34736
+ this.personalBestScore = bestMove.score;
34737
+ }
34738
+ }
34442
34739
  }
34443
34740
  }
34444
34741
  catch (error) {
@@ -34459,6 +34756,7 @@ class Riddle extends BaseGame {
34459
34756
  locale: this.locale,
34460
34757
  userId: state.userId,
34461
34758
  groupId: state.userGroupId || null,
34759
+ userDisplayName: state.userFullname || state.userNick || state.userId,
34462
34760
  move,
34463
34761
  }
34464
34762
  });
@@ -34507,13 +34805,26 @@ class Riddle extends BaseGame {
34507
34805
  // If the move is not valid or was already played, return
34508
34806
  if (!move)
34509
34807
  return;
34510
- if (move.score > this.personalBestScore) {
34511
- // This is the best score we've seen yet
34512
- this.personalBestScore = move.score;
34513
- // This might affect the global state,
34514
- // so notify the server
34515
- this.submitRiddleWord(move);
34516
- }
34808
+ const { state } = this;
34809
+ if (!state || !state.userId)
34810
+ return;
34811
+ // Save all moves to localStorage (local backup/cache)
34812
+ // Convert RiddleWord[] to IPlayerMove[] for persistence
34813
+ const movesToSave = this.playerMoves.map(m => ({
34814
+ score: m.score,
34815
+ word: m.word,
34816
+ coord: m.coord,
34817
+ timestamp: m.timestamp
34818
+ }));
34819
+ RiddlePersistence.saveLocalMoves(state.userId, this.date, movesToSave);
34820
+ // If the move does not improve the personal best, we're done
34821
+ if (move.score <= this.personalBestScore)
34822
+ return;
34823
+ // This is the best score we've seen yet
34824
+ this.personalBestScore = move.score;
34825
+ // Submit to server; the server handles all Firebase updates
34826
+ // (achievements, stats, global best, leaderboard)
34827
+ this.submitRiddleWord(move);
34517
34828
  }
34518
34829
  updateGlobalBestScore(best) {
34519
34830
  // Update the global best score, typically as a result
@@ -34740,6 +35051,9 @@ class Model {
34740
35051
  this.game = null;
34741
35052
  // The current Gáta Dagsins riddle, if any
34742
35053
  this.riddle = null;
35054
+ // Gáta Dagsins-specific properties
35055
+ this.userStats = null;
35056
+ this.leaderboard = [];
34743
35057
  // The current Netskrafl game list
34744
35058
  this.gameList = null;
34745
35059
  // Number of games where it's the player's turn, plus count of zombie games
@@ -35847,6 +36161,12 @@ class Actions {
35847
36161
  if (state === null || state === void 0 ? void 0 : state.userGroupId) {
35848
36162
  attachFirebaseListener(basePath + `group/${state.userGroupId}/best`, (json, firstAttach) => this.onRiddleGroupScoreUpdate(json, firstAttach));
35849
36163
  }
36164
+ // Listen to global leaderboard
36165
+ attachFirebaseListener(basePath + "leaders", (json, firstAttach) => this.onLeaderboardUpdate(json, firstAttach));
36166
+ // Listen to user stats (if user is logged in)
36167
+ if (state === null || state === void 0 ? void 0 : state.userId) {
36168
+ attachFirebaseListener(`gatadagsins/users/${locale}/${state.userId}/stats`, (json, firstAttach) => this.onUserStatsUpdate(json, firstAttach));
36169
+ }
35850
36170
  }
35851
36171
  detachListenerFromRiddle(date, locale) {
35852
36172
  const { state } = this.model;
@@ -35855,6 +36175,10 @@ class Actions {
35855
36175
  if (state === null || state === void 0 ? void 0 : state.userGroupId) {
35856
36176
  detachFirebaseListener(basePath + `group/${state.userGroupId}/best`);
35857
36177
  }
36178
+ detachFirebaseListener(basePath + "leaders");
36179
+ if (state === null || state === void 0 ? void 0 : state.userId) {
36180
+ detachFirebaseListener(`gatadagsins/users/${locale}/${state.userId}/stats`);
36181
+ }
35858
36182
  }
35859
36183
  onRiddleGlobalScoreUpdate(json, firstAttach) {
35860
36184
  const { riddle } = this.model;
@@ -35872,6 +36196,38 @@ class Actions {
35872
36196
  riddle.updateGroupBestScore(json);
35873
36197
  m.redraw();
35874
36198
  }
36199
+ onLeaderboardUpdate(json, firstAttach) {
36200
+ if (!json || typeof json !== 'object') {
36201
+ this.model.leaderboard = [];
36202
+ }
36203
+ else {
36204
+ // Convert dictionary to array and sort by score (desc), then timestamp (desc)
36205
+ const entries = Object.keys(json).map(userId => ({
36206
+ userId: json[userId].userId || userId,
36207
+ displayName: json[userId].displayName || '',
36208
+ score: json[userId].score || 0,
36209
+ timestamp: json[userId].timestamp || ''
36210
+ }));
36211
+ // Sort by score descending, then by timestamp descending (newer first)
36212
+ entries.sort((a, b) => {
36213
+ if (b.score !== a.score) {
36214
+ return b.score - a.score;
36215
+ }
36216
+ return b.timestamp.localeCompare(a.timestamp);
36217
+ });
36218
+ this.model.leaderboard = entries;
36219
+ }
36220
+ m.redraw();
36221
+ }
36222
+ onUserStatsUpdate(json, firstAttach) {
36223
+ if (!json) {
36224
+ this.model.userStats = null;
36225
+ }
36226
+ else {
36227
+ this.model.userStats = json;
36228
+ }
36229
+ m.redraw();
36230
+ }
35875
36231
  async fetchRiddle(date, locale) {
35876
36232
  // Create the game via model
35877
36233
  if (!this.model)
@@ -36062,13 +36418,14 @@ const Netskrafl = React.memo(NetskraflImpl);
36062
36418
  */
36063
36419
  const RiddleScore = {
36064
36420
  view: (vnode) => {
36065
- const { riddle } = vnode.attrs;
36421
+ const { riddle, mode = "desktop" } = vnode.attrs;
36066
36422
  if (!riddle)
36067
36423
  return m("div");
36068
36424
  const score = riddle.currentScore;
36069
36425
  const hasValidMove = score !== undefined;
36070
36426
  const hasTiles = riddle.tilesPlaced().length > 0;
36071
- let classes = [".gata-dagsins-score"];
36427
+ const baseClass = (mode === "mobile" ? ".mobile-score" : ".gata-dagsins-score");
36428
+ let classes = [baseClass];
36072
36429
  let displayText = "0";
36073
36430
  if (!hasTiles) {
36074
36431
  // State 1: No tiles on board - grayed/disabled, showing zero
@@ -36102,7 +36459,8 @@ const RiddleScore = {
36102
36459
  classes.push(".celebrate");
36103
36460
  }
36104
36461
  }
36105
- return m("div" + classes.join(""), m("span.gata-dagsins-legend", displayText));
36462
+ const legendClass = mode === "mobile" ? ".mobile-score-legend" : ".gata-dagsins-legend";
36463
+ return m("div" + classes.join(""), m("span" + legendClass, displayText));
36106
36464
  }
36107
36465
  };
36108
36466
 
@@ -36162,35 +36520,40 @@ const GataDagsinsBoardAndRack = {
36162
36520
  */
36163
36521
  const SunCorona = {
36164
36522
  view: (vnode) => {
36165
- const { animate = false } = vnode.attrs;
36523
+ const { animate = false, size = 80 } = vnode.attrs;
36524
+ // Calculate ray positions based on size
36525
+ // For 80px: rays from -40 to -20 (inner radius 20px, outer radius 40px)
36526
+ // For 90px: rays from -45 to -30 (inner radius 30px, outer radius 45px) to fit 60px circle
36527
+ const outerRadius = size / 2;
36528
+ const innerRadius = size / 3;
36166
36529
  return m("div.sun-corona" + (animate ? ".rotating" : ""), [
36167
36530
  m.trust(`
36168
- <svg width="80" height="80" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
36531
+ <svg width="${size}" height="${size}" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
36169
36532
  <g transform="translate(50,50)">
36170
36533
  <!-- Ray at 0° (12 o'clock) -->
36171
- <polygon points="0,-40 -3,-20 3,-20" fill="#f1c40f" opacity="0.8" transform="rotate(0)"/>
36534
+ <polygon points="0,-${outerRadius} -3,-${innerRadius} 3,-${innerRadius}" fill="#f1c40f" opacity="0.8" transform="rotate(0)"/>
36172
36535
  <!-- Ray at 30° -->
36173
- <polygon points="0,-40 -3,-20 3,-20" fill="#f39c12" opacity="0.7" transform="rotate(30)"/>
36536
+ <polygon points="0,-${outerRadius} -3,-${innerRadius} 3,-${innerRadius}" fill="#f39c12" opacity="0.7" transform="rotate(30)"/>
36174
36537
  <!-- Ray at 60° -->
36175
- <polygon points="0,-40 -3,-20 3,-20" fill="#f1c40f" opacity="0.8" transform="rotate(60)"/>
36538
+ <polygon points="0,-${outerRadius} -3,-${innerRadius} 3,-${innerRadius}" fill="#f1c40f" opacity="0.8" transform="rotate(60)"/>
36176
36539
  <!-- Ray at 90° (3 o'clock) -->
36177
- <polygon points="0,-40 -3,-20 3,-20" fill="#f39c12" opacity="0.7" transform="rotate(90)"/>
36540
+ <polygon points="0,-${outerRadius} -3,-${innerRadius} 3,-${innerRadius}" fill="#f39c12" opacity="0.7" transform="rotate(90)"/>
36178
36541
  <!-- Ray at 120° -->
36179
- <polygon points="0,-40 -3,-20 3,-20" fill="#f1c40f" opacity="0.8" transform="rotate(120)"/>
36542
+ <polygon points="0,-${outerRadius} -3,-${innerRadius} 3,-${innerRadius}" fill="#f1c40f" opacity="0.8" transform="rotate(120)"/>
36180
36543
  <!-- Ray at 150° -->
36181
- <polygon points="0,-40 -3,-20 3,-20" fill="#f39c12" opacity="0.7" transform="rotate(150)"/>
36544
+ <polygon points="0,-${outerRadius} -3,-${innerRadius} 3,-${innerRadius}" fill="#f39c12" opacity="0.7" transform="rotate(150)"/>
36182
36545
  <!-- Ray at 180° (6 o'clock) -->
36183
- <polygon points="0,-40 -3,-20 3,-20" fill="#f1c40f" opacity="0.8" transform="rotate(180)"/>
36546
+ <polygon points="0,-${outerRadius} -3,-${innerRadius} 3,-${innerRadius}" fill="#f1c40f" opacity="0.8" transform="rotate(180)"/>
36184
36547
  <!-- Ray at 210° -->
36185
- <polygon points="0,-40 -3,-20 3,-20" fill="#f39c12" opacity="0.7" transform="rotate(210)"/>
36548
+ <polygon points="0,-${outerRadius} -3,-${innerRadius} 3,-${innerRadius}" fill="#f39c12" opacity="0.7" transform="rotate(210)"/>
36186
36549
  <!-- Ray at 240° -->
36187
- <polygon points="0,-40 -3,-20 3,-20" fill="#f1c40f" opacity="0.8" transform="rotate(240)"/>
36550
+ <polygon points="0,-${outerRadius} -3,-${innerRadius} 3,-${innerRadius}" fill="#f1c40f" opacity="0.8" transform="rotate(240)"/>
36188
36551
  <!-- Ray at 270° (9 o'clock) -->
36189
- <polygon points="0,-40 -3,-20 3,-20" fill="#f39c12" opacity="0.7" transform="rotate(270)"/>
36552
+ <polygon points="0,-${outerRadius} -3,-${innerRadius} 3,-${innerRadius}" fill="#f39c12" opacity="0.7" transform="rotate(270)"/>
36190
36553
  <!-- Ray at 300° -->
36191
- <polygon points="0,-40 -3,-20 3,-20" fill="#f1c40f" opacity="0.8" transform="rotate(300)"/>
36554
+ <polygon points="0,-${outerRadius} -3,-${innerRadius} 3,-${innerRadius}" fill="#f1c40f" opacity="0.8" transform="rotate(300)"/>
36192
36555
  <!-- Ray at 330° -->
36193
- <polygon points="0,-40 -3,-20 3,-20" fill="#f39c12" opacity="0.7" transform="rotate(330)"/>
36556
+ <polygon points="0,-${outerRadius} -3,-${innerRadius} 3,-${innerRadius}" fill="#f39c12" opacity="0.7" transform="rotate(330)"/>
36194
36557
  </g>
36195
36558
  </svg>
36196
36559
  `)
@@ -36198,6 +36561,101 @@ const SunCorona = {
36198
36561
  }
36199
36562
  };
36200
36563
 
36564
+ /*
36565
+
36566
+ Mobile-Status.ts
36567
+
36568
+ Mobile-only horizontal status component for Gáta Dagsins
36569
+
36570
+ Copyright (C) 2025 Miðeind ehf.
36571
+ Author: Vilhjálmur Þorsteinsson
36572
+
36573
+ The Creative Commons Attribution-NonCommercial 4.0
36574
+ International Public License (CC-BY-NC 4.0) applies to this software.
36575
+ For further information, see https://github.com/mideind/Netskrafl
36576
+
36577
+ */
36578
+ // Mobile-only horizontal status display
36579
+ const MobileStatus = () => {
36580
+ return {
36581
+ view: (vnode) => {
36582
+ const { riddle, selectedMoves, bestMove, onMoveClick } = vnode.attrs;
36583
+ const { bestPossibleScore, globalBestScore } = riddle;
36584
+ // Determine if player achieved best possible score
36585
+ const achieved = bestMove !== undefined;
36586
+ const celebrate = bestMove && bestMove.word !== "";
36587
+ // Get player's current best score
36588
+ const playerBestScore = selectedMoves.length > 0 ? selectedMoves[0].score : 0;
36589
+ // Determine current leader score (may be this player or another)
36590
+ let leaderScore = 0;
36591
+ let isPlayerLeading = false;
36592
+ if (globalBestScore && globalBestScore.score > 0) {
36593
+ leaderScore = globalBestScore.score;
36594
+ // Check if player is leading
36595
+ isPlayerLeading = playerBestScore >= globalBestScore.score;
36596
+ }
36597
+ else {
36598
+ leaderScore = playerBestScore;
36599
+ isPlayerLeading = playerBestScore > 0;
36600
+ }
36601
+ return m(".mobile-status-container", [
36602
+ // Current word score (leftmost) - uses RiddleScore component in mobile mode
36603
+ m(".mobile-status-item", m(RiddleScore, { riddle, mode: "mobile" })),
36604
+ // Player's best score
36605
+ m(".mobile-status-item.player-best", [
36606
+ m(".mobile-status-label", ts("Þín besta:")),
36607
+ m(".mobile-status-score", playerBestScore.toString())
36608
+ ]),
36609
+ // Current leader score
36610
+ m(".mobile-status-item.leader" + (isPlayerLeading ? ".is-player" : ""), [
36611
+ m(".mobile-status-label", isPlayerLeading ? ts("Þú leiðir!") : ts("Leiðandi:")),
36612
+ m(".mobile-status-score", leaderScore.toString())
36613
+ ]),
36614
+ // Best possible score
36615
+ m(".mobile-status-item.best-possible"
36616
+ + (celebrate ? ".celebrate" : "")
36617
+ + (achieved ? ".achieved" : ""), {
36618
+ onclick: () => celebrate && onMoveClick(bestMove.word, bestMove.coord)
36619
+ }, [
36620
+ // Wrapper for score and corona to position them together
36621
+ m(".mobile-best-score-wrapper", [
36622
+ celebrate ? m(SunCorona, { animate: true, size: 100 }) : null,
36623
+ m(".mobile-status-score", bestPossibleScore.toString())
36624
+ ])
36625
+ ])
36626
+ ]);
36627
+ }
36628
+ };
36629
+ };
36630
+
36631
+ /*
36632
+
36633
+ TabBar.ts
36634
+
36635
+ Reusable tab navigation component
36636
+
36637
+ Copyright (C) 2025 Miðeind ehf.
36638
+ Author: Vilhjálmur Þorsteinsson
36639
+
36640
+ The Creative Commons Attribution-NonCommercial 4.0
36641
+ International Public License (CC-BY-NC 4.0) applies to this software.
36642
+ For further information, see https://github.com/mideind/Netskrafl
36643
+
36644
+ */
36645
+ const TabBar = {
36646
+ view: (vnode) => {
36647
+ const { tabs, activeTab, onTabChange } = vnode.attrs;
36648
+ return m(".tab-bar", tabs.map(tab => m(".tab-item" + (activeTab === tab.id ? ".active" : ""), {
36649
+ key: tab.id,
36650
+ onclick: () => onTabChange(tab.id)
36651
+ }, [
36652
+ tab.iconGlyph ? m("span.tab-icon", glyph(tab.iconGlyph)) :
36653
+ tab.icon ? m("span.tab-icon", tab.icon) : null,
36654
+ m("span.tab-label", tab.label)
36655
+ ])));
36656
+ }
36657
+ };
36658
+
36201
36659
  /*
36202
36660
 
36203
36661
  Thermometer.ts
@@ -36272,12 +36730,27 @@ const BestPossibleScore = () => {
36272
36730
  return {
36273
36731
  view: (vnode) => {
36274
36732
  const { score, bestMove, onMoveClick } = vnode.attrs;
36275
- const topLabel = bestMove
36276
- ? removeBlankMarkers(bestMove.word)
36277
- : ts("Besta mögulega lögn");
36733
+ // Determine the label based on achievement status
36734
+ let topLabel;
36735
+ if (bestMove !== undefined) {
36736
+ if (bestMove.word) {
36737
+ // Current player achieved it - show their word
36738
+ topLabel = removeBlankMarkers(bestMove.word);
36739
+ }
36740
+ else {
36741
+ // Someone else achieved it - indicate this
36742
+ topLabel = ts("Bestu lögn náð!");
36743
+ }
36744
+ }
36745
+ else {
36746
+ // Not achieved yet - show default label
36747
+ topLabel = ts("Besta mögulega lögn");
36748
+ }
36749
+ const achieved = bestMove !== undefined;
36278
36750
  const celebrate = bestMove && bestMove.word !== "";
36279
36751
  return m(".thermometer-best-score"
36280
- + (celebrate ? ".celebrate" : ""), m(".thermometer-best-score-container", {
36752
+ + (celebrate ? ".celebrate" : "")
36753
+ + (achieved ? ".achieved" : ""), m(".thermometer-best-score-container", {
36281
36754
  onclick: () => celebrate && onMoveClick(bestMove.word, bestMove.coord)
36282
36755
  }, [
36283
36756
  // Sun corona behind the circle when celebrating
@@ -36405,6 +36878,196 @@ const Thermometer = () => {
36405
36878
  };
36406
36879
  };
36407
36880
 
36881
+ /*
36882
+
36883
+ StatsView.ts
36884
+
36885
+ User statistics display component for Gáta Dagsins
36886
+
36887
+ Copyright (C) 2025 Miðeind ehf.
36888
+ Author: Vilhjálmur Þorsteinsson
36889
+
36890
+ The Creative Commons Attribution-NonCommercial 4.0
36891
+ International Public License (CC-BY-NC 4.0) applies to this software.
36892
+ For further information, see https://github.com/mideind/Netskrafl
36893
+
36894
+ */
36895
+ const StatsView = {
36896
+ view: (vnode) => {
36897
+ const { stats, loading = false } = vnode.attrs;
36898
+ if (loading) {
36899
+ return m(".stats-view.loading", m(".loading-message", ts("Sæki tölfræði...")));
36900
+ }
36901
+ if (!stats) {
36902
+ return m(".stats-view.empty", m(".empty-message", ts("Engin tölfræði til að sýna")));
36903
+ }
36904
+ const statItems = [
36905
+ {
36906
+ iconGlyph: "fire",
36907
+ label: ts("Núverandi striklota"),
36908
+ value: stats.currentStreak,
36909
+ highlight: stats.currentStreak > 0
36910
+ },
36911
+ {
36912
+ iconGlyph: "star",
36913
+ label: ts("Lengsta striklota"),
36914
+ value: stats.longestStreak
36915
+ },
36916
+ {
36917
+ iconGlyph: "tower",
36918
+ label: ts("Hæsta skori náð"),
36919
+ value: stats.totalTopScores
36920
+ },
36921
+ {
36922
+ iconGlyph: "certificate",
36923
+ label: ts("Striklota hæsta skors"),
36924
+ value: stats.topScoreStreak,
36925
+ highlight: stats.topScoreStreak > 0
36926
+ },
36927
+ {
36928
+ iconGlyph: "calendar",
36929
+ label: ts("Heildarfjöldi daga"),
36930
+ value: stats.totalDaysPlayed
36931
+ },
36932
+ ];
36933
+ return m(".stats-view", m(".stats-grid", statItems.map((item, index) => m(".stat-item" + (item.highlight ? ".highlight" : ""), { key: index }, [
36934
+ m(".stat-icon", glyph(item.iconGlyph)),
36935
+ m(".stat-info", [
36936
+ m(".stat-label", item.label),
36937
+ m(".stat-value", item.value.toString())
36938
+ ])
36939
+ ]))));
36940
+ }
36941
+ };
36942
+
36943
+ /*
36944
+
36945
+ LeaderboardView.ts
36946
+
36947
+ Daily leaderboard display component for Gáta Dagsins
36948
+
36949
+ Copyright (C) 2025 Miðeind ehf.
36950
+ Author: Vilhjálmur Þorsteinsson
36951
+
36952
+ The Creative Commons Attribution-NonCommercial 4.0
36953
+ International Public License (CC-BY-NC 4.0) applies to this software.
36954
+ For further information, see https://github.com/mideind/Netskrafl
36955
+
36956
+ */
36957
+ function getMedalIcon(rank) {
36958
+ switch (rank) {
36959
+ case 1: return "🥇";
36960
+ case 2: return "🥈";
36961
+ case 3: return "🥉";
36962
+ default: return null;
36963
+ }
36964
+ }
36965
+ function formatDate(dateStr) {
36966
+ // Format YYYY-MM-DD to Icelandic date (e.g., "2. okt.")
36967
+ const date = new Date(dateStr + "T00:00:00");
36968
+ const day = date.getDate();
36969
+ const months = [
36970
+ "janúar", "febrúar", "mars", "apríl", "maí", "júní",
36971
+ "júlí", "ágúst", "september", "október", "nóvember", "desember",
36972
+ ];
36973
+ const month = months[date.getMonth()];
36974
+ return `${day}. ${month}`;
36975
+ }
36976
+ const LeaderboardView = {
36977
+ view: (vnode) => {
36978
+ const { leaderboard, currentUserId, date, loading = false } = vnode.attrs;
36979
+ if (loading) {
36980
+ return m(".leaderboard-view.loading", m(".loading-message", ts("Hleð stigatöflu...")));
36981
+ }
36982
+ if (!leaderboard || leaderboard.length === 0) {
36983
+ return m(".leaderboard-view.empty", m(".empty-message", ts("Engin stig skráð enn")));
36984
+ }
36985
+ return m(".leaderboard-view", [
36986
+ m(".leaderboard-header", [
36987
+ m(".leaderboard-title", formatDate(date)),
36988
+ ]),
36989
+ m(".leaderboard-list", leaderboard.map((entry, index) => {
36990
+ const rank = index + 1;
36991
+ const isCurrentUser = entry.userId === currentUserId;
36992
+ const medal = getMedalIcon(rank);
36993
+ return m(".leaderboard-entry" + (isCurrentUser ? ".current-user" : ""), { key: entry.userId }, [
36994
+ m(".entry-rank", [
36995
+ medal ? m("span.medal", medal) : m("span.rank-number", rank.toString())
36996
+ ]),
36997
+ m(".entry-name", isCurrentUser ? ts("Þú") : entry.displayName),
36998
+ m(".entry-score", entry.score.toString())
36999
+ ]);
37000
+ }))
37001
+ ]);
37002
+ }
37003
+ };
37004
+
37005
+ /*
37006
+
37007
+ RightSideTabs.ts
37008
+
37009
+ Desktop tabbed container for Performance/Stats/Leaderboard in Gáta Dagsins
37010
+
37011
+ Copyright (C) 2025 Miðeind ehf.
37012
+ Author: Vilhjálmur Þorsteinsson
37013
+
37014
+ The Creative Commons Attribution-NonCommercial 4.0
37015
+ International Public License (CC-BY-NC 4.0) applies to this software.
37016
+ For further information, see https://github.com/mideind/Netskrafl
37017
+
37018
+ */
37019
+ const RightSideTabs = () => {
37020
+ // Component-local state for active tab (defaults to performance)
37021
+ let activeTab = "performance";
37022
+ const tabs = [
37023
+ { id: "performance", label: ts("Frammistaða"), iconGlyph: "dashboard" },
37024
+ { id: "stats", label: ts("Tölfræði"), iconGlyph: "stats" },
37025
+ { id: "leaderboard", label: ts("Stigatafla"), iconGlyph: "tower" }
37026
+ ];
37027
+ return {
37028
+ view: (vnode) => {
37029
+ const { view, selectedMoves, bestMove, onMoveClick } = vnode.attrs;
37030
+ const { riddle, state } = view.model;
37031
+ if (!riddle) {
37032
+ return m(".gatadagsins-right-side-tabs", "");
37033
+ }
37034
+ const handleTabChange = (tabId) => {
37035
+ activeTab = tabId;
37036
+ };
37037
+ return m(".gatadagsins-right-side-tabs", [
37038
+ // Tab navigation
37039
+ m(TabBar, {
37040
+ tabs,
37041
+ activeTab,
37042
+ onTabChange: handleTabChange
37043
+ }),
37044
+ // Tab content
37045
+ m(".tab-content", [
37046
+ // Performance tab (thermometer)
37047
+ activeTab === "performance" ? m(Thermometer, {
37048
+ riddle,
37049
+ selectedMoves,
37050
+ bestMove,
37051
+ onMoveClick
37052
+ }) : null,
37053
+ // Stats tab
37054
+ activeTab === "stats" ? m(StatsView, {
37055
+ stats: view.model.userStats || null,
37056
+ loading: false
37057
+ }) : null,
37058
+ // Leaderboard tab
37059
+ activeTab === "leaderboard" ? m(LeaderboardView, {
37060
+ leaderboard: view.model.leaderboard || [],
37061
+ currentUserId: (state === null || state === void 0 ? void 0 : state.userId) || "",
37062
+ date: riddle.date,
37063
+ loading: false
37064
+ }) : null
37065
+ ])
37066
+ ]);
37067
+ }
37068
+ };
37069
+ };
37070
+
36408
37071
  /*
36409
37072
 
36410
37073
  GataDagsins-Right-Side.ts
@@ -36420,7 +37083,7 @@ const Thermometer = () => {
36420
37083
 
36421
37084
  */
36422
37085
  const GataDagsinsRightSide = {
36423
- // Component containing a single vertical thermometer-style performance display
37086
+ // Component containing both mobile status bar and desktop tabbed view
36424
37087
  view: (vnode) => {
36425
37088
  const { view, selectedMoves, bestMove } = vnode.attrs;
36426
37089
  const { riddle } = view.model;
@@ -36431,13 +37094,20 @@ const GataDagsinsRightSide = {
36431
37094
  }
36432
37095
  };
36433
37096
  return m(".gatadagsins-right-side-wrapper", riddle ? [
36434
- // Single thermometer column containing all performance levels
36435
- m(".gatadagsins-thermometer-column", m(Thermometer, {
37097
+ // Mobile-only status bar (visible on mobile, hidden on desktop)
37098
+ m(".gatadagsins-mobile-status", m(MobileStatus, {
36436
37099
  riddle,
36437
37100
  selectedMoves,
36438
37101
  bestMove,
36439
37102
  onMoveClick: handleMoveClick
36440
37103
  })),
37104
+ // Desktop-only tabbed view (hidden on mobile, visible on desktop)
37105
+ m(".gatadagsins-thermometer-column", m(RightSideTabs, {
37106
+ view,
37107
+ selectedMoves,
37108
+ bestMove,
37109
+ onMoveClick: handleMoveClick
37110
+ })),
36441
37111
  ] : null);
36442
37112
  }
36443
37113
  };
@@ -36520,6 +37190,102 @@ const GataDagsinsHelp = {
36520
37190
  }
36521
37191
  };
36522
37192
 
37193
+ /*
37194
+
37195
+ StatsModal.ts
37196
+
37197
+ Mobile modal for stats and leaderboard
37198
+
37199
+ Copyright (C) 2025 Miðeind ehf.
37200
+ Author: Vilhjálmur Þorsteinsson
37201
+
37202
+ The Creative Commons Attribution-NonCommercial 4.0
37203
+ International Public License (CC-BY-NC 4.0) applies to this software.
37204
+ For further information, see https://github.com/mideind/Netskrafl
37205
+
37206
+ */
37207
+ const StatsModal = () => {
37208
+ // Component-local state for active tab (defaults to stats)
37209
+ let activeTab = "stats";
37210
+ const tabs = [
37211
+ { id: "stats", label: ts("Tölfræði"), iconGlyph: "stats" },
37212
+ { id: "leaderboard", label: ts("Stigatafla"), iconGlyph: "tower" }
37213
+ ];
37214
+ return {
37215
+ view: (vnode) => {
37216
+ const { view, onClose } = vnode.attrs;
37217
+ const { riddle, state } = view.model;
37218
+ if (!riddle) {
37219
+ return null;
37220
+ }
37221
+ const handleTabChange = (tabId) => {
37222
+ activeTab = tabId;
37223
+ };
37224
+ return [
37225
+ // Backdrop
37226
+ m(".modal-backdrop", {
37227
+ onclick: onClose
37228
+ }),
37229
+ // Modal dialog
37230
+ m(".modal-dialog.stats-modal", [
37231
+ m(".modal-content", [
37232
+ // Header with close button
37233
+ m(".modal-header", [
37234
+ m("h2", ts("Tölfræði")),
37235
+ m("button.close", {
37236
+ onclick: onClose
37237
+ }, "×")
37238
+ ]),
37239
+ // Tab navigation
37240
+ m(TabBar, {
37241
+ tabs,
37242
+ activeTab,
37243
+ onTabChange: handleTabChange
37244
+ }),
37245
+ // Modal body with tab content
37246
+ m(".modal-body", [
37247
+ activeTab === "stats" ? m(StatsView, {
37248
+ stats: view.model.userStats || null,
37249
+ loading: false
37250
+ }) : null,
37251
+ activeTab === "leaderboard" ? m(LeaderboardView, {
37252
+ leaderboard: view.model.leaderboard || [],
37253
+ currentUserId: (state === null || state === void 0 ? void 0 : state.userId) || "",
37254
+ date: riddle.date,
37255
+ loading: false
37256
+ }) : null
37257
+ ])
37258
+ ])
37259
+ ])
37260
+ ];
37261
+ }
37262
+ };
37263
+ };
37264
+
37265
+ /*
37266
+
37267
+ MobileStatsButton.ts
37268
+
37269
+ Button to open stats modal on mobile
37270
+
37271
+ Copyright (C) 2025 Miðeind ehf.
37272
+ Author: Vilhjálmur Þorsteinsson
37273
+
37274
+ The Creative Commons Attribution-NonCommercial 4.0
37275
+ International Public License (CC-BY-NC 4.0) applies to this software.
37276
+ For further information, see https://github.com/mideind/Netskrafl
37277
+
37278
+ */
37279
+ const MobileStatsButton = {
37280
+ view: (vnode) => {
37281
+ const { onClick } = vnode.attrs;
37282
+ return m(".mobile-stats-button", {
37283
+ onclick: onClick,
37284
+ title: "Tölfræði og stigatafla"
37285
+ }, m(".stats-icon", glyph("stats")));
37286
+ }
37287
+ };
37288
+
36523
37289
  /*
36524
37290
 
36525
37291
  GataDagsins.ts
@@ -36535,11 +37301,19 @@ const GataDagsinsHelp = {
36535
37301
 
36536
37302
  */
36537
37303
  const MAX_MOVES_TO_DISPLAY = 10;
36538
- const selectTopMoves = (thisPlayer, moves, globalBestScore, groupBestScore) => {
36539
- var _a;
37304
+ const currentMoveState = (riddle) => {
37305
+ var _a, _b, _c;
37306
+ const thisPlayer = ((_a = riddle.model.state) === null || _a === void 0 ? void 0 : _a.userId) || "";
37307
+ const { bestPossibleScore, // The highest score achievable for this riddle
37308
+ globalBestScore, // The best score achieved by any player
37309
+ groupBestScore, // The best score achieved within the player's group
37310
+ playerMoves, } = riddle;
37311
+ // If the player has equaled the best possible score,
37312
+ // the winning word is stored here and displayed at the top
37313
+ let bestMove = undefined;
36540
37314
  // Sort moves by score in descending order and
36541
37315
  // cut the tail off the list to only include the top moves
36542
- const movesToDisplay = moves
37316
+ const selectedMoves = playerMoves
36543
37317
  .sort((a, b) => b.score - a.score)
36544
37318
  .slice(0, MAX_MOVES_TO_DISPLAY)
36545
37319
  .map(move => ({
@@ -36547,61 +37321,49 @@ const selectTopMoves = (thisPlayer, moves, globalBestScore, groupBestScore) => {
36547
37321
  word: move.word,
36548
37322
  coord: move.coord,
36549
37323
  }));
36550
- // If there is a global best score, and it is different from
36551
- // the player's own top score, we include it as the first move
37324
+ // Check whether we need to add or annotate the global best score
36552
37325
  if (globalBestScore && globalBestScore.score > 0) {
36553
37326
  const { score, word, coord } = globalBestScore;
36554
- if (((_a = movesToDisplay[0]) === null || _a === void 0 ? void 0 : _a.score) >= score) {
37327
+ if (((_c = (_b = selectedMoves[0]) === null || _b === void 0 ? void 0 : _b.score) !== null && _c !== void 0 ? _c : 0) >= score) {
36555
37328
  // This player has made a move that scores the same
36556
37329
  // or better as the top score: mark the move
36557
- movesToDisplay[0].isGlobalBestScore = true;
37330
+ selectedMoves[0].isGlobalBestScore = true;
36558
37331
  }
36559
37332
  else if (globalBestScore.player === thisPlayer) {
36560
37333
  // This player holds the global best score, probably
36561
- // from a previous session: add it as a move
36562
- movesToDisplay.unshift({ score, isGlobalBestScore: true, word, coord });
37334
+ // from a previous session, so it's not already
37335
+ // in the selectedMoves list: add it as a move
37336
+ selectedMoves.unshift({ score, isGlobalBestScore: true, word, coord });
36563
37337
  }
36564
37338
  else {
36565
- // This is a global best score from another player: add it as a special move
36566
- movesToDisplay.unshift({ score, isGlobalBestScore: true, word: "", coord: "" });
37339
+ // This is a global best score from another player
37340
+ selectedMoves.unshift({ score, isGlobalBestScore: true, word: "", coord: "" });
36567
37341
  }
36568
37342
  }
36569
- // TODO: Add handling for group best score
36570
- return movesToDisplay;
36571
- };
36572
- const currentMoveState = (riddle) => {
36573
- var _a;
36574
- const thisPlayer = ((_a = riddle.model.state) === null || _a === void 0 ? void 0 : _a.userId) || "";
36575
- const { bestPossibleScore, globalBestScore, groupBestScore, playerMoves, } = riddle;
36576
- // If the player has equaled the best possible score,
36577
- // the winning word is stored here and displayed at the top
36578
- let bestMove = undefined;
36579
- // Apply the move selection and allocation algorithm
36580
- const selectedMoves = selectTopMoves(thisPlayer, playerMoves, globalBestScore);
36581
- // If the top-scoring move has the bestPossibleScore,
36582
- // extract it from the move list
37343
+ // Check if the best possible score has been achieved, by this player
37344
+ // or another player. If so, we remove it from the move list, since we
37345
+ // only display it at the top of the thermometer.
36583
37346
  if (selectedMoves.length > 0 && selectedMoves[0].score === bestPossibleScore) {
36584
- if (selectedMoves[0].word) {
36585
- // The word was played by this player
36586
- bestMove = selectedMoves.shift();
36587
- }
37347
+ bestMove = selectedMoves.shift();
36588
37348
  }
36589
37349
  return { selectedMoves, bestMove };
36590
37350
  };
36591
37351
  const GataDagsins$1 = () => {
36592
37352
  // A view of the Gáta Dagsins page
36593
37353
  let showHelp = false;
37354
+ let showStatsModal = false;
36594
37355
  return {
36595
- oninit: (vnode) => {
37356
+ oninit: async (vnode) => {
36596
37357
  const { model, actions } = vnode.attrs.view;
36597
37358
  const { riddle } = model;
36598
37359
  if (!riddle) {
36599
37360
  const { date, locale } = vnode.attrs;
36600
37361
  // Initialize a fresh riddle object if it doesn't exist
36601
- actions.fetchRiddle(date, locale);
37362
+ await actions.fetchRiddle(date, locale);
36602
37363
  }
36603
- // Initialize help dialog state
37364
+ // Initialize dialog states
36604
37365
  showHelp = false;
37366
+ showStatsModal = false;
36605
37367
  },
36606
37368
  view: (vnode) => {
36607
37369
  var _a;
@@ -36615,6 +37377,10 @@ const GataDagsins$1 = () => {
36615
37377
  showHelp = !showHelp;
36616
37378
  m.redraw();
36617
37379
  };
37380
+ const toggleStatsModal = () => {
37381
+ showStatsModal = !showStatsModal;
37382
+ m.redraw();
37383
+ };
36618
37384
  return m("div.drop-target", {
36619
37385
  id: "gatadagsins-background",
36620
37386
  }, [
@@ -36639,11 +37405,15 @@ const GataDagsins$1 = () => {
36639
37405
  ((_a = model.state) === null || _a === void 0 ? void 0 : _a.beginner) ? m(Beginner, { view }) : "",
36640
37406
  // Custom Info button for GataDagsins that shows help dialog
36641
37407
  m(".info", { title: ts("Upplýsingar og hjálp") }, m("a.iconlink", { href: "#", onclick: (e) => { e.preventDefault(); toggleHelp(); } }, glyph("info-sign"))),
37408
+ // Mobile stats button (hidden on desktop)
37409
+ m(MobileStatsButton, { onClick: toggleStatsModal }),
36642
37410
  // Help dialog and backdrop
36643
37411
  showHelp ? [
36644
37412
  m(".modal-backdrop", { onclick: (e) => { e.preventDefault(); } }),
36645
37413
  m(GataDagsinsHelp, { onClose: toggleHelp })
36646
37414
  ] : "",
37415
+ // Stats modal and backdrop (mobile only)
37416
+ showStatsModal ? m(StatsModal, { view, onClose: toggleStatsModal }) : "",
36647
37417
  ]);
36648
37418
  }
36649
37419
  };
@@ -36682,11 +37452,22 @@ async function main(state, container) {
36682
37452
  const model = new Model(settings, state);
36683
37453
  const actions = new Actions(model);
36684
37454
  const view = new View(actions);
37455
+ // Get date from URL parameter, fallback to today
37456
+ const urlParams = new URLSearchParams(window.location.search);
37457
+ const dateParam = urlParams.get('date');
36685
37458
  const today = new Date().toISOString().split("T")[0];
37459
+ const riddleDate = dateParam || today;
37460
+ // Validate date format (YYYY-MM-DD)
37461
+ const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
37462
+ const validDate = dateRegex.test(riddleDate) ? riddleDate : today;
36686
37463
  const locale = state.locale || "is_IS";
37464
+ // Log the date being used (helpful for debugging)
37465
+ if (dateParam) {
37466
+ console.log(`Loading Gáta Dagsins for date: ${validDate} (from URL parameter)`);
37467
+ }
36687
37468
  // Mount the Gáta Dagsins UI using an anonymous closure component
36688
37469
  m.mount(container, {
36689
- view: () => m(GataDagsins$1, { view, date: today, locale }),
37470
+ view: () => m(GataDagsins$1, { view, date: validDate, locale }),
36690
37471
  });
36691
37472
  }
36692
37473
  catch (e) {
@@ -36696,6 +37477,8 @@ async function main(state, container) {
36696
37477
  return "success";
36697
37478
  }
36698
37479
 
37480
+ // Note: To load a specific date for debugging, use URL parameter: ?date=YYYY-MM-DD
37481
+ // Example: http://localhost:6006/?date=2025-01-25
36699
37482
  const mountForUser = async (state) => {
36700
37483
  // Return a DOM tree containing a mounted Gáta Dagsins UI
36701
37484
  // for the user specified in the state object