fix: keep Holo-work rows stable until sorting is reapplied
Publish GitHub Pages output / publish (push) Successful in 31s

This commit is contained in:
2026-09-14 11:26:02 +09:00
parent dd37f59565
commit 238b9a9913
4 changed files with 56 additions and 5 deletions
+7 -1
View File
@@ -1,7 +1,13 @@
--- ---
updatedAt: 2026-09-12T00:00:00+09:00 updatedAt: 2026-09-14T00:00:00+09:00
--- ---
## 2026년 9월 14일
### 홀로워크 기록 정렬 개선
`IMPROVED` 횟수 변경 중 멤버 행이 이동하지 않도록 목록 순서를 유지합니다. 정렬 기준을 바꾸거나 ‘다시 정렬’을 누르면 최신 횟수로 정렬하며, 검색 중에도 전체 목록의 정렬 순서를 유지합니다.
## 2026년 9월 12일 ## 2026년 9월 12일
### 홀로워크 관리 ### 홀로워크 관리
+23
View File
@@ -0,0 +1,23 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { sortHoloWorkMembers } from '../models/holo-work-sort.ts';
const members = [{ id: 'a', order: 1 }, { id: 'b', order: 2 }, { id: 'c', order: 3 }];
const ids = (rows: typeof members) => rows.map((row) => row.id);
test('횟수 변경은 기존 순서를 유지하고 다시 정렬할 때 최신 횟수를 반영한다', () => {
const counts = { a: 1, b: 2, c: 3 };
const snapshot = sortHoloWorkMembers(members, counts, 'asc');
counts.a = 5;
assert.deepEqual(ids(snapshot), ['a', 'b', 'c']);
assert.deepEqual(ids(sortHoloWorkMembers(members, counts, 'asc')), ['b', 'c', 'a']);
assert.deepEqual(ids(sortHoloWorkMembers(members, counts, 'desc')), ['a', 'c', 'b']);
assert.deepEqual(ids(members), ['a', 'b', 'c']);
});
test('미등록 횟수는 0이며 동률은 멤버순, 기준 변경은 전체 목록에 적용한다', () => {
const counts = { a: 2, b: 2 };
assert.deepEqual(ids(sortHoloWorkMembers(members, counts, 'asc')), ['c', 'a', 'b']);
assert.deepEqual(ids(sortHoloWorkMembers(members, counts, 'desc')), ['a', 'b', 'c']);
assert.deepEqual(ids(sortHoloWorkMembers([...members].reverse(), counts, 'member')), ['a', 'b', 'c']);
});
+11
View File
@@ -0,0 +1,11 @@
export type HoloWorkSort = 'member' | 'asc' | 'desc';
/** A fresh ordering snapshot; subsequent count edits do not move its rows. */
export function sortHoloWorkMembers<T extends { id: string; order: number }>(
members: readonly T[], counts: Readonly<Record<string, number>>, sort: HoloWorkSort
): T[] {
return [...members].sort((a, b) => {
const difference = (counts[a.id] ?? 0) - (counts[b.id] ?? 0);
return (sort === 'asc' ? difference : sort === 'desc' ? -difference : 0) || a.order - b.order;
});
}
+15 -4
View File
@@ -8,6 +8,7 @@
import boardSummary from '$lib/data/boards-summary.json'; import boardSummary from '$lib/data/boards-summary.json';
import { adjustWorkTeam, isWorkCount, parseWorkTeams, type HoloWorkCounts } from '$lib/storage/holo-work'; import { adjustWorkTeam, isWorkCount, parseWorkTeams, type HoloWorkCounts } from '$lib/storage/holo-work';
import type { HoloWorkTeam } from '$lib/models/user-data'; import type { HoloWorkTeam } from '$lib/models/user-data';
import { sortHoloWorkMembers, type HoloWorkSort } from '$lib/models/holo-work-sort';
import { USER_DATA_STORAGE_KEY, loadBrowserData, saveBrowserData } from '$lib/storage/browser-storage'; import { USER_DATA_STORAGE_KEY, loadBrowserData, saveBrowserData } from '$lib/storage/browser-storage';
import { defaultPreferences } from '$lib/models/default-preferences'; import { defaultPreferences } from '$lib/models/default-preferences';
@@ -22,14 +23,20 @@
$: validTeam = Boolean(teamName.trim()) && selectedMembers.every((id) => members.some((member) => member.id === id)) && new Set(selectedMembers).size === 5; $: validTeam = Boolean(teamName.trim()) && selectedMembers.every((id) => members.some((member) => member.id === id)) && new Set(selectedMembers).size === 5;
let ready = false; let ready = false;
let search = ''; let search = '';
let sort = 'member'; let sort: HoloWorkSort = 'member';
let sortedMembers = [...members];
let status = ''; let status = '';
let error = ''; let error = '';
$: visibleMembers = members.filter((member) => member.name.replace(/\s/g, '').toLocaleLowerCase().includes(search.replace(/\s/g, '').toLocaleLowerCase())) $: visibleMembers = sortedMembers.filter((member) => member.name.replace(/\s/g, '').toLocaleLowerCase().includes(search.replace(/\s/g, '').toLocaleLowerCase()));
.sort((a, b) => (sort === 'asc' ? (counts[a.id] ?? 0) - (counts[b.id] ?? 0) : sort === 'desc' ? (counts[b.id] ?? 0) - (counts[a.id] ?? 0) : 0) || a.order - b.order);
$: total = members.reduce((sum, member) => sum + BigInt(counts[member.id] ?? 0), 0n); $: total = members.reduce((sum, member) => sum + BigInt(counts[member.id] ?? 0), 0n);
$: recorded = members.filter((member) => (counts[member.id] ?? 0) > 0).length; $: recorded = members.filter((member) => (counts[member.id] ?? 0) > 0).length;
function applySort(nextSort: HoloWorkSort = sort) {
sort = nextSort;
sortedMembers = sortHoloWorkMembers(members, counts, sort);
status = '현재 횟수로 정렬했습니다.';
}
function loadLatestData() { function loadLatestData() {
const data = loadBrowserData(defaultPreferences()); const data = loadBrowserData(defaultPreferences());
bonuses = calculateHoloWorkBoardBonuses(boardSummary, data.memberBoards, { bonuses = calculateHoloWorkBoardBonuses(boardSummary, data.memberBoards, {
@@ -224,8 +231,9 @@
<h2 class="all-members-title">멤버별 기록</h2> <h2 class="all-members-title">멤버별 기록</h2>
<div class="filters"> <div class="filters">
<label>멤버 검색<input type="search" bind:value={search} placeholder="멤버 이름 입력" /></label> <label>멤버 검색<input type="search" bind:value={search} placeholder="멤버 이름 입력" /></label>
<label>정렬<select bind:value={sort}><option value="member">멤버순</option><option value="asc">횟수 적은 순</option><option value="desc">횟수 많은 순</option></select></label> <label>정렬<select value={sort} on:change={(event) => applySort(event.currentTarget.value as HoloWorkSort)}><option value="member">멤버순</option><option value="asc">횟수 적은 순</option><option value="desc">횟수 많은 순</option></select></label>
</div> </div>
<div class="sort-actions"><p class="hint">횟수를 변경해도 위치는 유지됩니다. 정렬 기준을 바꾸거나 다시 정렬하면 최신 횟수를 반영합니다.</p><button type="button" on:click={() => applySort()} disabled={!ready}>다시 정렬</button></div>
<ul aria-label="멤버별 Holo워크 횟수"> <ul aria-label="멤버별 Holo워크 횟수">
{#each visibleMembers as member (member.id)} {#each visibleMembers as member (member.id)}
<li> <li>
@@ -253,6 +261,9 @@
.summary strong { display: block; margin-top: 10px; color: #f3b4d0; font-size: 26px; overflow-wrap: anywhere; } .summary strong { display: block; margin-top: 10px; color: #f3b4d0; font-size: 26px; overflow-wrap: anywhere; }
small { margin-left: 7px; color: #a4adba; font-size: 13px; font-weight: 400; } small { margin-left: 7px; color: #a4adba; font-size: 13px; font-weight: 400; }
.filters { display: grid; grid-template-columns: 1fr 150px; gap: 12px; } .filters { display: grid; grid-template-columns: 1fr 150px; gap: 12px; }
.sort-actions { display: flex; align-items: center; gap: 12px; justify-content: space-between; margin-top: 12px; }
.sort-actions .hint { margin: 0; }
.sort-actions button { flex-shrink: 0; padding: 8px 12px; font-size: 13px; }
label { display: grid; gap: 8px; color: #a4adba; font-size: 13px; } label { display: grid; gap: 8px; color: #a4adba; font-size: 13px; }
input, select { box-sizing: border-box; min-width: 0; width: 100%; height: 44px; padding: 0 12px; border: 1px solid #414b5b; border-radius: 9px; background: #101319; color: #e8ebf1; font: inherit; } input, select { box-sizing: border-box; min-width: 0; width: 100%; height: 44px; padding: 0 12px; border: 1px solid #414b5b; border-radius: 9px; background: #101319; color: #e8ebf1; font: inherit; }
.status { min-height: 20px; color: #f3b4d0; font-size: 13px; } .status { min-height: 20px; color: #f3b4d0; font-size: 13px; }