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:
@@ -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