From 5e95d142f0e1a97993fcbb79460ceff4a90cd609 Mon Sep 17 00:00:00 2001 From: pa Date: Mon, 16 Mar 2026 23:00:14 +0900 Subject: [PATCH] feat: add hot worlds --- .../nav-menu/__tests__/navMenuUtils.test.js | 4 +- src/components/nav-menu/navLayoutDefaults.js | 2 +- src/components/nav-menu/navMenuUtils.js | 2 +- src/localization/en.json | 33 ++ src/plugins/router.js | 6 + src/services/database/feed.js | 127 +++++++ src/shared/constants/ui.js | 7 + src/stores/settings/appearance.js | 3 +- src/views/Charts/components/HotWorlds.vue | 331 ++++++++++++++++++ .../Dashboard/components/panelRegistry.js | 3 + 10 files changed, 514 insertions(+), 4 deletions(-) create mode 100644 src/views/Charts/components/HotWorlds.vue diff --git a/src/components/nav-menu/__tests__/navMenuUtils.test.js b/src/components/nav-menu/__tests__/navMenuUtils.test.js index 11e58c8d..dfcc4729 100644 --- a/src/components/nav-menu/__tests__/navMenuUtils.test.js +++ b/src/components/nav-menu/__tests__/navMenuUtils.test.js @@ -14,6 +14,7 @@ const testDefinitions = [ { key: 'tools', routeName: 'tools' }, { key: 'charts-instance', routeName: 'charts-instance' }, { key: 'charts-mutual', routeName: 'charts-mutual' }, + { key: 'charts-hot-worlds', routeName: 'charts-hot-worlds' }, { key: 'notification', routeName: 'notification' }, { key: 'direct-access', action: 'direct-access' } ]; @@ -283,7 +284,8 @@ describe('sanitizeLayout', () => { expect(chartsFolder).toBeDefined(); expect(chartsFolder.items).toEqual([ 'charts-instance', - 'charts-mutual' + 'charts-mutual', + 'charts-hot-worlds' ]); }); diff --git a/src/components/nav-menu/navLayoutDefaults.js b/src/components/nav-menu/navLayoutDefaults.js index aeabb5c2..a755faa8 100644 --- a/src/components/nav-menu/navLayoutDefaults.js +++ b/src/components/nav-menu/navLayoutDefaults.js @@ -31,7 +31,7 @@ export function createBaseDefaultNavLayout(t) { nameKey: 'nav_tooltip.charts', name: t('nav_tooltip.charts'), icon: 'ri-pie-chart-line', - items: ['charts-instance', 'charts-mutual'] + items: ['charts-instance', 'charts-mutual', 'charts-hot-worlds'] }, { type: 'item', key: 'tools' }, { type: 'item', key: 'direct-access' } diff --git a/src/components/nav-menu/navMenuUtils.js b/src/components/nav-menu/navMenuUtils.js index 5cea7b2b..9f0a72dd 100644 --- a/src/components/nav-menu/navMenuUtils.js +++ b/src/components/nav-menu/navMenuUtils.js @@ -44,7 +44,7 @@ export function sanitizeLayout( const normalizedHiddenKeys = normalizeHiddenKeys(hiddenKeys, definitionMap); const hiddenSet = new Set(normalizedHiddenKeys); const normalized = []; - const chartsKeys = ['charts-instance', 'charts-mutual']; + const chartsKeys = ['charts-instance', 'charts-mutual', 'charts-hot-worlds']; const appendItemEntry = (key, target = normalized) => { if (!key || usedKeys.has(key) || !definitionMap.has(key)) { diff --git a/src/localization/en.json b/src/localization/en.json index 4c0be587..ed02b5eb 100644 --- a/src/localization/en.json +++ b/src/localization/en.json @@ -542,6 +542,39 @@ "exclude_friends_placeholder": "Select friends to exclude", "exclude_friends_help": "Selected friends will be hidden from the graph." } + }, + "hot_worlds": { + "tab_label": "Hot Worlds", + "header": "Hot Worlds", + "refresh": "Refresh", + "sorted_by": "Sorted by unique friends", + "tips": { + "description": "Shows which worlds your friends have been visiting the most. Ranked by the number of unique friends who visited. Trend compares the first half vs second half of the selected period." + }, + "period": { + "days_7": "Last 7d", + "days_30": "Last 30d", + "days_90": "Last 90d" + }, + "trend": { + "rising": "Rising", + "cooling": "Cooling" + }, + "stats_line": { + "friends": "{count} friends", + "visits": "{count} visits" + }, + "stats": { + "top_friends": "top friends", + "total_visits": "total visits", + "rising": "rising", + "cooling": "cooling" + }, + "sheet": { + "friends_who_visited": "Friends who visited" + }, + "no_friend_data": "No friend visit data", + "friend_visits": "{count} visits" } }, "tools": { diff --git a/src/plugins/router.js b/src/plugins/router.js index d773f16e..a09578b8 100644 --- a/src/plugins/router.js +++ b/src/plugins/router.js @@ -109,6 +109,12 @@ const routes = [ component: () => import('./../views/Charts/components/MutualFriends.vue') }, + { + path: 'charts/hot-worlds', + name: 'charts-hot-worlds', + component: () => + import('./../views/Charts/components/HotWorlds.vue') + }, { path: 'tools', name: 'tools', component: Tools }, { path: 'tools/gallery', diff --git a/src/services/database/feed.js b/src/services/database/feed.js index 872c271d..496a5c6e 100644 --- a/src/services/database/feed.js +++ b/src/services/database/feed.js @@ -604,6 +604,133 @@ const feed = { { '@userId': userId } ); return data; + }, + + /** + * @param {number} days - Number of days to look back + * @param {number} limit - Max number of worlds to return + * @returns {Promise} Ranked list of hot worlds + */ + async getHotWorlds(days = 30, limit = 30) { + const halfDays = Math.floor(days / 2); + const results = []; + await sqliteService.execute( + (dbRow) => { + results.push({ + worldId: dbRow[0], + worldName: dbRow[1], + visitCount: dbRow[2], + uniqueFriends: dbRow[3], + lastVisited: dbRow[4] + }); + }, + `SELECT + SUBSTR(location, 1, INSTR(location, ':') - 1) AS world_id, + world_name, + COUNT(*) AS visit_count, + COUNT(DISTINCT user_id) AS unique_friends, + MAX(created_at) AS last_visited + FROM ${dbVars.userPrefix}_feed_gps + WHERE created_at >= datetime('now', @daysOffset) + AND location LIKE 'wrld_%' + AND INSTR(location, ':') > 0 + AND world_name IS NOT NULL AND world_name != '' + GROUP BY world_id + ORDER BY unique_friends DESC, visit_count DESC + LIMIT @limit`, + { + '@daysOffset': `-${days} days`, + '@limit': limit + } + ); + + const trendMap = new Map(); + await sqliteService.execute( + (dbRow) => { + trendMap.set(dbRow[0], dbRow[1]); + }, + `SELECT + SUBSTR(location, 1, INSTR(location, ':') - 1) AS world_id, + COUNT(DISTINCT user_id) AS unique_friends + FROM ${dbVars.userPrefix}_feed_gps + WHERE created_at >= datetime('now', @daysOffset) + AND created_at < datetime('now', @halfOffset) + AND location LIKE 'wrld_%' + AND INSTR(location, ':') > 0 + AND world_name IS NOT NULL AND world_name != '' + GROUP BY world_id`, + { + '@daysOffset': `-${days} days`, + '@halfOffset': `-${halfDays} days` + } + ); + + const recentMap = new Map(); + await sqliteService.execute( + (dbRow) => { + recentMap.set(dbRow[0], dbRow[1]); + }, + `SELECT + SUBSTR(location, 1, INSTR(location, ':') - 1) AS world_id, + COUNT(DISTINCT user_id) AS unique_friends + FROM ${dbVars.userPrefix}_feed_gps + WHERE created_at >= datetime('now', @halfOffset) + AND location LIKE 'wrld_%' + AND INSTR(location, ':') > 0 + AND world_name IS NOT NULL AND world_name != '' + GROUP BY world_id`, + { + '@halfOffset': `-${halfDays} days` + } + ); + + for (const world of results) { + const oldFriends = trendMap.get(world.worldId) || 0; + const newFriends = recentMap.get(world.worldId) || 0; + if (newFriends > oldFriends) { + world.trend = 'rising'; + } else if (newFriends < oldFriends) { + world.trend = 'cooling'; + } else { + world.trend = 'stable'; + } + } + + return results; + }, + + /** + * @param {string} worldId - The world ID (e.g. wrld_xxx) + * @param {number} days - Number of days to look back + * @returns {Promise} List of friends who visited + */ + async getHotWorldFriendDetail(worldId, days = 30) { + const results = []; + await sqliteService.execute( + (dbRow) => { + results.push({ + userId: dbRow[0], + displayName: dbRow[1], + visitCount: dbRow[2], + lastVisit: dbRow[3] + }); + }, + `SELECT + user_id, + display_name, + COUNT(*) AS visit_count, + MAX(created_at) AS last_visit + FROM ${dbVars.userPrefix}_feed_gps + WHERE SUBSTR(location, 1, INSTR(location, ':') - 1) = @worldId + AND created_at >= datetime('now', @daysOffset) + GROUP BY user_id + ORDER BY visit_count DESC`, + { + '@worldId': worldId, + '@daysOffset': `-${days} days` + } + ); + return results; } }; diff --git a/src/shared/constants/ui.js b/src/shared/constants/ui.js index a32ba84b..48ff63fa 100644 --- a/src/shared/constants/ui.js +++ b/src/shared/constants/ui.js @@ -106,6 +106,13 @@ const navDefinitions = [ labelKey: 'view.charts.mutual_friend.tab_label', routeName: 'charts-mutual' }, + { + key: 'charts-hot-worlds', + icon: 'ri-fire-line', + tooltip: 'view.charts.hot_worlds.tab_label', + labelKey: 'view.charts.hot_worlds.tab_label', + routeName: 'charts-hot-worlds' + }, { key: 'tools', icon: 'ri-tools-line', diff --git a/src/stores/settings/appearance.js b/src/stores/settings/appearance.js index aaa4f36f..7bb857ff 100644 --- a/src/stores/settings/appearance.js +++ b/src/stores/settings/appearance.js @@ -109,7 +109,8 @@ export const useAppearanceSettingsStore = defineStore( 'friends-locations', 'friend-list', 'charts-instance', - 'charts-mutual' + 'charts-mutual', + 'charts-hot-worlds' ].includes(currentRouteName); }); diff --git a/src/views/Charts/components/HotWorlds.vue b/src/views/Charts/components/HotWorlds.vue new file mode 100644 index 00000000..54ceb020 --- /dev/null +++ b/src/views/Charts/components/HotWorlds.vue @@ -0,0 +1,331 @@ + + + diff --git a/src/views/Dashboard/components/panelRegistry.js b/src/views/Dashboard/components/panelRegistry.js index 4793736b..4162261d 100644 --- a/src/views/Dashboard/components/panelRegistry.js +++ b/src/views/Dashboard/components/panelRegistry.js @@ -35,6 +35,9 @@ export const panelComponentMap = { 'charts-mutual': defineAsyncComponent( () => import('../../Charts/components/MutualFriends.vue') ), + 'charts-hot-worlds': defineAsyncComponent( + () => import('../../Charts/components/HotWorlds.vue') + ), tools: Tools, 'widget:feed': defineAsyncComponent( () => import('../widgets/FeedWidget.vue')