feat: add Holo-work recommendations and responsive member grids
Publish GitHub Pages output / publish (push) Successful in 30s

This commit is contained in:
2026-09-18 12:38:59 +09:00
parent ba21e8f1c1
commit d941a12b4e
5 changed files with 154 additions and 21 deletions
+3 -2
View File
@@ -8,12 +8,13 @@
<span class="progress" class:complete={progress.complete}>
<strong>{count.toLocaleString('ko-KR')}</strong> / {progress.target?.toLocaleString('ko-KR') ?? '—'}회
{#if progress.complete}<small>완료</small>{/if}
{#if progress.complete}<small>완료</small>{:else if progress.target !== null}<small class="remaining">{(progress.target - count).toLocaleString('ko-KR')}회 남음</small>{/if}
</span>
<style>
.progress { white-space: nowrap; color: #a4adba; font-variant-numeric: tabular-nums; font-size: 14px; }
.progress { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px; color: #a4adba; font-variant-numeric: tabular-nums; font-size: 14px; }
strong { color: #f3b4d0; font-size: 17px; }
.complete strong, small { color: #98dbbc; }
small { margin-left: 6px; font-size: 12px; }
.remaining { color: #a4adba; }
</style>
@@ -0,0 +1,33 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { recommendWorkMembers, sortHoloWorkMembers } from '../models/holo-work-sort.ts';
const members = ['a', 'b', 'c', 'd', 'e', 'f', 'g'].map((id, order) => ({ id, order }));
const thresholds = Object.fromEntries(members.map(({ id }) => [id, [1, 5, 10]]));
const counts = { a: 5, b: 9, c: 0, d: 4, e: 10, f: 3, g: 9 };
test('업적 거리, 현재 횟수, 멤버 순으로 정렬하고 완료 멤버는 뒤에 둔다', () => {
const ordered = sortHoloWorkMembers(members, counts, 'achievement', thresholds);
assert.deepEqual(ordered.map(({ id }) => id), ['c', 'd', 'b', 'g', 'f', 'a', 'e']);
const changed = { ...counts, c: 10 };
assert.equal(ordered[0].id, 'c');
assert.equal(sortHoloWorkMembers(members, changed, 'achievement', thresholds)[0].id, 'd');
});
test('완료 멤버라도 고정한 자리는 유지하고 중복 없이 추천한다', () => {
const selected = ['e', '', 'b', '', ''];
const pinned = [true, false, true, false, false];
assert.deepEqual(recommendWorkMembers(members, counts, thresholds, selected, pinned), ['e', 'c', 'b', 'd', 'g']);
assert.deepEqual(selected, ['e', '', 'b', '', '']);
});
test('추천 후보가 부족하면 빈자리를 남기고 알 수 없는 업적은 추천하지 않는다', () => {
assert.deepEqual(recommendWorkMembers(members, counts, { b: [10] }, [], []), ['b', '', '', '', '']);
assert.deepEqual(recommendWorkMembers(members, counts, {}, [], []), ['', '', '', '', '']);
});
test('모두 고정한 조합은 그대로 유지되고 고정 해제 후 명시적 갱신에만 바뀐다', () => {
const selected = ['a', 'b', 'c', 'd', 'e'];
assert.deepEqual(recommendWorkMembers(members, counts, thresholds, selected, Array(5).fill(true)), selected);
assert.deepEqual(recommendWorkMembers(members, counts, thresholds, selected, []), ['c', 'd', 'b', 'g', 'f']);
});
+29 -2
View File
@@ -1,11 +1,38 @@
export type HoloWorkSort = 'member' | 'asc' | 'desc';
export type HoloWorkSort = 'member' | 'asc' | 'desc' | 'achievement';
type Thresholds = Readonly<Record<string, readonly number[]>>;
export function remainingWork(id: string, counts: Readonly<Record<string, number>>, thresholds: Thresholds): number {
const count = counts[id] ?? 0;
const target = thresholds[id]?.find((value) => value > count);
return target === undefined ? Infinity : target - count;
}
/** 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
members: readonly T[], counts: Readonly<Record<string, number>>, sort: HoloWorkSort, thresholds: Thresholds = {}
): T[] {
return [...members].sort((a, b) => {
const difference = (counts[a.id] ?? 0) - (counts[b.id] ?? 0);
if (sort === 'achievement') {
return (remainingWork(a.id, counts, thresholds) - remainingWork(b.id, counts, thresholds)) || difference || a.order - b.order;
}
return (sort === 'asc' ? difference : sort === 'desc' ? -difference : 0) || a.order - b.order;
});
}
/** Keep pinned slots and fill the others with distinct members with unfinished achievements. */
export function recommendWorkMembers<T extends { id: string; order: number }>(
members: readonly T[], counts: Readonly<Record<string, number>>, thresholds: Thresholds,
selected: readonly string[], pinned: readonly boolean[]
): string[] {
const used = new Set<string>();
const result = Array.from({ length: 5 }, (_, index) => {
const id = selected[index];
if (!pinned[index] || !members.some((member) => member.id === id) || used.has(id)) return '';
used.add(id);
return id;
});
const candidates = sortHoloWorkMembers(members, counts, 'achievement', thresholds)
.filter((member) => !used.has(member.id) && Number.isFinite(remainingWork(member.id, counts, thresholds)));
return result.map((id) => id || candidates.shift()?.id || '');
}