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 || '');
}
+80 -16
View File
@@ -6,9 +6,10 @@
import { calculateHoloWorkBoardBonuses, sumHoloWorkBoardBonuses, type HoloWorkBoardBonus } from '$lib/engine/board-effects';
import HoloWorkProgress from '$lib/components/HoloWorkProgress.svelte';
import boardSummary from '$lib/data/boards-summary.json';
import { adjustWorkTeam, isWorkCount, parseWorkTeams, type HoloWorkCounts } from '$lib/storage/holo-work';
import { adjustWorkTeam, isWorkCount, parseWorkTeams, workAchievement, type HoloWorkCounts } from '$lib/storage/holo-work';
import type { HoloWorkTeam } from '$lib/models/user-data';
import { sortHoloWorkMembers, type HoloWorkSort } from '$lib/models/holo-work-sort';
import { recommendWorkMembers, sortHoloWorkMembers, type HoloWorkSort } from '$lib/models/holo-work-sort';
import achievements from '$lib/data/holo-work-achievements.json';
import { USER_DATA_STORAGE_KEY, loadBrowserData, saveBrowserData } from '$lib/storage/browser-storage';
import { defaultPreferences } from '$lib/models/default-preferences';
@@ -18,6 +19,10 @@
let teams: HoloWorkTeam[] = [];
let teamName = '';
let selectedMembers = ['', '', '', '', ''];
const thresholds = achievements.thresholdsByMember;
let pinned = [false, false, false, false, false];
let recommendationCounts: HoloWorkCounts | null = null;
$: recommendationStale = recommendationCounts !== null && members.some(({ id }) => (counts[id] ?? 0) !== (recommendationCounts?.[id] ?? 0));
let editingId = '';
let deletingId = '';
$: validTeam = Boolean(teamName.trim()) && selectedMembers.every((id) => members.some((member) => member.id === id)) && new Set(selectedMembers).size === 5;
@@ -33,7 +38,7 @@
function applySort(nextSort: HoloWorkSort = sort) {
sort = nextSort;
sortedMembers = sortHoloWorkMembers(members, counts, sort);
sortedMembers = sortHoloWorkMembers(members, counts, sort, thresholds);
status = '현재 횟수로 정렬했습니다.';
}
@@ -96,15 +101,37 @@
editingId = '';
teamName = '';
selectedMembers = ['', '', '', '', ''];
pinned = [false, false, false, false, false];
recommendationCounts = null;
}
function editTeam(team: HoloWorkTeam) {
editingId = team.id;
teamName = team.name;
selectedMembers = [...team.memberIds];
pinned = [false, false, false, false, false];
recommendationCounts = null;
document.getElementById('team-name')?.focus();
}
function chooseMember(index: number, id: string) {
selectedMembers[index] = id;
pinned[index] = Boolean(id);
}
function refreshRecommendation() {
if (!ready) return;
try {
counts = loadLatestData().holoWorkCounts;
selectedMembers = recommendWorkMembers(members, counts, thresholds, selectedMembers, pinned);
recommendationCounts = { ...counts };
error = '';
status = selectedMembers.every(Boolean)
? '고정 멤버를 유지하고 업적에 가까운 멤버를 추천했습니다. 조합 저장을 눌러 확정하세요.'
: '추천할 멤버가 부족합니다. 남은 자리는 직접 선택해 주세요.';
} catch { error = '추천을 불러오지 못했습니다. 다시 시도해 주세요.'; }
}
function saveTeam() {
if (!ready || !validTeam) return;
try {
@@ -186,18 +213,29 @@
<form on:submit|preventDefault={saveTeam}>
<label for="team-name">조합 이름</label>
<input id="team-name" bind:value={teamName} maxlength="80" placeholder="예: 매일 보내는 조합" required disabled={!ready} />
<p class="hint">직접 선택한 멤버는 고정됩니다. 추천 갱신을 누르면 고정하지 않은 자리를 다음 업적에 가까운 멤버로 채웁니다.</p>
<div class="team-actions">
<button type="button" on:click={refreshRecommendation} disabled={!ready || pinned.every(Boolean)}>추천 갱신</button>
<button type="button" on:click={() => pinned = [false, false, false, false, false]} disabled={!pinned.some(Boolean)}>고정 모두 해제</button>
{#if recommendationStale}<span class="refresh-hint">횟수가 변경되었습니다. 추천을 갱신해 주세요.</span>{/if}
</div>
<div class="member-selects">
{#each selectedMembers as memberId, index}
<div class="member-slot" class:pinned={pinned[index]}>
<label>멤버 {index + 1}
<select bind:value={selectedMembers[index]} required disabled={!ready}>
<option value="" disabled>멤버 선택</option>
<select value={memberId} on:change={(event) => chooseMember(index, event.currentTarget.value)} required disabled={!ready}>
<option value="">멤버 선택</option>
{#each members as member}
<option value={member.id} disabled={selectedMembers.includes(member.id) && memberId !== member.id}>{member.name}</option>
{/each}
</select>
</label>
<button type="button" aria-pressed={pinned[index]} aria-label={`멤버 ${index + 1} 고정`} on:click={() => pinned[index] = !pinned[index]} disabled={!ready || !memberId}>{pinned[index] ? '고정됨' : '고정'}</button>
{#if memberId}<HoloWorkProgress memberId={memberId} count={counts[memberId] ?? 0} /><HoloWorkBonuses bonus={bonuses[memberId]} />{/if}
</div>
{/each}
</div>
<div class="team-bonus-total"><h4>선택한 조합 보드 보너스 합계</h4><HoloWorkBonuses bonus={sumHoloWorkBoardBonuses(bonuses, selectedMembers.filter(Boolean))} showEmpty /></div>
<div class="team-actions">
<button class="plus" type="submit" disabled={!ready || !validTeam}>{editingId ? '변경 저장' : '조합 저장'}</button>
{#if editingId}<button type="button" on:click={clearEditor}>수정 취소</button>{/if}
@@ -231,26 +269,30 @@
<h2 class="all-members-title">멤버별 기록</h2>
<div class="filters">
<label>멤버 검색<input type="search" bind:value={search} placeholder="멤버 이름 입력" /></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>
<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><option value="achievement">업적 가까운 순</option></select></label>
</div>
<div class="sort-actions"><p class="hint">횟수를 변경해도 위치는 유지됩니다. 정렬 기준을 바꾸거나 다시 정렬하면 최신 횟수를 반영합니다.</p><button type="button" on:click={() => applySort()} disabled={!ready}>다시 정렬</button></div>
<ul aria-label="멤버별 Holo워크 횟수">
<ul class="member-grid" aria-label="멤버별 Holo워크 횟수">
{#each visibleMembers as member (member.id)}
{@const count = counts[member.id] ?? 0}
{@const progress = workAchievement(count, (thresholds as Record<string, number[]>)[member.id] ?? [])}
<li>
<div class="member-info"><strong>{member.name}</strong><HoloWorkProgress memberId={member.id} count={counts[member.id] ?? 0} /><HoloWorkBonuses bonus={bonuses[member.id]} /></div>
<strong class="member-name">{member.name}</strong>
<div class="counter">
<button on:click={() => adjust(member.id, -1)} disabled={!ready || !(counts[member.id] > 0)} aria-label={`${member.name} 횟수 1 감소`}>−1</button>
<input type="number" min="0" max={Number.MAX_SAFE_INTEGER} step="1" value={counts[member.id] ?? 0} disabled={!ready} on:change={(event) => changeInput(member.id, event.currentTarget)} aria-label={`${member.name} 파견 횟수`} />
<span>회</span>
<button class="plus" on:click={() => adjust(member.id, 1)} disabled={!ready || counts[member.id] === Number.MAX_SAFE_INTEGER} aria-label={`${member.name} 횟수 1 증가`}>+1</button>
<span class="achievement-target">/ {progress.target?.toLocaleString('ko-KR') ?? '—'}회</span>
</div>
{#if progress.complete}<p class="achievement-note complete">모든 업적 완료</p>{/if}
<HoloWorkBonuses bonus={bonuses[member.id]} />
</li>
{:else}<li class="empty">검색에 맞는 멤버가 없습니다.</li>{/each}
</ul>
</main>
<style>
main { width: min(760px, calc(100% - var(--page-gutter))); margin: 0 auto; padding: 42px 0 80px; }
main { width: min(1600px, calc(100% - var(--page-gutter))); margin: 0 auto; padding: 42px 0 80px; }
h1 { margin: 0 0 14px; font-size: clamp(30px, 5vw, 42px); letter-spacing: -.04em; }
h2 { margin: 0; font-size: 22px; }
header p, .description { color: #a4adba; line-height: 1.7; }
@@ -271,10 +313,13 @@
ul { padding: 0; list-style: none; border: 1px solid #343c49; border-radius: 14px; background: #151a22; overflow: hidden; }
li { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 16px 20px; border-bottom: 1px solid #343c49; }
li:last-child { border-bottom: 0; }
.member-info > strong { line-height: 1.6; }
.counter { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.counter input { width: 96px; text-align: center; font-variant-numeric: tabular-nums; }
.counter span { color: #a4adba; font-size: 13px; }
.counter { display: flex; align-items: center; gap: 6px; }
.counter input { width: 76px; height: 36px; padding: 0 4px; text-align: center; font-size: 16px; font-variant-numeric: tabular-nums; appearance: textfield; -moz-appearance: textfield; }
.counter input::-webkit-inner-spin-button, .counter input::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
.counter button { min-width: 36px; min-height: 36px; padding: 0 4px; border-radius: 7px; font-size: 13px; }
.achievement-target { margin-left: 4px; color: #a4adba; font-size: 14px; white-space: nowrap; }
.achievement-note { margin: 0; color: #a4adba; font-size: 12px; line-height: 1.5; }
.achievement-note.complete { color: #98dbbc; }
button { min-width: 44px; min-height: 44px; border: 1px solid #414b5b; border-radius: 9px; background: #222936; color: #e8ebf1; font: inherit; font-weight: 700; cursor: pointer; }
button.plus { background: #ed8bb5; border-color: #ed8bb5; color: #21121a; }
button:hover:not(:disabled) { filter: brightness(1.15); }
@@ -288,7 +333,7 @@
.member-info { display: grid; gap: 6px; }
.team-panel { margin: 20px 0; padding: 20px; border: 1px solid #343c49; border-radius: 14px; background: #151a22; }
form { display: grid; gap: 12px; margin-top: 18px; }
.member-selects { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.member-selects { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
.team-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
.team-actions button, .delete-confirm button { padding: 8px 12px; }
.saved-teams { margin-top: 30px; }
@@ -297,5 +342,24 @@
.team-members li { padding: 10px 0; }
.delete-confirm { color: #f3b4d0; font-size: 14px; }
.all-members-title { margin: 32px 0 18px; }
@media (max-width: 540px) { li { align-items: flex-start; flex-direction: column; gap: 10px; padding: 16px; } .counter { align-self: flex-end; } .summary { gap: 10px; } .summary div { padding: 16px; } .filters { grid-template-columns: minmax(0, 1fr) 130px; } }
.member-slot { display: grid; align-content: start; gap: 10px; padding: 12px; border: 1px solid #343c49; border-radius: 10px; min-width: 0; }
.member-slot.pinned { border-color: #ed8bb5; }
.member-slot button[aria-pressed="true"] { color: #f3b4d0; }
.refresh-hint { align-self: center; color: #e8d69d; font-size: 13px; }
.member-grid, .team-members { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr)); gap: 12px; border: 0; background: transparent; overflow: visible; }
.member-grid li, .team-members li { min-width: 0; flex-direction: column; align-items: stretch; padding: 16px; border: 1px solid #343c49; border-radius: 12px; background: #151a22; }
.member-grid { grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 16px; }
.member-grid li { justify-content: flex-start; padding: 16px; gap: 10px; }
.member-name { min-width: 0; overflow-wrap: anywhere; font-size: 15px; line-height: 1.5; }
.member-grid .empty { grid-column: 1 / -1; }
@media (min-width: 1024px) {
.team-members { grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 10px; }
.team-members li { padding: 12px; overflow-wrap: anywhere; }
}
@media (pointer: coarse) { .counter button { min-width: 36px; min-height: 40px; } .counter input { height: 40px; font-size: 16px; } }
@media (max-width: 1439px) { .member-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
@media (max-width: 1199px) { .member-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
@media (max-width: 899px) { .member-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 599px) { .member-grid { grid-template-columns: minmax(0, 1fr); gap: 12px; } }
@media (max-width: 540px) { .summary { gap: 10px; } .summary div { padding: 16px; } .filters { grid-template-columns: minmax(0, 1fr) 130px; } }
</style>