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 || '');
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user