Files
HolodoriCalc/src/routes/tools/holo-work/+page.svelte
T
dal4segno 0b794edc80
Publish GitHub Pages output / publish (push) Successful in 41s
style: rename service to 갤산기 and enlarge navigation brand
2026-10-06 11:38:30 +09:00

366 lines
22 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { onMount } from 'svelte';
import ToolsNav from '$lib/components/ToolsNav.svelte';
import HoloWorkBonuses from '$lib/components/HoloWorkBonuses.svelte';
import cardSummary from '$lib/data/cards-summary.json';
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, workAchievement, type HoloWorkCounts } from '$lib/storage/holo-work';
import type { HoloWorkTeam } from '$lib/models/user-data';
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';
const members = [...boardSummary.members].sort((a, b) => a.order - b.order);
let bonuses: Record<string, HoloWorkBoardBonus> = {};
let counts: HoloWorkCounts = {};
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;
let ready = false;
let search = '';
let sort: HoloWorkSort = 'member';
let sortedMembers = [...members];
let status = '';
let error = '';
$: visibleMembers = sortedMembers.filter((member) => member.name.replace(/\s/g, '').toLocaleLowerCase().includes(search.replace(/\s/g, '').toLocaleLowerCase()));
$: total = members.reduce((sum, member) => sum + BigInt(counts[member.id] ?? 0), 0n);
$: recorded = members.filter((member) => (counts[member.id] ?? 0) > 0).length;
function applySort(nextSort: HoloWorkSort = sort) {
sort = nextSort;
sortedMembers = sortHoloWorkMembers(members, counts, sort, thresholds);
status = '현재 횟수로 정렬했습니다.';
}
function loadLatestData() {
const data = loadBrowserData(defaultPreferences());
bonuses = calculateHoloWorkBoardBonuses(boardSummary, data.memberBoards, {
placements: data.boardCards,
cards: cardSummary.cards.filter((card) => card.details.connect.length > 0).map((card) => ({
id: card.id, awakening: data.collection[card.id]?.awakening ?? 0, connect: card.details.connect
}))
});
return data;
}
function read() {
try {
const data = loadLatestData();
counts = data.holoWorkCounts;
teams = data.holoWorkTeams;
ready = true;
error = '';
} catch {
ready = false;
error = '저장된 기록을 읽을 수 없습니다. 브라우저 저장소 설정이나 기록을 확인한 뒤 다시 불러와 주세요.';
}
}
function update(id: string, value: number) {
if (!ready) return;
if (!isWorkCount(value)) {
error = '횟수는 0 이상의 정수로 입력해 주세요.';
return;
}
try {
// Read the latest document before changing one member to preserve other tabs' edits.
const data = loadLatestData();
const next = { ...data.holoWorkCounts, [id]: value };
saveBrowserData({ ...data, holoWorkCounts: next });
counts = next;
error = '';
status = `${members.find((member) => member.id === id)?.name}: ${value.toLocaleString('ko-KR')}회 저장했습니다.`;
} catch {
error = '기록을 저장하지 못했습니다. 브라우저 저장소 설정을 확인한 뒤 다시 시도해 주세요.';
}
}
function changeInput(id: string, input: HTMLInputElement) {
update(id, input.value === '' ? NaN : input.valueAsNumber);
input.value = String(counts[id] ?? 0);
}
function adjust(id: string, amount: number) {
try {
const latest = loadLatestData().holoWorkCounts;
update(id, (latest[id] ?? 0) + amount);
} catch { read(); }
}
function clearEditor() {
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 {
const data = loadLatestData();
if (editingId && !data.holoWorkTeams.some((team) => team.id === editingId)) throw new Error('다른 탭에서 삭제된 조합입니다. 새 조합으로 저장해 주세요.');
const team = { id: editingId || crypto.randomUUID(), name: teamName.trim(), memberIds: [...selectedMembers] };
const next = parseWorkTeams(editingId ? data.holoWorkTeams.map((item) => item.id === editingId ? team : item) : [...data.holoWorkTeams, team]);
saveBrowserData({ ...data, holoWorkTeams: next });
counts = data.holoWorkCounts;
teams = next;
clearEditor();
error = '';
status = `${team.name} 조합을 저장했습니다.`;
} catch (cause) { error = cause instanceof Error ? cause.message : '조합을 저장하지 못했습니다.'; }
}
function deleteTeam(id: string) {
try {
const data = loadLatestData();
const next = data.holoWorkTeams.filter((team) => team.id !== id);
saveBrowserData({ ...data, holoWorkTeams: next });
counts = data.holoWorkCounts;
teams = next;
deletingId = '';
if (editingId === id) clearEditor();
error = '';
status = '조합을 삭제했습니다. 멤버별 횟수는 유지됩니다.';
} catch { error = '조합을 삭제하지 못했습니다. 다시 시도해 주세요.'; }
}
function adjustTeam(id: string, amount: 1 | -1) {
try {
const data = loadLatestData();
const team = data.holoWorkTeams.find((item) => item.id === id);
if (!team || team.memberIds.some((id) => !members.some((member) => member.id === id))) throw new Error('조합의 멤버를 확인해 주세요.');
const next = adjustWorkTeam(data.holoWorkCounts, team.memberIds, amount);
saveBrowserData({ ...data, holoWorkCounts: next });
counts = next;
teams = data.holoWorkTeams;
error = '';
status = `${team.name}: 5명 모두 ${amount === 1 ? '1회 추가' : '1회 감소'}했습니다.`;
} catch (cause) { error = cause instanceof Error ? cause.message : '횟수를 저장하지 못했습니다.'; }
}
onMount(() => {
read();
const sync = (event: StorageEvent) => {
if (event.storageArea === localStorage && (event.key === USER_DATA_STORAGE_KEY || event.key === null)) {
read();
status = '다른 탭의 기록을 반영했습니다.';
}
};
window.addEventListener('storage', sync);
return () => window.removeEventListener('storage', sync);
});
</script>
<svelte:head>
<title>홀로워크 관리 · 도구 · 갤산기</title>
<meta name="description" content="멤버별 Holo워크 파견 횟수를 기록하고 확인하세요." />
</svelte:head>
<main>
<header><h1>도구</h1><p>플레이할 때 유용한 도구를 모았습니다.</p></header>
<ToolsNav active="holo-work" />
<h2>홀로워크 관리</h2>
<p class="description">각 멤버를 Holo워크에 보낸 횟수를 기록하세요. 기존 횟수를 직접 입력하거나, 보낼 때마다 +1을 누르면 됩니다.</p>
<p class="hint">횟수 / 다음 업적 요구량으로 표시합니다. 업적은 Holo워크 <strong>완료 횟수</strong> 기준이며, 400회 달성 후에는 최종 목표와 완료 표시를 유지합니다.</p>
<p class="hint">보드 보너스는 <a href="/data/boards">사용자 데이터의 멤버 보드</a>에 저장된 활성 노드와 연결 카드 증폭을 반영합니다. 조합 합계는 보상 종류별 획득량 증가율의 합입니다.</p>
<section class="summary" aria-label="파견 기록 요약">
<div><span>전체 파견 횟수</span><strong>{ready ? total.toLocaleString('ko-KR') : '—'}<small>회</small></strong></div>
<div><span>파견한 멤버</span><strong>{ready ? recorded : '—'}<small>/ {members.length}명</small></strong></div>
</section>
<p role="status" class="status">{status}</p>
{#if error}<p role="alert" class="error">{error}</p>{/if}
{#if !ready}<button class="reload" on:click={read}>기록 다시 불러오기</button>{/if}
<section class="team-panel" aria-labelledby="team-editor-title">
<h2 id="team-editor-title">{editingId ? '조합 수정' : '5명 조합 저장'}</h2>
<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 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}
</div>
</form>
</section>
<section class="saved-teams" aria-labelledby="saved-teams-title">
<h2 id="saved-teams-title">저장한 조합 <small>{teams.length}개</small></h2>
<p class="hint">같은 멤버의 횟수는 모든 조합에 함께 반영됩니다. 조합을 삭제해도 횟수는 유지됩니다.</p>
{#each teams as team (team.id)}
<article class="team-panel" aria-label={`${team.name} 조합`}>
<h3>{team.name}</h3>
<ul class="team-members">
{#each team.memberIds as id}
<li><div class="member-info"><span>{members.find((member) => member.id === id)?.name ?? '알 수 없는 멤버'}</span><HoloWorkBonuses bonus={bonuses[id]} /></div><HoloWorkProgress memberId={id} count={counts[id] ?? 0} /></li>
{/each}
</ul>
<div class="team-bonus-total"><h4>조합 보드 보너스 합계</h4><HoloWorkBonuses bonus={sumHoloWorkBoardBonuses(bonuses, team.memberIds)} showEmpty /></div>
<div class="team-actions">
<button class="plus" on:click={() => adjustTeam(team.id, 1)} disabled={!ready || team.memberIds.some((id) => (counts[id] ?? 0) >= Number.MAX_SAFE_INTEGER)}>5명 모두 +1</button>
<button on:click={() => adjustTeam(team.id, -1)} disabled={!ready || team.memberIds.some((id) => !(counts[id] > 0))}>5명 모두 −1</button>
<button on:click={() => editTeam(team)} disabled={!ready}>수정</button>
<button on:click={() => deletingId = team.id} disabled={!ready}>삭제</button>
</div>
{#if deletingId === team.id}
<div class="delete-confirm"><p>‘{team.name}’ 조합을 삭제할까요? 횟수는 유지됩니다.</p><button on:click={() => deleteTeam(team.id)}>삭제 확인</button> <button on:click={() => deletingId = ''}>취소</button></div>
{/if}
</article>
{:else}<p class="hint">저장한 조합이 없습니다. 서로 다른 멤버 5명을 선택해 저장하세요.</p>{/each}
</section>
<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><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 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>
<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} 파견 횟수`} />
<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(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; }
.hint { color: #a4adba; font-size: 13px; line-height: 1.7; }
.summary { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin: 24px 0; }
.summary div { padding: 20px; border: 1px solid #694254; border-radius: 12px; background: rgb(237 139 181 / 6%); }
.summary span { color: #a4adba; font-size: 13px; }
.summary strong { display: block; margin-top: 10px; color: #f3b4d0; font-size: 26px; overflow-wrap: anywhere; }
small { margin-left: 7px; color: #a4adba; font-size: 13px; font-weight: 400; }
.filters { display: grid; grid-template-columns: 1fr 150px; gap: 12px; }
.sort-actions { display: flex; align-items: center; gap: 12px; justify-content: space-between; margin-top: 12px; }
.sort-actions .hint { margin: 0; }
.sort-actions button { flex-shrink: 0; padding: 8px 12px; font-size: 13px; }
label { display: grid; gap: 8px; color: #a4adba; font-size: 13px; }
input, select { box-sizing: border-box; min-width: 0; width: 100%; height: 44px; padding: 0 12px; border: 1px solid #414b5b; border-radius: 9px; background: #101319; color: #e8ebf1; font: inherit; }
.status { min-height: 20px; color: #f3b4d0; font-size: 13px; }
.error { color: #f3a1a1; line-height: 1.7; }
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; }
.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); }
button:disabled { opacity: .4; cursor: not-allowed; }
button:focus-visible, input:focus-visible, select:focus-visible { outline: 3px solid #ed8bb5; outline-offset: 3px; }
.reload { padding: 0 14px; }
.empty { color: #a4adba; }
.hint a { color: #f3b4d0; }
.team-bonus-total { padding: 14px; margin: 14px 0; background: rgb(226 190 95 / 5%); border: 1px solid #65552c; border-radius: 10px; }
.team-bonus-total h4 { margin: 0 0 10px; color: #e8d69d; font-size: 13px; }
.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(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; }
h3 { margin: 0; font-size: 18px; overflow-wrap: anywhere; }
.team-members { border: 0; margin: 12px 0; }
.team-members li { padding: 10px 0; }
.delete-confirm { color: #f3b4d0; font-size: 14px; }
.all-members-title { margin: 32px 0 18px; }
.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>