feat: add Holo-work tracking, teams and board bonuses
Publish GitHub Pages output / publish (push) Successful in 30s

This commit is contained in:
2026-09-12 15:16:30 +09:00
parent cfea24f5f5
commit dd37f59565
25 changed files with 1237 additions and 33 deletions
+6
View File
@@ -1,5 +1,11 @@
# Repository instructions # Repository instructions
## Local development ports
- Port 5173 is reserved for the user's local Docker container. Never start an agent-run server on port 5173 or stop the container to free that port.
- For local testing, use another available port, for example `npm run dev -- --port 5174 --strictPort`. If it is occupied, choose another port without stopping the existing service.
- Stop agent-started test servers when verification is complete.
## Commit and push shorthand ## Commit and push shorthand
- When the user says "마무리 해" (or "마무리해"), treat it as authorization to verify the completed task, commit its changes, and push to the configured upstream. - When the user says "마무리 해" (or "마무리해"), treat it as authorization to verify the completed task, commit its changes, and push to the configured upstream.
+11 -1
View File
@@ -1,7 +1,17 @@
--- ---
updatedAt: 2026-09-10T21:32:35+09:00 updatedAt: 2026-09-12T00:00:00+09:00
--- ---
## 2026년 9월 12일
### 홀로워크 관리
`NEW` 도구에 ‘홀로워크 관리’를 추가했습니다. 멤버별 Holo워크 파견 횟수를 직접 입력하거나 +1·−1 버튼으로 수정하고, 이름 검색과 횟수순 정렬로 확인할 수 있습니다. 기록은 브라우저에 자동 저장되며 사용자 데이터 JSON 백업·불러오기에 포함됩니다.
원하는 멤버 5명으로 이름을 붙인 조합을 저장·수정·삭제하고, 조합 전체의 횟수를 한 번에 +1·−1 할 수 있습니다. 멤버별 기록은 `현재 횟수 / 다음 업적 요구량`으로 표시되며, 최종 400회 달성 시 완료로 표시합니다. 저장한 조합도 사용자 데이터 JSON 백업에 포함됩니다.
멤버 보드의 활성 노드와 연결 카드 증폭을 반영해 레슨 Pt·큐브·특훈 아이템 획득량 보너스를 멤버별로 표시하고, 저장한 조합에서는 보상 종류별 보너스 합계를 확인할 수 있습니다.
## 2026년 9월 10일 ## 2026년 9월 10일
### 악곡 랜덤 선택 ### 악곡 랜덤 선택
+2
View File
@@ -15,6 +15,7 @@ services:
volumes: volumes:
- .:/app - .:/app
- node_modules:/app/node_modules - node_modules:/app/node_modules
- svelte_kit:/app/.svelte-kit
- sqlite_data:/app/data - sqlite_data:/app/data
healthcheck: healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:5173/api/health').then((response) => { if (!response.ok) process.exit(1); }).catch(() => process.exit(1))"] test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:5173/api/health').then((response) => { if (!response.ok) process.exit(1); }).catch(() => process.exit(1))"]
@@ -26,4 +27,5 @@ services:
volumes: volumes:
node_modules: node_modules:
svelte_kit:
sqlite_data: sqlite_data:
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"schemaVersion": 1, "schemaVersion": 1,
"generatedAt": "2026-09-11T01:18:17.159Z", "generatedAt": "2026-09-12T03:46:50.302Z",
"sources": { "sources": {
"eng": { "eng": {
"repository": "https://github.com/HolodoriDB/holodori-db-eng-diff", "repository": "https://github.com/HolodoriDB/holodori-db-eng-diff",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"schemaVersion": 1, "schemaVersion": 1,
"generatedAt": "2026-09-11T01:18:17.159Z", "generatedAt": "2026-09-12T03:46:50.302Z",
"sources": { "sources": {
"eng": { "eng": {
"repository": "https://github.com/HolodoriDB/holodori-db-eng-diff", "repository": "https://github.com/HolodoriDB/holodori-db-eng-diff",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"schemaVersion": 1, "schemaVersion": 1,
"generatedAt": "2026-09-11T01:18:17.159Z", "generatedAt": "2026-09-12T03:46:50.302Z",
"source": "HolodoriDB community master-data diff repositories", "source": "HolodoriDB community master-data diff repositories",
"sourceLicense": null, "sourceLicense": null,
"sourceLicenseNote": "No LICENSE file was present in the three source repositories at collection time.", "sourceLicenseNote": "No LICENSE file was present in the three source repositories at collection time.",
+2
View File
@@ -6,6 +6,7 @@
import AnalysisPage from '../src/routes/analysis/+page.svelte'; import AnalysisPage from '../src/routes/analysis/+page.svelte';
import RatingPage from '../src/routes/rating/+page.svelte'; import RatingPage from '../src/routes/rating/+page.svelte';
import CombinationPage from '../src/routes/combination/+page.svelte'; import CombinationPage from '../src/routes/combination/+page.svelte';
import HoloWorkPage from '../src/routes/tools/holo-work/+page.svelte';
import ToolsPage from '../src/routes/tools/+page.svelte'; import ToolsPage from '../src/routes/tools/+page.svelte';
import EventPage from '../src/routes/event/+page.svelte'; import EventPage from '../src/routes/event/+page.svelte';
import DataPage from '../src/routes/data/+page.svelte'; import DataPage from '../src/routes/data/+page.svelte';
@@ -24,6 +25,7 @@
['/combination', CombinationPage], ['/combination', CombinationPage],
['/tools', ToolsPage], ['/tools', ToolsPage],
['/tools/random', ToolsPage], ['/tools/random', ToolsPage],
['/tools/holo-work', HoloWorkPage],
['/random', ToolsPage], ['/random', ToolsPage],
['/event', EventPage], ['/event', EventPage],
['/data', DataPage], ['/data', DataPage],
@@ -0,0 +1,22 @@
import { writeFileSync } from 'node:fs';
import { prepareHolodoriDbSource, readHolodoriDbText } from './holodoridb-source.mjs';
const source = prepareHolodoriDbSource('kor');
const missions = JSON.parse(readHolodoriDbText(source, 'Mission.json')).map((row) => row.data)
.filter((mission) => mission.characterId
&& mission.categoryType === 'MissionCategoryType_MISSION_CATEGORY_TYPE_ACHIEVEMENT'
&& mission.type === 'MissionType_MISSION_TYPE_INCREMENT_WORK_FINISH_COUNT');
const progress = JSON.parse(readHolodoriDbText(source, 'MissionProgress.json')).map((row) => row.data);
if (!missions.length) throw new Error('No member Holo-work achievements found.');
const thresholdsByMember = Object.fromEntries(missions.sort((a, b) => a.characterId.localeCompare(b.characterId)).map((mission) => {
const thresholds = [...new Set(progress.filter((row) => row.missionId === mission.id).map((row) => Number(row.threshold)))].sort((a, b) => a - b);
if (!thresholds.length || thresholds.some((value) => !Number.isSafeInteger(value) || value <= 0)) {
throw new Error(`Invalid Holo-work thresholds: ${mission.id}`);
}
return [mission.characterId, thresholds];
}));
writeFileSync(new URL('../src/lib/data/holo-work-achievements.json', import.meta.url), JSON.stringify({
source: { repository: source.repository, commit: source.commit, files: ['Mission.json', 'MissionProgress.json'] },
thresholdsByMember
}, null, 2) + '\n');
console.log(`Collected Holo-work achievements for ${missions.length} members at ${source.commit}.`);
+20
View File
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HoloWorkBoardBonus } from '$lib/engine/board-effects';
export let bonus: HoloWorkBoardBonus | undefined;
export let showEmpty = false;
const labels = { lesson: '레슨 Pt', cube: '큐브', training: '특훈 아이템' };
$: entries = (Object.keys(labels) as (keyof HoloWorkBoardBonus)[]).filter((key) => (bonus?.[key] ?? 0) > 0);
</script>
{#if entries.length}
<div class="bonuses" aria-label="홀로워크 보드 보너스">
{#each entries as key}<span>{labels[key]} <strong>+{bonus![key].toLocaleString('ko-KR', { maximumFractionDigits: 3 })}%</strong></span>{/each}
</div>
{:else if showEmpty}<p class="empty">활성 보드 보너스 없음</p>{/if}
<style>
.bonuses { display: flex; flex-wrap: wrap; gap: 6px; }
.bonuses span { padding: 5px 8px; border: 1px solid #65552c; border-radius: 7px; background: rgb(226 190 95 / 8%); color: #d9c890; font-size: 12px; line-height: 1.5; }
strong { color: #f1d583; white-space: nowrap; }
.empty { margin: 0; color: #a4adba; font-size: 13px; }
</style>
@@ -0,0 +1,19 @@
<script lang="ts">
import achievements from '$lib/data/holo-work-achievements.json';
import { workAchievement } from '$lib/storage/holo-work';
export let memberId: string;
export let count: number;
$: progress = workAchievement(count, (achievements.thresholdsByMember as Record<string, number[]>)[memberId] ?? []);
</script>
<span class="progress" class:complete={progress.complete}>
<strong>{count.toLocaleString('ko-KR')}</strong> / {progress.target?.toLocaleString('ko-KR') ?? '—'}회
{#if progress.complete}<small>완료</small>{/if}
</span>
<style>
.progress { white-space: nowrap; 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; }
</style>
+16
View File
@@ -0,0 +1,16 @@
<script lang="ts">
export let active: 'random' | 'holo-work';
</script>
<nav aria-label="도구 메뉴">
<a href="/tools/random" aria-current={active === 'random' ? 'page' : undefined}>악곡 랜덤</a>
<a href="/tools/holo-work" aria-current={active === 'holo-work' ? 'page' : undefined}>홀로워크 관리</a>
</nav>
<style>
nav { display: flex; gap: 5px; margin: 24px 0; padding: 5px; border: 1px solid #2c3441; border-radius: 12px; background: #171c24; }
a { flex: 1; padding: 9px 12px; border-radius: 8px; color: #929cad; font-size: 14px; font-weight: 800; text-align: center; text-decoration: none; }
a:hover { color: #dfe4eb; background: rgb(255 255 255 / 4%); }
a[aria-current='page'] { color: #f3a5c5; background: rgb(239 139 182 / 12%); }
a:focus-visible { outline: 3px solid #ed8bb5; outline-offset: 3px; }
</style>
+606
View File
@@ -0,0 +1,606 @@
{
"source": {
"repository": "https://github.com/HolodoriDB/holodori-db-kor-diff.git",
"commit": "9234a7d99c5cfba9b71803c24a6de4c005c4842f",
"files": [
"Mission.json",
"MissionProgress.json"
]
},
"thresholdsByMember": {
"chr-00001": [
1,
5,
10,
30,
50,
100,
200,
300,
400
],
"chr-00002": [
1,
5,
10,
30,
50,
100,
200,
300,
400
],
"chr-00004": [
1,
5,
10,
30,
50,
100,
200,
300,
400
],
"chr-00005": [
1,
5,
10,
30,
50,
100,
200,
300,
400
],
"chr-00006": [
1,
5,
10,
30,
50,
100,
200,
300,
400
],
"chr-00007": [
1,
5,
10,
30,
50,
100,
200,
300,
400
],
"chr-00010": [
1,
5,
10,
30,
50,
100,
200,
300,
400
],
"chr-00011": [
1,
5,
10,
30,
50,
100,
200,
300,
400
],
"chr-00012": [
1,
5,
10,
30,
50,
100,
200,
300,
400
],
"chr-00013": [
1,
5,
10,
30,
50,
100,
200,
300,
400
],
"chr-00014": [
1,
5,
10,
30,
50,
100,
200,
300,
400
],
"chr-00015": [
1,
5,
10,
30,
50,
100,
200,
300,
400
],
"chr-00016": [
1,
5,
10,
30,
50,
100,
200,
300,
400
],
"chr-00017": [
1,
5,
10,
30,
50,
100,
200,
300,
400
],
"chr-00018": [
1,
5,
10,
30,
50,
100,
200,
300,
400
],
"chr-00019": [
1,
5,
10,
30,
50,
100,
200,
300,
400
],
"chr-00021": [
1,
5,
10,
30,
50,
100,
200,
300,
400
],
"chr-00022": [
1,
5,
10,
30,
50,
100,
200,
300,
400
],
"chr-00023": [
1,
5,
10,
30,
50,
100,
200,
300,
400
],
"chr-00026": [
1,
5,
10,
30,
50,
100,
200,
300,
400
],
"chr-00027": [
1,
5,
10,
30,
50,
100,
200,
300,
400
],
"chr-00028": [
1,
5,
10,
30,
50,
100,
200,
300,
400
],
"chr-00030": [
1,
5,
10,
30,
50,
100,
200,
300,
400
],
"chr-00031": [
1,
5,
10,
30,
50,
100,
200,
300,
400
],
"chr-00032": [
1,
5,
10,
30,
50,
100,
200,
300,
400
],
"chr-00034": [
1,
5,
10,
30,
50,
100,
200,
300,
400
],
"chr-00035": [
1,
5,
10,
30,
50,
100,
200,
300,
400
],
"chr-00036": [
1,
5,
10,
30,
50,
100,
200,
300,
400
],
"chr-00037": [
1,
5,
10,
30,
50,
100,
200,
300,
400
],
"chr-00039": [
1,
5,
10,
30,
50,
100,
200,
300,
400
],
"chr-03001": [
1,
5,
10,
30,
50,
100,
200,
300,
400
],
"chr-03002": [
1,
5,
10,
30,
50,
100,
200,
300,
400
],
"chr-03003": [
1,
5,
10,
30,
50,
100,
200,
300,
400
],
"chr-03004": [
1,
5,
10,
30,
50,
100,
200,
300,
400
],
"chr-03005": [
1,
5,
10,
30,
50,
100,
200,
300,
400
],
"chr-03006": [
1,
5,
10,
30,
50,
100,
200,
300,
400
],
"chr-03007": [
1,
5,
10,
30,
50,
100,
200,
300,
400
],
"chr-03008": [
1,
5,
10,
30,
50,
100,
200,
300,
400
],
"chr-03009": [
1,
5,
10,
30,
50,
100,
200,
300,
400
],
"chr-04001": [
1,
5,
10,
30,
50,
100,
200,
300,
400
],
"chr-04002": [
1,
5,
10,
30,
50,
100,
200,
300,
400
],
"chr-04003": [
1,
5,
10,
30,
50,
100,
200,
300,
400
],
"chr-04007": [
1,
5,
10,
30,
50,
100,
200,
300,
400
],
"chr-04010": [
1,
5,
10,
30,
50,
100,
200,
300,
400
],
"chr-04012": [
1,
5,
10,
30,
50,
100,
200,
300,
400
],
"chr-04013": [
1,
5,
10,
30,
50,
100,
200,
300,
400
],
"chr-04014": [
1,
5,
10,
30,
50,
100,
200,
300,
400
],
"chr-04015": [
1,
5,
10,
30,
50,
100,
200,
300,
400
],
"chr-04016": [
1,
5,
10,
30,
50,
100,
200,
300,
400
],
"chr-04017": [
1,
5,
10,
30,
50,
100,
200,
300,
400
],
"chr-06002": [
1,
5,
10,
30,
50,
100,
200,
300,
400
],
"chr-06003": [
1,
5,
10,
30,
50,
100,
200,
300,
400
],
"chr-06004": [
1,
5,
10,
30,
50,
100,
200,
300,
400
],
"chr-06005": [
1,
5,
10,
30,
50,
100,
200,
300,
400
]
}
}
+38
View File
@@ -142,6 +142,44 @@ export type MemberBoardEffectSummary = {
}[]; }[];
}; };
export type HoloWorkBoardBonus = { lesson: number; cube: number; training: number };
const emptyWorkBonus = (): HoloWorkBoardBonus => ({ lesson: 0, cube: 0, training: 0 });
/** Percent bonuses provided when each board's owner participates in Holo-work. */
export function calculateHoloWorkBoardBonuses(
summary: BoardSummarySource,
memberBoards: Readonly<Record<string, readonly string[]>>,
connections?: BoardConnections | null
): Record<string, HoloWorkBoardBonus> {
const multipliers = connectionMultipliers(summary, memberBoards, connections);
return Object.fromEntries(Object.entries(memberBoards).map(([memberId, nodes]) => {
const bonus = emptyWorkBonus();
for (const groupId of new Set(nodes)) {
const effect = nodeForMember(summary, groupId, memberId)?.effect;
if (effect?.trigger !== 'work' || effect.type !== 'work_reward_quantity_up_permil_up'
|| effect.targetId !== 'skill_tree_effect_target-skill_tree_character' || effect.value === null) continue;
// Stable resource suffixes from the official SkillTreeEffect IDs distinguish reward categories.
const category = effect.id?.endsWith('-work-item-item-card_level_up') ? 'lesson'
: effect.id?.endsWith('-work-item-item-skill_tree_card-1') ? 'cube'
: effect.id?.endsWith('-work-item-item-card_limit_break-attribute_1-1') ? 'training' : null;
if (category) bonus[category] += effect.value * (multipliers[memberId]?.[groupId] ?? 1) / 10;
}
return [memberId, bonus];
}));
}
export function sumHoloWorkBoardBonuses(bonuses: Readonly<Record<string, HoloWorkBoardBonus>>, memberIds: readonly string[]): HoloWorkBoardBonus {
const total = emptyWorkBonus();
for (const id of new Set(memberIds)) {
const bonus = bonuses[id];
if (!bonus) continue;
total.lesson += bonus.lesson;
total.cube += bonus.cube;
total.training += bonus.training;
}
return total;
}
/** Saved-board overview, without assuming a leader or a song. Conditional effects stay in the detail rows. */ /** Saved-board overview, without assuming a leader or a song. Conditional effects stay in the detail rows. */
export function summarizeMemberBoardEffects( export function summarizeMemberBoardEffects(
summary: BoardSummarySource, summary: BoardSummarySource,
+48
View File
@@ -0,0 +1,48 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { calculateHoloWorkBoardBonuses, sumHoloWorkBoardBonuses, type BoardSummarySource, type BoardConnections } from './board-effects.ts';
function effect(resource: string, value: number) {
return { id: `effect-work-item-${resource}`, type: 'work_reward_quantity_up_permil_up', value,
trigger: 'work', targetId: 'skill_tree_effect_target-skill_tree_character' };
}
const summary: BoardSummarySource = {
members: [{ id: 'a', positionGroupId: 'model' }, { id: 'b', positionGroupId: 'model' }],
models: { model: [{ groupId: 'S-1', x: 0, y: 0 }, { groupId: 'Y-1', x: 1, y: 0 }, { groupId: 'S-2', x: 2, y: 0 }] },
nodeGroups: {
'Y-1': [{ characterIds: [], effect: effect('item-card_level_up', 30) }, { characterIds: ['b'], effect: effect('item-card_level_up', 60) }],
'Y-2': [{ characterIds: [], effect: effect('item-skill_tree_card-1', 40) }],
'Y-3': [{ characterIds: [], effect: effect('item-card_limit_break-attribute_1-1', 80) }],
'B-1': [{ characterIds: [], effect: { type: 'performance_up', value: 100 } }]
}
};
const connections: BoardConnections = {
placements: { a: { 'S-1': 'card' } },
cards: [{ id: 'card', awakening: 0, connect: [
{ level: 1, effectPermilUp: 500, extent: [{ x: 1, y: 0 }, { x: -1, y: 0 }] },
{ level: 2, effectPermilUp: 1000, extent: [{ x: 1, y: 0 }, { x: -1, y: 0 }] }
] }]
};
test('활성 노드만 합산하고 중복 노드·다른 보너스를 제외하며 멤버별 변형을 적용한다', () => {
const bonuses = calculateHoloWorkBoardBonuses(summary, { a: ['Y-1', 'Y-1', 'Y-2', 'B-1'], b: ['Y-1', 'Y-3'] });
assert.deepEqual(bonuses, { a: { lesson: 3, cube: 4, training: 0 }, b: { lesson: 6, cube: 0, training: 8 } });
assert.deepEqual(calculateHoloWorkBoardBonuses(summary, {}), {});
});
test('연결 카드 개화와 배치·해제에 따라 소수점 보너스도 반영한다', () => {
const boards = { a: ['Y-1'] };
assert.equal(calculateHoloWorkBoardBonuses(summary, boards, connections).a.lesson, 4.5);
const awakened = { ...connections, cards: connections.cards.map((card) => ({ ...card, awakening: 5 })) };
assert.equal(calculateHoloWorkBoardBonuses(summary, boards, awakened).a.lesson, 6);
assert.equal(calculateHoloWorkBoardBonuses(summary, boards).a.lesson, 3);
assert.equal(calculateHoloWorkBoardBonuses(summary, { a: [] }, connections).a.lesson, 0);
assert.equal(calculateHoloWorkBoardBonuses(summary, boards, { ...connections, placements: { a: { 'S-2': 'card' } } }).a.lesson, 3);
});
test('조합 내 멤버의 보상 종류별 보너스만 합산하며 중복 멤버는 한 번만 센다', () => {
const bonuses = calculateHoloWorkBoardBonuses(summary, { a: ['Y-1', 'Y-2'], b: ['Y-1', 'Y-3'] }, connections);
assert.deepEqual(sumHoloWorkBoardBonuses(bonuses, ['a', 'b', 'c', 'd', 'e']), { lesson: 10.5, cube: 4, training: 8 });
assert.deepEqual(sumHoloWorkBoardBonuses(bonuses, ['a', 'a']), bonuses.a);
assert.deepEqual(sumHoloWorkBoardBonuses(bonuses, []), { lesson: 0, cube: 0, training: 0 });
});
+67
View File
@@ -0,0 +1,67 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createUserData, type CardListPreferences } from '../models/user-data.ts';
import { parseUserData } from '../storage/browser-storage.ts';
import { adjustWorkTeam, parseWorkTeams, workAchievement } from '../storage/holo-work.ts';
const preferences: CardListPreferences = {
types: [], stats: [], activeConditions: [], specialConditions: [], activeProbabilities: [],
cardVariants: [], generations: [], sortKey: null, sortDirection: 'asc', skillLevel: 1
};
test('사용자 데이터 JSON은 홀로워크 기록과 카드·보드 설정을 함께 복원한다', () => {
const data = createUserData(preferences);
data.holoWorkCounts = { memberA: 15, memberB: 0, futureMember: 200 };
data.holoWorkTeams = [{ id: 'team1', name: '매일 조합', memberIds: ['a', 'b', 'c', 'd', 'e'] }];
data.collection = { cardA: { owned: true, awakening: 3 } };
data.memberBoards = { memberA: ['node1'] };
assert.deepEqual(parseUserData(JSON.stringify(data), preferences), data);
});
test('홀로워크 필드가 없는 기존 백업은 빈 기록으로 복원한다', () => {
const { holoWorkCounts, holoWorkTeams, ...oldBackup } = createUserData(preferences);
assert.deepEqual(parseUserData(JSON.stringify(oldBackup), preferences).holoWorkCounts, {});
assert.deepEqual(parseUserData(JSON.stringify(oldBackup), preferences).holoWorkTeams, []);
});
test('업적 달성 즉시 다음 요구량으로 넘어가며 최종 목표는 유지한다', () => {
const thresholds = [1, 5, 10, 30, 50, 100, 200, 300, 400];
assert.deepEqual(workAchievement(0, thresholds), { target: 1, complete: false });
assert.deepEqual(workAchievement(12, thresholds), { target: 30, complete: false });
for (let i = 0; i < thresholds.length; i++) {
assert.deepEqual(workAchievement(thresholds[i] - 1, thresholds), { target: thresholds[i], complete: false });
assert.deepEqual(workAchievement(thresholds[i], thresholds), { target: thresholds[i + 1] ?? 400, complete: i === thresholds.length - 1 });
}
assert.deepEqual(workAchievement(500, thresholds), { target: 400, complete: true });
assert.deepEqual(workAchievement(12, []), { target: null, complete: false });
});
test('조합은 서로 다른 멤버 5명이어야 하고 중복 ID나 빈 이름을 거부한다', () => {
const team = { id: 'a', name: '조합', memberIds: ['a', 'b', 'c', 'd', 'e'] };
for (const memberIds of [[], ['a', 'b', 'c', 'd'], ['a', 'b', 'c', 'd', 'a'], ['a', 'b', 'c', 'd', 'e', 'f']]) {
assert.throws(() => parseWorkTeams([{ ...team, memberIds }]));
}
assert.throws(() => parseWorkTeams([team, team]));
assert.throws(() => parseWorkTeams([{ ...team, name: ' ' }]));
assert.deepEqual(parseWorkTeams([team]), [team]);
});
test('조합 일괄 변경은 다섯 명만 바꾸고 하나라도 범위를 벗어나면 모두 유지한다', () => {
const ids = ['a', 'b', 'c', 'd', 'e'];
const counts = { a: 1, b: 3, c: 5, d: 10, e: 399, other: 99 };
const incremented = adjustWorkTeam(counts, ids, 1);
assert.deepEqual(incremented, { a: 2, b: 4, c: 6, d: 11, e: 400, other: 99 });
assert.deepEqual(adjustWorkTeam(incremented, ids, -1), counts);
assert.throws(() => adjustWorkTeam({ ...counts, e: 0 }, ids, -1));
assert.throws(() => adjustWorkTeam({ ...counts, e: Number.MAX_SAFE_INTEGER }, ids, 1));
assert.equal(counts.a, 1);
});
test('잘못된 홀로워크 횟수가 있는 백업은 거부한다', () => {
for (const value of [-1, 1.5, '3', null, Number.MAX_SAFE_INTEGER + 1]) {
assert.throws(() => parseUserData(JSON.stringify({ ...createUserData(preferences), holoWorkCounts: { a: value } }), preferences));
}
for (const value of [null, [], 'invalid']) {
assert.throws(() => parseUserData(JSON.stringify({ ...createUserData(preferences), holoWorkCounts: value }), preferences));
}
});
+17
View File
@@ -0,0 +1,17 @@
import cardSummary from '$lib/data/cards-summary.json';
import type { CardListPreferences } from './user-data';
export function defaultPreferences(): CardListPreferences {
return {
types: ['큐트', '퓨어', '해피'],
stats: ['퍼포먼스', '테크닉', '센스'],
activeConditions: ['일반', '조건부'],
specialConditions: ['일반', '조건부'],
activeProbabilities: ['높음', '중간'],
cardVariants: ['일반', ...new Set(cardSummary.cards.filter((card) => card.rarity === 5).map((card) => card.versionName).filter(Boolean))],
generations: [...new Set(cardSummary.cards.filter((card) => card.rarity === 5).map((card) => card.generation).filter(Boolean))],
sortKey: null,
sortDirection: 'asc',
skillLevel: 1
};
}
+6
View File
@@ -31,9 +31,13 @@ export type CardListPreferences = {
export type BoardCardPlacements = Record<string, Record<string, string>>; export type BoardCardPlacements = Record<string, Record<string, string>>;
export type HoloWorkTeam = { id: string; name: string; memberIds: string[] };
export type UserDataV1 = { export type UserDataV1 = {
schemaVersion: 1; schemaVersion: 1;
updatedAt: string; updatedAt: string;
holoWorkCounts: Record<string, number>;
holoWorkTeams: HoloWorkTeam[];
memoryCount: number; memoryCount: number;
memberPowerUpPercent: number; memberPowerUpPercent: number;
collection: Record<string, CollectionCardState>; collection: Record<string, CollectionCardState>;
@@ -49,6 +53,8 @@ export function createUserData(preferences: CardListPreferences): UserDataV1 {
return { return {
schemaVersion: 1, schemaVersion: 1,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
holoWorkCounts: {},
holoWorkTeams: [],
memoryCount: 0, memoryCount: 0,
memberPowerUpPercent: 0, memberPowerUpPercent: 0,
collection: {}, collection: {},
+4 -1
View File
@@ -1,5 +1,6 @@
import { parseWorkCounts, parseWorkTeams } from './holo-work.ts';
import type { CardListPreferences, CardListSortKey, CardSkillLevel, CollectionCardState, SortDirection, UserData } from '$lib/models/user-data'; import type { CardListPreferences, CardListSortKey, CardSkillLevel, CollectionCardState, SortDirection, UserData } from '$lib/models/user-data';
import { createUserData } from '$lib/models/user-data'; import { createUserData } from '../models/user-data.ts';
export const USER_DATA_STORAGE_KEY = 'holodori:user-data:v1'; export const USER_DATA_STORAGE_KEY = 'holodori:user-data:v1';
@@ -80,6 +81,8 @@ export function parseUserData(text: string, fallbackPreferences: CardListPrefere
return { return {
schemaVersion: 1, schemaVersion: 1,
updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : defaults.updatedAt, updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : defaults.updatedAt,
holoWorkCounts: parseWorkCounts(value.holoWorkCounts),
holoWorkTeams: parseWorkTeams(value.holoWorkTeams),
memoryCount: Math.max(0, Math.round(finiteNumber(value.memoryCount))), memoryCount: Math.max(0, Math.round(finiteNumber(value.memoryCount))),
memberPowerUpPercent: Math.max(0, Math.min(50, finiteNumber(value.memberPowerUpPercent))), memberPowerUpPercent: Math.max(0, Math.min(50, finiteNumber(value.memberPowerUpPercent))),
collection: parseCollection(value.collection), collection: parseCollection(value.collection),
+46
View File
@@ -0,0 +1,46 @@
import type { HoloWorkTeam } from '../models/user-data.ts';
export type HoloWorkCounts = Record<string, number>;
export function isWorkCount(value: unknown): value is number {
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
}
export function parseWorkCounts(value: unknown): HoloWorkCounts {
if (value === undefined) return {};
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error('올바른 홀로워크 기록이 아닙니다.');
}
const entries = Object.entries(value);
if (entries.some(([, count]) => !isWorkCount(count))) throw new Error('횟수는 0 이상의 정수여야 합니다.');
return Object.fromEntries(entries);
}
export function parseWorkTeams(value: unknown): HoloWorkTeam[] {
if (value === undefined) return [];
if (!Array.isArray(value)) throw new Error('올바른 홀로워크 조합 목록이 아닙니다.');
const ids = new Set<string>();
return value.map((team) => {
if (!team || typeof team !== 'object' || typeof team.id !== 'string' || !team.id.trim()
|| ids.has(team.id) || typeof team.name !== 'string' || !team.name.trim() || team.name.trim().length > 80
|| !Array.isArray(team.memberIds) || team.memberIds.length !== 5
|| team.memberIds.some((id: unknown) => typeof id !== 'string' || !id.trim())
|| new Set(team.memberIds).size !== 5) {
throw new Error('홀로워크 조합은 이름과 서로 다른 멤버 5명이 필요합니다.');
}
ids.add(team.id);
return { id: team.id, name: team.name.trim(), memberIds: [...team.memberIds] };
});
}
export function workAchievement(count: number, thresholds: readonly number[]) {
const target = thresholds.find((threshold) => threshold > count) ?? thresholds.at(-1) ?? null;
return { target, complete: target !== null && count >= target };
}
export function adjustWorkTeam(counts: HoloWorkCounts, memberIds: readonly string[], amount: 1 | -1): HoloWorkCounts {
if (memberIds.length !== 5 || new Set(memberIds).size !== 5) throw new Error('서로 다른 멤버 5명이 필요합니다.');
const changes = memberIds.map((id) => [id, (counts[id] ?? 0) + amount] as const);
if (changes.some(([, value]) => !isWorkCount(value))) throw new Error('변경할 수 없는 횟수가 포함되어 있습니다.');
return { ...counts, ...Object.fromEntries(changes) };
}
+1 -1
View File
@@ -48,7 +48,7 @@
</a> </a>
<a class="feature-card" href="/tools"> <a class="feature-card" href="/tools">
<span class="feature-number">06</span> <span class="feature-number">06</span>
<div><h3>도구</h3><p>악곡 랜덤 등 플레이에 유용한 도구를 이용합니다. 원하는 레벨 범위에서 악곡과 난이도를 뽑을 수 있습니다.</p></div> <div><h3>도구</h3><p>악곡과 난이도를 랜덤으로 뽑거나, 홀로워크에 보낸 횟수를 멤버별로 기록하고 확인합니다.</p></div>
<span class="arrow" aria-hidden="true">→</span> <span class="arrow" aria-hidden="true">→</span>
</a> </a>
</div> </div>
+4 -1
View File
@@ -45,6 +45,8 @@
$: currentUserData = { $: currentUserData = {
schemaVersion: 1, schemaVersion: 1,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
holoWorkCounts: {},
holoWorkTeams: [],
memoryCount, memoryCount,
memberPowerUpPercent, memberPowerUpPercent,
collection, collection,
@@ -56,7 +58,8 @@
$: if (storageReady) { $: if (storageReady) {
try { try {
saveBrowserData(currentUserData); const { holoWorkCounts, holoWorkTeams } = loadBrowserData(defaultPreferences());
saveBrowserData({ ...currentUserData, holoWorkCounts, holoWorkTeams });
} catch {} } catch {}
} }
+5 -17
View File
@@ -10,6 +10,7 @@
import { calculateBoardEffects, calculateUnitPower, cardMatchesSearch, maximumWeightMatching, plainSkillText } from '$lib/engine/index.ts'; import { calculateBoardEffects, calculateUnitPower, cardMatchesSearch, maximumWeightMatching, plainSkillText } from '$lib/engine/index.ts';
import { memoryParameterPermil } from '$lib/engine/memory.ts'; import { memoryParameterPermil } from '$lib/engine/memory.ts';
import type { BoardCardPlacements, CardListPreferences, CollectionCardState } from '$lib/models/user-data'; import type { BoardCardPlacements, CardListPreferences, CollectionCardState } from '$lib/models/user-data';
import { defaultPreferences } from '$lib/models/default-preferences';
import { createUserData } from '$lib/models/user-data'; import { createUserData } from '$lib/models/user-data';
import { clearBrowserData, loadBrowserData, parseUserData, saveBrowserData } from '$lib/storage/browser-storage'; import { clearBrowserData, loadBrowserData, parseUserData, saveBrowserData } from '$lib/storage/browser-storage';
@@ -100,21 +101,6 @@
const boardNodeGroups = boardSummary.nodeGroups as Record<string, BoardNode[]>; const boardNodeGroups = boardSummary.nodeGroups as Record<string, BoardNode[]>;
const emptyCardState: CollectionCardState = { owned: false, awakening: 0 }; const emptyCardState: CollectionCardState = { owned: false, awakening: 0 };
function defaultPreferences(): CardListPreferences {
return {
types: ['큐트', '퓨어', '해피'],
stats: ['퍼포먼스', '테크닉', '센스'],
activeConditions: ['일반', '조건부'],
specialConditions: ['일반', '조건부'],
activeProbabilities: ['높음', '중간'],
cardVariants: ['일반', ...new Set(fiveStarCards.map((card) => card.versionName).filter(Boolean))],
generations: [...new Set(fiveStarCards.map((card) => card.generation).filter(Boolean))],
sortKey: null,
sortDirection: 'asc',
skillLevel: 1
};
}
let userData = createUserData(defaultPreferences()); let userData = createUserData(defaultPreferences());
let message = ''; let message = '';
let isError = false; let isError = false;
@@ -379,7 +365,8 @@
} }
function persist(next = userData) { function persist(next = userData) {
userData = saveBrowserData(next); const { holoWorkCounts, holoWorkTeams } = loadBrowserData(defaultPreferences());
userData = saveBrowserData({ ...next, holoWorkCounts, holoWorkTeams });
greenEfficiencyResult = null; greenEfficiencyResult = null;
greenEfficiencyError = ''; greenEfficiencyError = '';
showMessage('변경 내용을 이 브라우저에 저장했습니다.'); showMessage('변경 내용을 이 브라우저에 저장했습니다.');
@@ -1004,7 +991,8 @@
function exportUserData() { function exportUserData() {
try { try {
userData = saveBrowserData(userData); const { holoWorkCounts, holoWorkTeams } = loadBrowserData(defaultPreferences());
userData = saveBrowserData({ ...userData, holoWorkCounts, holoWorkTeams });
const url = URL.createObjectURL(new Blob([JSON.stringify(userData, null, 2)], { type: 'application/json' })); const url = URL.createObjectURL(new Blob([JSON.stringify(userData, null, 2)], { type: 'application/json' }));
const link = document.createElement('a'); const link = document.createElement('a');
link.href = url; link.href = url;
+2 -8
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount, tick } from 'svelte'; import { onMount, tick } from 'svelte';
import ToolsNav from '$lib/components/ToolsNav.svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import exactChartCatalog from '$lib/data/exact-chart-catalog.json'; import exactChartCatalog from '$lib/data/exact-chart-catalog.json';
import { difficultyLabel } from '$lib/data/difficulty.ts'; import { difficultyLabel } from '$lib/data/difficulty.ts';
@@ -93,9 +94,7 @@
<p>플레이할 때 유용한 도구를 모았습니다.</p> <p>플레이할 때 유용한 도구를 모았습니다.</p>
</header> </header>
<nav class="tools-nav" aria-label="도구 메뉴"> <ToolsNav active="random" />
<a href="/tools/random" aria-current="page">악곡 랜덤</a>
</nav>
<div class="tool-heading"> <div class="tool-heading">
<h2>악곡 랜덤</h2> <h2>악곡 랜덤</h2>
@@ -165,11 +164,6 @@
main { width: min(760px, calc(100% - var(--page-gutter))); margin: 0 auto; padding: 42px 0 80px; } main { width: min(760px, 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; } h1 { margin: 0 0 14px; font-size: clamp(30px, 5vw, 42px); letter-spacing: -.04em; }
header > p:last-child { color: #a4adba; line-height: 1.7; } header > p:last-child { color: #a4adba; line-height: 1.7; }
.tools-nav { display: flex; gap: 5px; margin: 24px 0; padding: 5px; border: 1px solid #2c3441; border-radius: 12px; background: #171c24; }
.tools-nav a { flex: 1; padding: 9px 12px; border-radius: 8px; color: #929cad; font-size: 14px; font-weight: 800; text-align: center; text-decoration: none; }
.tools-nav a:hover { color: #dfe4eb; background: rgb(255 255 255 / 4%); }
.tools-nav a[aria-current='page'] { color: #f3a5c5; background: rgb(239 139 182 / 12%); }
.tools-nav a:focus-visible { outline: 3px solid #ed8bb5; outline-offset: 3px; }
.tool-heading p { color: #a4adba; line-height: 1.7; margin-bottom: 0; } .tool-heading p { color: #a4adba; line-height: 1.7; margin-bottom: 0; }
.panel, .result { margin-top: 26px; padding: 26px; border: 1px solid #343c49; border-radius: 16px; background: #151a22; } .panel, .result { margin-top: 26px; padding: 26px; border: 1px solid #343c49; border-radius: 16px; background: #151a22; }
.toggle { display: flex; align-items: center; gap: 10px; font-weight: 800; } .toggle { display: flex; align-items: center; gap: 10px; font-weight: 800; }
+290
View File
@@ -0,0 +1,290 @@
<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, type HoloWorkCounts } from '$lib/storage/holo-work';
import type { HoloWorkTeam } from '$lib/models/user-data';
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 = ['', '', '', '', ''];
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 = 'member';
let status = '';
let error = '';
$: visibleMembers = members.filter((member) => member.name.replace(/\s/g, '').toLocaleLowerCase().includes(search.replace(/\s/g, '').toLocaleLowerCase()))
.sort((a, b) => (sort === 'asc' ? (counts[a.id] ?? 0) - (counts[b.id] ?? 0) : sort === 'desc' ? (counts[b.id] ?? 0) - (counts[a.id] ?? 0) : 0) || a.order - b.order);
$: total = members.reduce((sum, member) => sum + BigInt(counts[member.id] ?? 0), 0n);
$: recorded = members.filter((member) => (counts[member.id] ?? 0) > 0).length;
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 = ['', '', '', '', ''];
}
function editTeam(team: HoloWorkTeam) {
editingId = team.id;
teamName = team.name;
selectedMembers = [...team.memberIds];
document.getElementById('team-name')?.focus();
}
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} />
<div class="member-selects">
{#each selectedMembers as memberId, index}
<label>멤버 {index + 1}
<select bind:value={selectedMembers[index]} required disabled={!ready}>
<option value="" disabled>멤버 선택</option>
{#each members as member}
<option value={member.id} disabled={selectedMembers.includes(member.id) && memberId !== member.id}>{member.name}</option>
{/each}
</select>
</label>
{/each}
</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 bind:value={sort}><option value="member">멤버순</option><option value="asc">횟수 적은 순</option><option value="desc">횟수 많은 순</option></select></label>
</div>
<ul aria-label="멤버별 Holo워크 횟수">
{#each visibleMembers as member (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>
<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>
</div>
</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; }
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; }
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; }
.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; }
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(2, minmax(0, 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; }
@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; } }
</style>
+2 -1
View File
@@ -10,7 +10,8 @@ export default defineConfig({
// Ignoring all of .svelte-kit leaves cached page imports pointing at old route IDs. // Ignoring all of .svelte-kit leaves cached page imports pointing at old route IDs.
watch: { watch: {
usePolling: process.env.WATCH_USE_POLLING === 'true', usePolling: process.env.WATCH_USE_POLLING === 'true',
interval: 1000 interval: 1000,
ignored: ['**/output/**', '**/pages-dist/**', '**/offline-dist/**', '**/release/**']
} }
} }
}); });