This commit is contained in:
+33
@@ -0,0 +1,33 @@
|
|||||||
|
---
|
||||||
|
updatedAt: 2026-08-31T14:15:17+09:00
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026년 8월 31일
|
||||||
|
|
||||||
|
### 업데이트 내역 탭 추가
|
||||||
|
|
||||||
|
`NEW` 버전 배포를 기다리지 않고, 수시로 반영되는 기능·개선·수정·데이터 변경 내용을 한곳에서 확인할 수 있습니다.
|
||||||
|
|
||||||
|
### 제보와 기능 건의 동선 개선
|
||||||
|
|
||||||
|
`IMPROVED` 상단 메뉴에서 버그 제보, 기능 건의, 실측값 제보 폼을 바로 열 수 있도록 정리했습니다.
|
||||||
|
|
||||||
|
### 카드별 가창 보너스 덮어쓰기
|
||||||
|
|
||||||
|
`NEW` 배치 분석과 조합 탐색에서 카드별 가창 보너스를 직접 입력해 계산에 반영할 수 있습니다.
|
||||||
|
|
||||||
|
### 점수 타임라인 추가
|
||||||
|
|
||||||
|
`NEW` 배치 분석 결과에서 초당 노트 분포와 멤버별 액티브·스페셜 발동 구간을 시각적으로 확인할 수 있습니다.
|
||||||
|
|
||||||
|
### 일반 배치 분석의 순서 선정 기준 보정
|
||||||
|
|
||||||
|
`FIXED` 일반 배치 분석이 예상 점수보다 이론상 최고점을 우선해 최적 순서를 선정하도록 기준을 바로잡았습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026년 8월 29일
|
||||||
|
|
||||||
|
### 최신 카드와 악곡 데이터 반영
|
||||||
|
|
||||||
|
`DATA` 신규 수영복 5성 카드와 악곡 6종의 Hard·Expert 채보를 추가하고, 악곡이 게임 내 공개 시각에 맞춰 목록에 표시되도록 개선했습니다.
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
import DataPage from '../src/routes/data/+page.svelte';
|
import DataPage from '../src/routes/data/+page.svelte';
|
||||||
import DataCardsPage from '../src/routes/data/cards/+page.svelte';
|
import DataCardsPage from '../src/routes/data/cards/+page.svelte';
|
||||||
import DataBoardsPage from '../src/routes/data/boards/+page.svelte';
|
import DataBoardsPage from '../src/routes/data/boards/+page.svelte';
|
||||||
|
import UpdatesPage from '../src/routes/updates/+page.svelte';
|
||||||
import { setOfflinePage } from './page-store';
|
import { setOfflinePage } from './page-store';
|
||||||
import { events } from '../src/lib/data/events';
|
import { events } from '../src/lib/data/events';
|
||||||
import { activeEventAt } from '../src/lib/engine/event-optimization';
|
import { activeEventAt } from '../src/lib/engine/event-optimization';
|
||||||
@@ -20,7 +21,8 @@
|
|||||||
['/event', EventPage],
|
['/event', EventPage],
|
||||||
['/data', DataPage],
|
['/data', DataPage],
|
||||||
['/data/cards', DataCardsPage],
|
['/data/cards', DataCardsPage],
|
||||||
['/data/boards', DataBoardsPage]
|
['/data/boards', DataBoardsPage],
|
||||||
|
['/updates', UpdatesPage]
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function pathFromHash() {
|
function pathFromHash() {
|
||||||
@@ -78,6 +80,7 @@
|
|||||||
<a href="#/combination" aria-current={currentPath.startsWith('/combination') ? 'page' : undefined}>조합 탐색</a>
|
<a href="#/combination" aria-current={currentPath.startsWith('/combination') ? 'page' : undefined}>조합 탐색</a>
|
||||||
{#if showEventTab}<a href="#/event" aria-current={currentPath.startsWith('/event') ? 'page' : undefined}>이벤트 Pt</a>{/if}
|
{#if showEventTab}<a href="#/event" aria-current={currentPath.startsWith('/event') ? 'page' : undefined}>이벤트 Pt</a>{/if}
|
||||||
<a href="#/data" aria-current={currentPath.startsWith('/data') ? 'page' : undefined}>사용자 데이터</a>
|
<a href="#/data" aria-current={currentPath.startsWith('/data') ? 'page' : undefined}>사용자 데이터</a>
|
||||||
|
<a href="#/updates" aria-current={currentPath.startsWith('/updates') ? 'page' : undefined}>업데이트 내역</a>
|
||||||
<a
|
<a
|
||||||
class="feedback-link"
|
class="feedback-link"
|
||||||
href="https://docs.google.com/forms/d/1z9c0c1crbCO6avXaYwrHZjhw2ib3zgAmW4ijJDTo4DM"
|
href="https://docs.google.com/forms/d/1z9c0c1crbCO6avXaYwrHZjhw2ib3zgAmW4ijJDTo4DM"
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { dev } from '$app/environment';
|
import { dev } from '$app/environment';
|
||||||
|
import updateHistoryMarkdown from '../../../UPDATES.md?raw';
|
||||||
import { renderReleaseMarkdown } from '$lib/release-notes/markdown';
|
import { renderReleaseMarkdown } from '$lib/release-notes/markdown';
|
||||||
import { RELEASES_PAGE_URL } from '$lib/release-notes/release-note-service';
|
|
||||||
import { currentAppVersion, releaseNotes } from '$lib/release-notes/release-note-store';
|
import { currentAppVersion, releaseNotes } from '$lib/release-notes/release-note-store';
|
||||||
import { versionTagIsNewer } from '$lib/release-notes/version';
|
import { versionTagIsNewer } from '$lib/release-notes/version';
|
||||||
|
import { parseUpdateHistoryDocument } from '$lib/update-history-document.ts';
|
||||||
|
|
||||||
let previewUpdate = false;
|
let previewUpdate = false;
|
||||||
|
const updateHistoryDocument = parseUpdateHistoryDocument(updateHistoryMarkdown);
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
previewUpdate = dev && new URLSearchParams(window.location.search).get('previewUpdate') === '1';
|
previewUpdate = dev && new URLSearchParams(window.location.search).get('previewUpdate') === '1';
|
||||||
@@ -16,6 +18,20 @@
|
|||||||
$: latestRelease = $releaseNotes.releases[0] ?? null;
|
$: latestRelease = $releaseNotes.releases[0] ?? null;
|
||||||
$: updateAvailable = latestRelease !== null && (previewUpdate || versionTagIsNewer(latestRelease.tagName, currentAppVersion));
|
$: updateAvailable = latestRelease !== null && (previewUpdate || versionTagIsNewer(latestRelease.tagName, currentAppVersion));
|
||||||
|
|
||||||
|
function updatedDateTime(value: string | null): string {
|
||||||
|
if (!value) return '시각 미등록';
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
|
return new Intl.DateTimeFormat('ko-KR', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
function publishedDate(value: string): string {
|
function publishedDate(value: string): string {
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
if (Number.isNaN(date.getTime())) return value;
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
@@ -27,25 +43,22 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if $releaseNotes.status === 'available' && latestRelease}
|
<aside class:available={updateAvailable} class="update-notice" aria-live="polite">
|
||||||
<aside class:available={updateAvailable} class="update-notice" aria-live="polite">
|
|
||||||
<div class="notice-header">
|
<div class="notice-header">
|
||||||
<div class="version-summary">
|
<div class="version-summary">
|
||||||
<span class="label">현재 버전</span>
|
<span class="label">최근 업데이트</span>
|
||||||
<strong>v{currentAppVersion.replace(/^v/i, '')}</strong>
|
<strong><time datetime={updateHistoryDocument.updatedAt ?? undefined}>{updatedDateTime(updateHistoryDocument.updatedAt)}</time></strong>
|
||||||
{#if previewUpdate}
|
{#if previewUpdate}
|
||||||
<span class="status"><b>업데이트 알림 미리보기</b></span>
|
<span class="status"><b>업데이트 알림 미리보기</b></span>
|
||||||
{:else if updateAvailable}
|
{:else if updateAvailable}
|
||||||
<span class="status"><b>{latestRelease.tagName}</b> 업데이트를 사용할 수 있습니다.</span>
|
<span class="status"><b>{latestRelease.tagName}</b> 업데이트를 사용할 수 있습니다.</span>
|
||||||
{:else}
|
|
||||||
<span class="status">최신 버전입니다.</span>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a class="action" href={RELEASES_PAGE_URL} target="_blank" rel="noreferrer">업데이트 내역 확인 <span aria-hidden="true">→</span></a>
|
<a class="action" href="/updates">업데이트 내역 확인 <span aria-hidden="true">→</span></a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if updateAvailable}
|
{#if updateAvailable && latestRelease}
|
||||||
<section class="latest-note" aria-labelledby="latest-release-title">
|
<section class="latest-note" aria-labelledby="latest-release-title">
|
||||||
<header>
|
<header>
|
||||||
<div>
|
<div>
|
||||||
@@ -62,8 +75,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
</aside>
|
</aside>
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.update-notice { margin-bottom: 12px; border: 1px solid #2c3440; border-radius: 12px; background: #151a22; overflow: hidden; }
|
.update-notice { margin-bottom: 12px; border: 1px solid #2c3440; border-radius: 12px; background: #151a22; overflow: hidden; }
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { parseUpdateHistoryDocument } from '../update-history-document.ts';
|
||||||
|
|
||||||
|
test('업데이트 Markdown의 최근 업데이트 시각을 본문과 분리한다', () => {
|
||||||
|
const document = parseUpdateHistoryDocument('---\nupdatedAt: 2026-08-31T14:15:17+09:00\n---\n\n## 업데이트');
|
||||||
|
|
||||||
|
assert.equal(document.updatedAt, '2026-08-31T14:15:17+09:00');
|
||||||
|
assert.equal(document.body, '\n## 업데이트');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('메타데이터가 없는 Markdown도 그대로 표시한다', () => {
|
||||||
|
const markdown = '## 업데이트';
|
||||||
|
assert.deepEqual(parseUpdateHistoryDocument(markdown), { updatedAt: null, body: markdown });
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
export type UpdateHistoryDocument = {
|
||||||
|
updatedAt: string | null;
|
||||||
|
body: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseUpdateHistoryDocument(markdown: string): UpdateHistoryDocument {
|
||||||
|
const frontmatter = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
||||||
|
if (!frontmatter) return { updatedAt: null, body: markdown };
|
||||||
|
|
||||||
|
const updatedAt = frontmatter[1]
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.match(/^updatedAt:\s*(.+?)\s*$/)?.[1] ?? null)
|
||||||
|
.find((value): value is string => value !== null) ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
updatedAt,
|
||||||
|
body: markdown.slice(frontmatter[0].length)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@
|
|||||||
<a href="/combination" aria-current={$page.url.pathname.startsWith('/combination') ? 'page' : undefined}>조합 탐색</a>
|
<a href="/combination" aria-current={$page.url.pathname.startsWith('/combination') ? 'page' : undefined}>조합 탐색</a>
|
||||||
{#if showEventTab}<a href="/event" aria-current={$page.url.pathname.startsWith('/event') ? 'page' : undefined}>이벤트 Pt</a>{/if}
|
{#if showEventTab}<a href="/event" aria-current={$page.url.pathname.startsWith('/event') ? 'page' : undefined}>이벤트 Pt</a>{/if}
|
||||||
<a href="/data" aria-current={$page.url.pathname.startsWith('/data') ? 'page' : undefined}>사용자 데이터</a>
|
<a href="/data" aria-current={$page.url.pathname.startsWith('/data') ? 'page' : undefined}>사용자 데이터</a>
|
||||||
|
<a href="/updates" aria-current={$page.url.pathname.startsWith('/updates') ? 'page' : undefined}>업데이트 내역</a>
|
||||||
<a
|
<a
|
||||||
class="feedback-link"
|
class="feedback-link"
|
||||||
href="https://docs.google.com/forms/d/1z9c0c1crbCO6avXaYwrHZjhw2ib3zgAmW4ijJDTo4DM"
|
href="https://docs.google.com/forms/d/1z9c0c1crbCO6avXaYwrHZjhw2ib3zgAmW4ijJDTo4DM"
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import updateHistoryMarkdown from '../../../UPDATES.md?raw';
|
||||||
|
import { renderReleaseMarkdown } from '$lib/release-notes/markdown.ts';
|
||||||
|
import { parseUpdateHistoryDocument } from '$lib/update-history-document.ts';
|
||||||
|
|
||||||
|
const updateHistoryDocument = parseUpdateHistoryDocument(updateHistoryMarkdown);
|
||||||
|
const renderedUpdateHistory = renderReleaseMarkdown(updateHistoryDocument.body);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>업데이트 내역 · 홀로도리 계산기</title>
|
||||||
|
<meta name="description" content="홀로도리 계산기에 수시로 반영된 기능, 개선, 수정, 데이터 업데이트 내역" />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<header class="page-header">
|
||||||
|
<p class="eyebrow">CHANGELOG</p>
|
||||||
|
<h1>업데이트 내역</h1>
|
||||||
|
<p class="description">큰 버전 배포 단위가 아닌, 실제로 반영된 변경 사항을 순서대로 기록합니다.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<aside class="delivery-note">
|
||||||
|
<span aria-hidden="true">●</span>
|
||||||
|
<p><strong>온라인판</strong>은 변경 사항이 수시로 반영됩니다. <strong>오프라인판</strong>은 다시 내려받은 파일에 포함된 시점까지의 내역을 보여 줍니다.</p>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<article class="markdown-body">{@html renderedUpdateHistory}</article>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
main { width: min(980px, calc(100% - var(--page-gutter))); margin: 0 auto; padding: var(--page-content-top) 0 88px; }
|
||||||
|
.page-header { padding: 18px 0 var(--page-header-bottom); }
|
||||||
|
.eyebrow { margin: 0 0 10px; color: #ed8bb5; font-size: 13px; font-weight: 900; letter-spacing: .12em; }
|
||||||
|
h1 { margin: 0; color: #f3f5f8; font-size: var(--page-title-size); letter-spacing: -.045em; }
|
||||||
|
.description { margin: 13px 0 0; color: #9ba5b4; font-size: var(--page-description-size); line-height: 1.65; }
|
||||||
|
.delivery-note { display: flex; align-items: flex-start; gap: 11px; margin-bottom: 36px; padding: 15px 17px; border: 1px solid rgb(237 139 181 / 24%); border-radius: 12px; background: rgb(237 139 181 / 7%); }
|
||||||
|
.delivery-note > span { margin-top: 7px; color: #ed8bb5; font-size: 7px; }
|
||||||
|
.delivery-note p { margin: 0; color: #aeb6c2; font-size: 14px; line-height: 1.65; }
|
||||||
|
.delivery-note strong { color: #e9edf2; }
|
||||||
|
.markdown-body { position: relative; padding-left: 34px; }
|
||||||
|
.markdown-body::before { position: absolute; top: 9px; bottom: 0; left: 6px; width: 1px; background: #303947; content: ''; }
|
||||||
|
:global(.markdown-body h2) { position: relative; margin: 0 0 14px; padding-top: 2px; color: #cad0d9; font-size: 15px; font-weight: 850; }
|
||||||
|
:global(.markdown-body h2:not(:first-child)) { margin-top: 38px; }
|
||||||
|
:global(.markdown-body h2::before) { position: absolute; top: 6px; left: -34px; width: 13px; height: 13px; border: 3px solid #101319; border-radius: 50%; background: #ed8bb5; box-shadow: 0 0 0 1px rgb(237 139 181 / 45%); content: ''; }
|
||||||
|
:global(.markdown-body h3) { margin: 10px 0 0; padding: 19px 20px 0; border: 1px solid #2b3441; border-bottom: 0; border-radius: 12px 12px 0 0; color: #edf0f4; background: #151a22; font-size: 17px; letter-spacing: -.02em; }
|
||||||
|
:global(.markdown-body h3 + p) { margin: 0 0 10px; padding: 8px 20px 19px; border: 1px solid #2b3441; border-top: 0; border-radius: 0 0 12px 12px; color: #9ca6b4; background: #151a22; font-size: 14px; line-height: 1.65; }
|
||||||
|
:global(.markdown-body code) { margin-right: 7px; padding: 3px 7px; border: 1px solid rgb(237 139 181 / 32%); border-radius: 999px; color: #efa0c1; background: rgb(237 139 181 / 9%); font-family: inherit; font-size: 10px; font-weight: 900; }
|
||||||
|
:global(.markdown-body hr) { height: 1px; margin: 38px 0; border: 0; background: #2c3440; }
|
||||||
|
:global(.markdown-body ul), :global(.markdown-body ol) { color: #9ca6b4; line-height: 1.7; }
|
||||||
|
:global(.markdown-body a) { color: #f0a1c3; }
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.page-header { padding-top: 12px; }
|
||||||
|
.delivery-note { margin-bottom: 30px; }
|
||||||
|
.markdown-body { padding-left: 27px; }
|
||||||
|
:global(.markdown-body h2::before) { left: -27px; }
|
||||||
|
:global(.markdown-body h3) { padding: 17px 17px 0; }
|
||||||
|
:global(.markdown-body h3 + p) { padding: 8px 17px 17px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user