diff --git a/UPDATES.md b/UPDATES.md index 48f154d..3205585 100644 --- a/UPDATES.md +++ b/UPDATES.md @@ -1,7 +1,15 @@ --- -updatedAt: 2026-09-17T20:37:06+09:00 +updatedAt: 2026-09-18T03:36:33.635Z --- +## 2026년 9월 18일 + +### 홀로워크 조합 추천과 목록 개선 + +`NEW` 조합 편집에서 원하는 멤버를 고정하고, 나머지 자리를 다음 업적에 가까운 멤버로 추천받을 수 있습니다. 추천은 버튼을 눌렀을 때만 갱신하며, 선택한 조합의 보드 보너스 합계를 확인하고 저장할 수 있습니다. + +`IMPROVED` 업적 가까운 순 정렬을 추가했습니다. 횟수를 수정해도 목록과 추천 조합은 유지됩니다. 전체 멤버 목록을 넓은 화면에서 최대 5열로 표시하고, 이름 아래에 횟수 입력·조정과 업적 목표를 함께 배치했습니다. 저장 조합의 5명도 데스크톱에서 한 행에 표시합니다. + ## 2026년 9월 17일 ### 노란 보드 연결 카드 배치 보정 diff --git a/src/lib/components/HoloWorkProgress.svelte b/src/lib/components/HoloWorkProgress.svelte index c66c040..c9b5533 100644 --- a/src/lib/components/HoloWorkProgress.svelte +++ b/src/lib/components/HoloWorkProgress.svelte @@ -8,12 +8,13 @@ {count.toLocaleString('ko-KR')} / {progress.target?.toLocaleString('ko-KR') ?? '—'}회 - {#if progress.complete}완료{/if} + {#if progress.complete}완료{:else if progress.target !== null}{(progress.target - count).toLocaleString('ko-KR')}회 남음{/if} diff --git a/src/lib/engine/holo-work-recommendation.test.ts b/src/lib/engine/holo-work-recommendation.test.ts new file mode 100644 index 0000000..0ffd8c4 --- /dev/null +++ b/src/lib/engine/holo-work-recommendation.test.ts @@ -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']); +}); diff --git a/src/lib/models/holo-work-sort.ts b/src/lib/models/holo-work-sort.ts index cb12c6a..b653a13 100644 --- a/src/lib/models/holo-work-sort.ts +++ b/src/lib/models/holo-work-sort.ts @@ -1,11 +1,38 @@ -export type HoloWorkSort = 'member' | 'asc' | 'desc'; +export type HoloWorkSort = 'member' | 'asc' | 'desc' | 'achievement'; +type Thresholds = Readonly>; + +export function remainingWork(id: string, counts: Readonly>, 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( - members: readonly T[], counts: Readonly>, sort: HoloWorkSort + members: readonly T[], counts: Readonly>, 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( + members: readonly T[], counts: Readonly>, thresholds: Thresholds, + selected: readonly string[], pinned: readonly boolean[] +): string[] { + const used = new Set(); + 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 || ''); +} diff --git a/src/routes/tools/holo-work/+page.svelte b/src/routes/tools/holo-work/+page.svelte index 87b7d6c..c87d2e6 100644 --- a/src/routes/tools/holo-work/+page.svelte +++ b/src/routes/tools/holo-work/+page.svelte @@ -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 @@
+

직접 선택한 멤버는 고정됩니다. 추천 갱신을 누르면 고정하지 않은 자리를 다음 업적에 가까운 멤버로 채웁니다.

+
+ + + {#if recommendationStale}횟수가 변경되었습니다. 추천을 갱신해 주세요.{/if} +
{#each selectedMembers as memberId, index} +
+ + {#if memberId}{/if} +
{/each}
+

선택한 조합 보드 보너스 합계

{#if editingId}{/if} @@ -231,26 +269,30 @@

멤버별 기록

- +

횟수를 변경해도 위치는 유지됩니다. 정렬 기준을 바꾸거나 다시 정렬하면 최신 횟수를 반영합니다.

-
    +
      {#each visibleMembers as member (member.id)} + {@const count = counts[member.id] ?? 0} + {@const progress = workAchievement(count, (thresholds as Record)[member.id] ?? [])}
    • -
      {member.name}
      + {member.name}
      changeInput(member.id, event.currentTarget)} aria-label={`${member.name} 파견 횟수`} /> - 회 + / {progress.target?.toLocaleString('ko-KR') ?? '—'}회
      + {#if progress.complete}

      모든 업적 완료

      {/if} +
    • {:else}
    • 검색에 맞는 멤버가 없습니다.
    • {/each}