feat: add hot worlds

This commit is contained in:
pa
2026-03-16 23:00:14 +09:00
parent 357ac1a8bb
commit 5e95d142f0
10 changed files with 514 additions and 4 deletions

View File

@@ -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'
]);
});

View File

@@ -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' }

View File

@@ -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)) {

View File

@@ -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": {

View File

@@ -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',

View File

@@ -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<Array>} 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<Array>} 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;
}
};

View File

@@ -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',

View File

@@ -109,7 +109,8 @@ export const useAppearanceSettingsStore = defineStore(
'friends-locations',
'friend-list',
'charts-instance',
'charts-mutual'
'charts-mutual',
'charts-hot-worlds'
].includes(currentRouteName);
});

View File

@@ -0,0 +1,331 @@
<template>
<div id="chart" class="x-container">
<div ref="hotWorldsRef" class="pt-4">
<BackToTop :target="hotWorldsRef" :right="30" :bottom="30" :teleport="false" />
<div class="options-container mt-0 flex items-center justify-between">
<div class="flex items-center gap-2 mb-4">
<span class="shrink-0">{{ t('view.charts.hot_worlds.header') }}</span>
<HoverCard>
<HoverCardTrigger as-child>
<Info class="ml-1 text-xs opacity-70" />
</HoverCardTrigger>
<HoverCardContent side="bottom" align="start" class="w-75">
<div class="text-xs">
{{ t('view.charts.hot_worlds.tips.description') }}
</div>
</HoverCardContent>
</HoverCard>
</div>
<div class="flex items-center gap-2">
<ToggleGroup
variant="outline"
type="single"
:model-value="String(selectedDays)"
@update:modelValue="handleDaysChange">
<ToggleGroupItem value="7">
{{ t('view.charts.hot_worlds.period.days_7') }}
</ToggleGroupItem>
<ToggleGroupItem value="30">
{{ t('view.charts.hot_worlds.period.days_30') }}
</ToggleGroupItem>
<ToggleGroupItem value="90">
{{ t('view.charts.hot_worlds.period.days_90') }}
</ToggleGroupItem>
</ToggleGroup>
</div>
</div>
<div v-if="isLoading" class="mt-[100px] flex items-center justify-center">
<RefreshCcw class="size-6 animate-spin text-muted-foreground" />
</div>
<div v-else-if="hotWorlds.length === 0" class="mt-[100px] flex items-center justify-center">
<DataTableEmpty type="nodata" />
</div>
<template v-else>
<div class="mx-auto mt-3 flex max-w-[1100px] items-center gap-3">
<div class="flex items-center gap-2 rounded-lg border px-3 py-2">
<MapPin class="size-3.5 text-muted-foreground" />
<span class="text-sm font-medium">{{ totalVisits.toLocaleString() }}</span>
<span class="text-xs text-muted-foreground">{{ t('view.charts.hot_worlds.stats.total_visits') }}</span>
</div>
<div v-if="risingCount > 0" class="flex items-center gap-2 rounded-lg border px-3 py-2">
<TrendingUp class="size-3.5 text-green-500/50" />
<span class="text-sm font-medium">{{ risingCount }}</span>
<span class="text-xs text-muted-foreground">{{ t('view.charts.hot_worlds.stats.rising') }}</span>
</div>
<div v-if="coolingCount > 0" class="flex items-center gap-2 rounded-lg border px-3 py-2">
<TrendingDown class="size-3.5 text-blue-400/50" />
<span class="text-sm font-medium">{{ coolingCount }}</span>
<span class="text-xs text-muted-foreground">{{ t('view.charts.hot_worlds.stats.cooling') }}</span>
</div>
<span class="ml-auto text-xs text-muted-foreground/50">{{ t('view.charts.hot_worlds.sorted_by') }}</span>
</div>
<div class="mx-auto mt-3 flex max-w-[1100px] gap-x-6">
<div
v-for="(column, colIdx) in columns"
:key="colIdx"
class="min-w-0 flex-1">
<button
v-for="world in column"
:key="world.worldId"
type="button"
class="group flex w-full items-start gap-3 rounded-lg px-3 py-2 text-left transition-colors hover:bg-accent"
:class="world._rank === 1 ? 'bg-primary/[0.04]' : ''"
@click="openDetail(world)">
<span
class="mt-0.5 w-6 shrink-0 text-right font-mono text-sm font-bold"
:class="world._rank === 1 ? 'text-primary' : 'text-muted-foreground'">
#{{ world._rank }}
</span>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5">
<span class="block max-w-[380px] truncate text-sm font-medium">
{{ world.worldName }}
</span>
<template v-if="world.trend === 'rising'">
<TrendingUp class="size-3 shrink-0 text-green-500/50" />
</template>
<template v-else-if="world.trend === 'cooling'">
<TrendingDown class="size-3 shrink-0 text-blue-400/50" />
</template>
</div>
<div class="mt-0.5 text-xs text-muted-foreground">
{{ t('view.charts.hot_worlds.stats_line.friends', { count: world.uniqueFriends }) }}
<span class="text-muted-foreground/50">
({{ t('view.charts.hot_worlds.stats_line.visits', { count: world.visitCount }) }})
</span>
</div>
<div
class="mt-1.5 h-2 w-full overflow-hidden rounded-full"
:class="isDarkMode ? 'bg-white/[0.08]' : 'bg-black/[0.06]'">
<div
class="h-full rounded-full transition-all duration-500"
:class="isDarkMode ? 'bg-white/[0.45]' : 'bg-black/[0.25]'"
:style="{ width: getBarWidth(world.uniqueFriends) }">
</div>
</div>
</div>
</button>
</div>
</div>
</template>
</div>
</div>
<Sheet :open="isSheetOpen" @update:open="handleSheetClose">
<SheetContent side="right" class="w-[340px] sm:max-w-[340px]">
<SheetHeader class="px-5">
<SheetTitle class="text-left">
<button
type="button"
class="text-left text-base font-semibold hover:underline"
@click="handleWorldClick">
{{ selectedWorld?.worldName }}
</button>
</SheetTitle>
</SheetHeader>
<div v-if="selectedWorld" class="flex flex-col gap-4 overflow-y-auto px-5">
<div class="flex flex-wrap items-center gap-2">
<span class="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2.5 py-1 text-xs font-medium">
<Users class="size-3" />
{{ t('view.charts.hot_worlds.stats_line.friends', { count: selectedWorld.uniqueFriends }) }}
</span>
<span class="inline-flex items-center gap-1 rounded-full bg-muted px-2.5 py-1 text-xs text-muted-foreground">
<MapPin class="size-3" />
{{ t('view.charts.hot_worlds.stats_line.visits', { count: selectedWorld.visitCount }) }}
</span>
<span
v-if="selectedWorld.trend === 'rising'"
class="inline-flex items-center gap-1 rounded-full bg-green-500/10 px-2.5 py-1 text-xs text-green-500/70">
<TrendingUp class="size-3" />
{{ t('view.charts.hot_worlds.trend.rising') }}
</span>
<span
v-else-if="selectedWorld.trend === 'cooling'"
class="inline-flex items-center gap-1 rounded-full bg-blue-400/10 px-2.5 py-1 text-xs text-blue-400/70">
<TrendingDown class="size-3" />
{{ t('view.charts.hot_worlds.trend.cooling') }}
</span>
</div>
<Separator />
<div>
<div class="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground/70">
{{ t('view.charts.hot_worlds.sheet.friends_who_visited') }}
</div>
<div v-if="isLoadingDetail" class="flex items-center justify-center py-8">
<RefreshCcw class="size-4 animate-spin text-muted-foreground" />
</div>
<div v-else-if="friendDetail.length === 0" class="py-6 text-center text-xs text-muted-foreground">
{{ t('view.charts.hot_worlds.no_friend_data') }}
</div>
<div v-else class="space-y-0.5">
<button
v-for="friend in friendDetail"
:key="friend.userId"
type="button"
class="flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-sm transition-colors hover:bg-accent"
@click="openUserDialog(friend.userId)">
<span class="min-w-0 flex-1 truncate">{{ friend.displayName }}</span>
<span class="shrink-0 rounded-md bg-muted px-1.5 py-0.5 text-[11px] tabular-nums text-muted-foreground">
{{ friend.visitCount }}×
</span>
</button>
</div>
</div>
</div>
</SheetContent>
</Sheet>
</template>
<script setup>
defineOptions({ name: 'ChartsHotWorlds' });
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { Info, MapPin, RefreshCcw, TrendingDown, TrendingUp } from 'lucide-vue-next';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
import BackToTop from '@/components/BackToTop.vue';
import { DataTableEmpty } from '@/components/ui/data-table';
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
import { Separator } from '@/components/ui/separator';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { showUserDialog } from '@/coordinators/userCoordinator';
import { showWorldDialog } from '@/coordinators/worldCoordinator';
import { database } from '@/services/database';
import { useAppearanceSettingsStore } from '@/stores';
const { t } = useI18n();
const { isDarkMode } = storeToRefs(useAppearanceSettingsStore());
const hotWorldsRef = ref(null);
const isLoading = ref(true);
const isLoadingDetail = ref(false);
const selectedDays = ref(30);
const hotWorlds = ref([]);
const friendDetail = ref([]);
// Sheet state
const isSheetOpen = ref(false);
const selectedWorld = ref(null);
const containerResizeObserver = new ResizeObserver(() => {
setContainerHeight();
});
const displayed = computed(() => hotWorlds.value.slice(0, 20));
const columns = computed(() => {
const items = displayed.value.map((w, i) => ({ ...w, _rank: i + 1 }));
const mid = Math.ceil(items.length / 2);
return [items.slice(0, mid), items.slice(mid)];
});
const maxFriends = computed(() => {
if (displayed.value.length === 0) return 1;
return displayed.value[0].uniqueFriends || 1;
});
const risingCount = computed(() => {
return displayed.value.filter((w) => w.trend === 'rising').length;
});
const coolingCount = computed(() => {
return displayed.value.filter((w) => w.trend === 'cooling').length;
});
const totalVisits = computed(() => {
return displayed.value.reduce((sum, w) => sum + (w.visitCount || 0), 0);
});
function getBarWidth(uniqueFriends) {
return `${Math.max(4, (uniqueFriends / maxFriends.value) * 100)}%`;
}
function setContainerHeight() {
if (hotWorldsRef.value) {
const availableHeight = window.innerHeight - 110;
hotWorldsRef.value.style.height = `${availableHeight}px`;
hotWorldsRef.value.style.overflowY = 'auto';
}
}
function handleDaysChange(value) {
if (!value) return;
selectedDays.value = parseInt(value, 10);
handleSheetClose(false);
loadData();
}
async function loadData() {
isLoading.value = true;
try {
hotWorlds.value = await database.getHotWorlds(selectedDays.value);
} catch (error) {
console.error('Error loading hot worlds:', error);
hotWorlds.value = [];
} finally {
isLoading.value = false;
}
}
async function openDetail(world) {
selectedWorld.value = world;
isSheetOpen.value = true;
isLoadingDetail.value = true;
try {
friendDetail.value = await database.getHotWorldFriendDetail(world.worldId, selectedDays.value);
} catch (error) {
console.error('Error loading friend detail:', error);
friendDetail.value = [];
} finally {
isLoadingDetail.value = false;
}
}
function handleSheetClose(open) {
if (!open) {
isSheetOpen.value = false;
selectedWorld.value = null;
friendDetail.value = [];
}
}
function handleWorldClick() {
if (selectedWorld.value?.worldId) {
showWorldDialog(selectedWorld.value.worldId);
}
}
function openUserDialog(userId) {
showUserDialog(userId);
}
onMounted(() => {
if (hotWorldsRef.value) {
containerResizeObserver.observe(hotWorldsRef.value);
}
setContainerHeight();
loadData();
});
onBeforeUnmount(() => {
if (hotWorldsRef.value) {
containerResizeObserver.unobserve(hotWorldsRef.value);
}
});
</script>

View File

@@ -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')