imrove friend list search responsiveness and stats refresh logic

This commit is contained in:
pa
2026-03-23 11:49:11 +09:00
parent 1895d0f25c
commit 3e9bff2f1b
4 changed files with 232 additions and 24 deletions

View File

@@ -473,6 +473,9 @@ const gameLog = {
},
async getAllUserStats(userIds, displayNames) {
if (!userIds.length && !displayNames.length) {
return [];
}
var data = [];
// this makes me most sad
var userIdsString = '';
@@ -485,6 +488,13 @@ const gameLog = {
displayNamesString += `'${displayName.replaceAll("'", "''")}', `;
}
displayNamesString = displayNamesString.slice(0, -2);
var whereClauses = [];
if (userIdsString) {
whereClauses.push(`g.user_id IN (${userIdsString})`);
}
if (displayNamesString) {
whereClauses.push(`g.display_name IN (${displayNamesString})`);
}
await sqliteService.execute(
(dbRow) => {
@@ -507,8 +517,7 @@ const gameLog = {
FROM
gamelog_join_leave g
WHERE
g.user_id IN (${userIdsString})
OR g.display_name IN (${displayNamesString})
${whereClauses.join('\n OR ')}
GROUP BY
g.user_id,
g.display_name

View File

@@ -59,6 +59,8 @@ export const useFriendStore = defineStore('Friend', () => {
const sortedFriends = shallowRef([]);
let sortedFriendsBatchDepth = 0;
let pendingSortedFriendsRebuild = false;
let allUserStatsRequestId = 0;
let allUserMutualCountRequestId = 0;
const derivedDebugCounters = reactive({
allFavoriteFriendIds: 0,
@@ -820,8 +822,16 @@ export const useFriendStore = defineStore('Friend', () => {
displayNames.push(ctx.ref.displayName);
}
}
if (!userIds.length) {
return;
}
const requestId = ++allUserStatsRequestId;
const data = await database.getAllUserStats(userIds, displayNames);
if (requestId !== allUserStatsRequestId) {
return;
}
const dataByDisplayName = new Map();
const friendsByDisplayName = new Map();
@@ -885,7 +895,14 @@ export const useFriendStore = defineStore('Friend', () => {
*
*/
async function getAllUserMutualCount() {
if (!friends.size) {
return;
}
const requestId = ++allUserMutualCountRequestId;
const mutualCountMap = await database.getMutualCountForAllUsers();
if (requestId !== allUserMutualCountRequestId) {
return;
}
runInSortedFriendsBatch(() => {
for (const [userId, mutualCount] of mutualCountMap.entries()) {
const ref = friends.get(userId);

View File

@@ -61,6 +61,7 @@
:placeholder="t('view.friend_list.search_placeholder')"
clearable
class="w-[250px]"
@input="scheduleFriendsListSearchChange"
@change="friendsListSearchChange" />
</div>
<div class="flex items-center">
@@ -120,7 +121,7 @@
<script setup>
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { computed, nextTick, ref, watch } from 'vue';
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
import { Button } from '@/components/ui/button';
import { InputGroupField } from '@/components/ui/input-group';
import { Progress } from '@/components/ui/progress';
@@ -185,6 +186,13 @@
});
const friendsListRef = ref(null);
const friendSearchCache = new Map();
const FRIEND_LIST_SEARCH_DEBOUNCE_MS = 150;
const FRIEND_STATS_REFRESH_INTERVAL_MS = 30000;
let friendsListSearchTimer = 0;
let friendStatsRefreshInFlight = null;
let lastFriendStatsRefreshAt = 0;
let lastFriendStatsRefreshKey = '';
const friendsListColumns = computed(() =>
createColumns({
@@ -257,7 +265,7 @@
() => route.path,
() => {
refreshFriendStats();
nextTick(() => friendsListSearchChange());
nextTick(() => applyFriendsListSearchChange());
},
{ immediate: true }
);
@@ -265,23 +273,116 @@
watch(
() => friends.value.size,
() => {
refreshFriendStats();
friendsListSearchChange();
friendSearchCache.clear();
refreshFriendStats({ force: true });
applyFriendsListSearchChange();
}
);
function refreshFriendStats() {
getAllUserStats();
getAllUserMutualCount();
onBeforeUnmount(() => {
if (friendsListSearchTimer) {
clearTimeout(friendsListSearchTimer);
}
});
function getFriendStatsRefreshKey() {
return Array.from(friends.value.keys()).sort().join('\u0000');
}
async function refreshFriendStats({ force = false } = {}) {
const friendStatsRefreshKey = getFriendStatsRefreshKey();
if (!friendStatsRefreshKey) {
return;
}
const now = Date.now();
const isStillFresh =
friendStatsRefreshKey === lastFriendStatsRefreshKey &&
now - lastFriendStatsRefreshAt < FRIEND_STATS_REFRESH_INTERVAL_MS;
if (!force && (friendStatsRefreshInFlight || isStillFresh)) {
return friendStatsRefreshInFlight;
}
friendStatsRefreshInFlight = Promise.allSettled([
getAllUserStats(),
getAllUserMutualCount()
]).then((results) => {
if (results.every((result) => result.status === 'fulfilled')) {
lastFriendStatsRefreshAt = Date.now();
lastFriendStatsRefreshKey = friendStatsRefreshKey;
}
return results;
}).finally(() => {
friendStatsRefreshInFlight = null;
});
return friendStatsRefreshInFlight;
}
/**
*
*/
function scheduleFriendsListSearchChange() {
if (friendsListSearchTimer) {
clearTimeout(friendsListSearchTimer);
}
friendsListSearchTimer = setTimeout(() => {
friendsListSearchTimer = 0;
applyFriendsListSearchChange();
}, FRIEND_LIST_SEARCH_DEBOUNCE_MS);
}
/**
*
*/
function friendsListSearchChange() {
if (friendsListSearchTimer) {
clearTimeout(friendsListSearchTimer);
friendsListSearchTimer = 0;
}
applyFriendsListSearchChange();
}
/**
*
* @param {object} ctx
* @returns {object | null}
*/
function getFriendSearchEntry(ctx) {
if (!ctx?.ref?.id) {
return null;
}
const signature = [
ctx.memo ?? '',
ctx.ref.displayName ?? '',
ctx.ref.note ?? '',
ctx.ref.bio ?? '',
ctx.ref.statusDescription ?? '',
ctx.ref.$trustLevel ?? ''
].join('\u0000');
const cached = friendSearchCache.get(ctx.id);
if (cached?.signature === signature) {
return cached;
}
const entry = {
signature,
bio: ctx.ref.bio ?? '',
displayName: ctx.ref.displayName ?? '',
memo: ctx.memo ?? '',
normalizedDisplayName: removeConfusables(ctx.ref.displayName ?? ''),
note: ctx.ref.note ?? '',
rank: String(ctx.ref.$trustLevel ?? '').toUpperCase(),
status: ctx.ref.statusDescription ?? ''
};
friendSearchCache.set(ctx.id, entry);
return entry;
}
/**
*
*/
function applyFriendsListSearchChange() {
friendsListLoading.value = true;
let query = '';
let cleanedQuery = '';
let upperQuery = '';
friendsListDisplayData.value = [];
let filters = friendsListSearchFilters.value.length
? [...friendsListSearchFilters.value]
@@ -290,31 +391,34 @@
if (friendsListSearch.value) {
query = friendsListSearch.value;
cleanedQuery = removeWhitespace(query);
upperQuery = query.toUpperCase();
}
for (const ctx of friends.value.values()) {
if (!ctx.ref) continue;
if (friendsListSearchFilterVIP.value && !allFavoriteFriendIds.value.has(ctx.id)) continue;
if (query) {
let match = false;
if (!match && filters.includes('Display Name') && ctx.ref.displayName) {
const searchEntry = getFriendSearchEntry(ctx);
if (!searchEntry) continue;
if (!match && filters.includes('Display Name') && searchEntry.displayName) {
match =
localeIncludes(ctx.ref.displayName, cleanedQuery, stringComparer.value) ||
localeIncludes(removeConfusables(ctx.ref.displayName), cleanedQuery, stringComparer.value);
localeIncludes(searchEntry.displayName, cleanedQuery, stringComparer.value) ||
localeIncludes(searchEntry.normalizedDisplayName, cleanedQuery, stringComparer.value);
}
if (!match && filters.includes('Memo') && ctx.memo) {
match = localeIncludes(ctx.memo, query, stringComparer.value);
if (!match && filters.includes('Memo') && searchEntry.memo) {
match = localeIncludes(searchEntry.memo, query, stringComparer.value);
}
if (!match && filters.includes('Note') && ctx.ref.note) {
match = localeIncludes(ctx.ref.note, query, stringComparer.value);
if (!match && filters.includes('Note') && searchEntry.note) {
match = localeIncludes(searchEntry.note, query, stringComparer.value);
}
if (!match && filters.includes('Bio') && ctx.ref.bio) {
match = localeIncludes(ctx.ref.bio, query, stringComparer.value);
if (!match && filters.includes('Bio') && searchEntry.bio) {
match = localeIncludes(searchEntry.bio, query, stringComparer.value);
}
if (!match && filters.includes('Status') && ctx.ref.statusDescription) {
match = localeIncludes(ctx.ref.statusDescription, query, stringComparer.value);
if (!match && filters.includes('Status') && searchEntry.status) {
match = localeIncludes(searchEntry.status, query, stringComparer.value);
}
if (!match && filters.includes('Rank')) {
match = String(ctx.ref.$trustLevel).toUpperCase().includes(query.toUpperCase());
match = searchEntry.rank.includes(upperQuery);
}
if (!match) continue;
}

View File

@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
@@ -121,6 +121,7 @@ vi.mock('../../../services/confusables', () => ({
}));
vi.mock('../../../shared/utils', () => ({
debounce: (fn) => fn,
localeIncludes: (source, query) =>
String(source ?? '')
.toLowerCase()
@@ -174,9 +175,9 @@ vi.mock('@/components/ui/button', () => ({
vi.mock('@/components/ui/input-group', () => ({
InputGroupField: {
props: ['modelValue'],
emits: ['update:modelValue', 'change'],
emits: ['update:modelValue', 'input', 'change'],
template:
'<input data-testid="friend-search" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" @change="$emit(\'change\')" />'
'<input data-testid="friend-search" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value); $emit(\'input\', $event.target.value)" @change="$emit(\'change\')" />'
}
}));
@@ -274,6 +275,7 @@ async function flushAsync() {
describe('FriendList.vue', () => {
beforeEach(() => {
vi.useFakeTimers();
mocks.route.path = '/friend-list';
mocks.friends.value = new Map();
mocks.allFavoriteFriendIds.value = new Set();
@@ -296,6 +298,11 @@ describe('FriendList.vue', () => {
mocks.toggleBulkColumnVisibility.mockReset();
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
});
test('filters friend list by search text and VIP toggle', async () => {
mocks.friends.value = new Map([
['usr_1', makeFriendCtx({ id: 'usr_1', displayName: 'Alice' })],
@@ -320,6 +327,77 @@ describe('FriendList.vue', () => {
expect(mocks.getAllUserMutualCount).toHaveBeenCalledTimes(1);
});
test('debounces search input and applies immediately on change', async () => {
mocks.friends.value = new Map([
['usr_1', makeFriendCtx({ id: 'usr_1', displayName: 'Alice' })],
['usr_2', makeFriendCtx({ id: 'usr_2', displayName: 'Bob' })]
]);
const wrapper = mount(FriendList);
await flushAsync();
const searchInput = wrapper.get('[data-testid="friend-search"]');
searchInput.element.value = 'bob';
await searchInput.trigger('input');
await nextTick();
expect(
wrapper.vm.friendsListDisplayData.map((item) => item.id)
).toEqual(['usr_1', 'usr_2']);
vi.advanceTimersByTime(150);
await flushAsync();
expect(
wrapper.vm.friendsListDisplayData.map((item) => item.id)
).toEqual(['usr_2']);
mocks.friendsListSearch.value = 'alice';
await searchInput.trigger('change');
await flushAsync();
expect(
wrapper.vm.friendsListDisplayData.map((item) => item.id)
).toEqual(['usr_1']);
});
test('refreshFriendStats retries immediately after a failed stats request', async () => {
mocks.friends.value = new Map([
['usr_1', makeFriendCtx({ id: 'usr_1', displayName: 'Alice' })]
]);
mocks.getAllUserStats.mockRejectedValueOnce(new Error('stats failed'));
const wrapper = mount(FriendList);
await flushAsync();
expect(mocks.getAllUserStats).toHaveBeenCalledTimes(1);
await wrapper.vm.refreshFriendStats();
expect(mocks.getAllUserStats).toHaveBeenCalledTimes(2);
expect(mocks.getAllUserMutualCount).toHaveBeenCalledTimes(2);
});
test('refreshFriendStats refreshes again when friend roster changes with same size', async () => {
mocks.friends.value = new Map([
['usr_1', makeFriendCtx({ id: 'usr_1', displayName: 'Alice' })]
]);
const wrapper = mount(FriendList);
await flushAsync();
expect(mocks.getAllUserStats).toHaveBeenCalledTimes(1);
mocks.friends.value = new Map([
['usr_2', makeFriendCtx({ id: 'usr_2', displayName: 'Bob' })]
]);
await wrapper.vm.refreshFriendStats();
expect(mocks.getAllUserStats).toHaveBeenCalledTimes(2);
expect(mocks.getAllUserMutualCount).toHaveBeenCalledTimes(2);
});
test('opens charts tab from toolbar button', async () => {
const wrapper = mount(FriendList);