feat: add Holo-work recommendations and responsive member grids
Publish GitHub Pages output / publish (push) Successful in 30s
Publish GitHub Pages output / publish (push) Successful in 30s
This commit is contained in:
@@ -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']);
|
||||
});
|
||||
@@ -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 || '');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user