Files
tech-log-frontend/src/features/tech-log/adapters/static/public-query.ts
T
DongHyeonkaandClaude Opus 5 fd73bc88a1 fix: Decision 미리보기의 결정일 요구를 풀고, 화면 테스트가 실제 동작을 다시 말하게 한다
Decision 은 결정일이 없으면 미리보기가 열리지 않았다. 검증은 그것을 경고로만 다루므로
날짜 없이 게시할 수 있는데 렌더 모델이 필수로 요구했다 — 작성자는 "경고라면서 왜 안
되냐"를 만난다. 계약을 nullable 로 열고 화면이 "결정일 미정"이라고 말하게 한다.

한 칸의 실패가 화면을 통째로 날리지 않게 한다. `Promise.all([gateway.foo()])` 은 foo 가
거절하는 것만 잡는다 — 호출이 동기적으로 던지면 배열을 만드는 중에 터져 rejection
handler 를 지나지 못하고, 그러면 홈 focus 한 칸 때문에 대시보드 전체가 빈 화면이 된다.
프로젝트 편집도 같은 모양이라 함께 고친다.

`IntersectionObserver` 가 없는 환경을 견딘다. 목차는 픽스처 Case 하나에서만 쓰여 그런
환경을 만난 적이 없었는데, 모든 Case 가 목차를 받게 되면서 jsdom 에서 문서가 통째로
깨졌다. 없으면 "지금 읽는 절" 표시만 못 할 뿐이다.

픽스처의 최근 기록에서 릴리스를 뺀다. 서버의 `latestEntries` 는 공개 투영에서 고르므로
릴리스가 없고, 홈이 릴리스를 따로 읽어 합친다 — 픽스처가 넣으면 같은 릴리스가 두 번
나온다.

화면 테스트는 `test:unit` 이 아니라 `test:tech-log` 가 돌린다. 그것을 돌리지 않아 위
두 결함과, 라우트 두 개·`--body-copy`·Case 배치 통합·활동 링크 제거처럼 의도한 변경에
고정돼 있던 단언들이 23건 빨간 채로 여러 커밋을 지나갔다. 단언을 실제 동작으로 옮긴다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-24 18:03:11 +09:00

310 lines
9.7 KiB
TypeScript

import {
projects,
publicRecords,
releases,
type Project,
type ProjectActivity,
type ProjectDecision,
type PublicRecord,
type RecordKind,
type Release,
type HomeFocusItem,
} from "./public-content.ts";
import type {
LatestRecordEntry,
PublicContentQueries,
PublicTopic,
} from "../../application/ports/public-content-queries.ts";
export type RecordFilters = {
kind?: RecordKind;
topic?: string;
project?: string;
openQuestionsOnly?: boolean;
};
export type SearchablePublicEntity = {
contentType: RecordKind | "PROJECT" | "RELEASE";
title: string;
summary: string;
path: string;
topic?: string;
topics?: ReadonlyArray<string>;
project?: string;
publishedAt?: string;
};
function comparePublishedAt(
left: Pick<PublicRecord, "publishedAt">,
right: Pick<PublicRecord, "publishedAt">,
) {
return right.publishedAt.localeCompare(left.publishedAt);
}
export function listRecords(filters: RecordFilters = {}): PublicRecord[] {
const hasTopicFilter = filters.topic !== undefined;
const hasProjectFilter = filters.project !== undefined;
const requestedTopic = filters.topic?.trim().toLocaleLowerCase("ko-KR");
const requestedProject = filters.project
?.trim()
.toLocaleLowerCase("ko-KR");
return publicRecords
.filter((record) => !filters.kind || record.kind === filters.kind)
.filter(
(record) =>
!hasTopicFilter ||
record.topic.toLocaleLowerCase("ko-KR") === requestedTopic,
)
.filter(
(record) =>
!hasProjectFilter ||
record.projectSlug.toLocaleLowerCase("ko-KR") === requestedProject ||
record.projectTitle.toLocaleLowerCase("ko-KR") === requestedProject,
)
.filter(
(record) =>
!filters.openQuestionsOnly ||
(record.kind === "QUESTION" &&
record.questionStatus !== "RESOLVED" &&
record.questionStatus !== "ARCHIVED"),
)
.sort(comparePublishedAt);
}
export function getRecord<K extends RecordKind>(
kind: K,
slug: string,
): Extract<PublicRecord, { kind: K }> | undefined {
return publicRecords.find(
(record) => record.kind === kind && record.slug === slug,
) as Extract<PublicRecord, { kind: K }> | undefined;
}
export function getProject(slug: string): Project | undefined {
return projects.find((project) => project.slug === slug);
}
export function getRelease(version: string): Release | undefined {
return releases.find((release) => release.version === version);
}
export function getProjectRecords(projectSlug: string): PublicRecord[] {
if (!getProject(projectSlug)) return [];
return listRecords({ project: projectSlug });
}
export function getProjectDecisions(projectSlug: string): ProjectDecision[] {
return [...(getProject(projectSlug)?.decisions ?? [])];
}
export function getProjectActivity(projectSlug: string): ProjectActivity[] {
return [...(getProject(projectSlug)?.activity ?? [])];
}
/**
* 정적 카탈로그에는 주제 테이블이 없다 — 기록마다 붙은 주제 이름이 있을 뿐이다. 그것을 모아
* 세면 백엔드의 `listPublicTopics` 와 같은 모양이 되고, 이 어댑터의 목적(백엔드 없이 화면을
* 그린다)에도 맞는다.
*/
export function listTopics(): PublicTopic[] {
const counts = new Map<string, { name: string; slug: string; recordCount: number }>();
for (const record of listRecords()) {
if (!record.topic) continue;
const existing = counts.get(record.topicSlug);
if (existing) existing.recordCount += 1;
else counts.set(record.topicSlug, { name: record.topic, slug: record.topicSlug, recordCount: 1 });
}
return [...counts.values()]
.sort((left, right) => right.recordCount - left.recordCount || (left.name < right.name ? -1 : 1))
.map((entry) => Object.freeze(entry));
}
/**
* 픽스처판 "최근 기록". HTTP 어댑터가 서버에서 읽어 오는 것과 같은 의미를 픽스처에서 만든다 —
* 프로젝트 활동과 릴리스가 원천이다.
*/
export function getLatestEntries(): LatestRecordEntry[] {
const recordByPath = new Map(publicRecords.map((record) => [record.path, record]));
const activities = projects.flatMap((project) =>
project.activity.map((activity) => {
const record = recordByPath.get(activity.recordPath ?? activity.path);
const published = activity.type === "PUBLICATION" && record;
return {
id: activity.id,
entryType: (published ? record.kind : "PROJECT_ACTIVITY") as LatestRecordEntry["entryType"],
title: published ? record.title : activity.title,
summary: activity.summary,
path: activity.path,
publishedAt: activity.dateTime,
topic: record?.topic ?? project.topics[0] ?? "",
project: project.title,
};
}),
);
/*
릴리스는 넣지 않는다. 이 목록은 서버의 `latestEntries` 와 같은 의미여야 하고, 그쪽은 공개
투영에서 고르므로 릴리스가 없다 — 릴리스는 Publication 파이프라인을 거치지 않는다. 홈 화면이
릴리스를 따로 읽어 합치므로, 여기서도 넣으면 같은 릴리스가 두 번 나온다.
*/
return [...activities].sort((left, right) =>
right.publishedAt.localeCompare(left.publishedAt),
);
}
export function getHomeFocusItems(): HomeFocusItem[] {
const project = getProject("backend-skeleton");
const question = getRecord("QUESTION", "validate-edge-token-again");
const decision = getProjectDecisions("backend-skeleton").find(
(item) => item.id === "storage-port-unification",
);
if (!project || !question || !decision) {
throw new Error("Missing canonical entity required for the public home focus");
}
return [
{
key: "current",
label: "현재 작업",
title: project.title,
summary: project.summary,
details: [
{ label: "단계", value: project.stage },
{ label: "현재 목표", value: project.currentGoal },
{ label: "다음 작업", value: project.nextStep },
],
targetPath: `/projects/${project.slug}`,
},
{
key: "question",
label: "열린 질문",
title: question.title,
summary: question.summary,
details: [
{ label: "확인한 사실", value: question.facts[0] ?? "" },
{ label: "남은 미지수", value: question.unknowns[0] ?? "" },
{ label: "다음 검증", value: question.nextValidation },
],
targetPath: question.path,
},
{
key: "decision",
label: "최근 결정",
title: decision.statement,
summary: decision.rationale,
details: [
{ label: "영향", value: decision.consequences[0] ?? "" },
{ label: "근거", value: decision.evidence[0]?.title ?? "" },
],
targetPath: `/projects/${project.slug}/decisions#${decision.id}`,
},
];
}
function recordSearchEntity(record: PublicRecord): SearchablePublicEntity {
return {
contentType: record.kind,
title: record.title,
summary: record.summary,
path: record.path,
topic: record.topic,
project: record.projectTitle,
publishedAt: record.publishedAt,
};
}
function projectSearchEntity(project: Project): SearchablePublicEntity {
return {
contentType: "PROJECT",
title: project.title,
summary: project.summary,
path: `/projects/${project.slug}`,
topics: project.topics,
project: project.title,
};
}
function releaseSearchEntity(release: Release): SearchablePublicEntity {
return {
contentType: "RELEASE",
title: release.title,
summary: release.summary,
path: release.path,
topic: "TechLog",
project: "TechLog",
publishedAt: release.publishedAt,
};
}
export function searchPublicContent(query: string): SearchablePublicEntity[] {
const normalizedQuery = query.trim().toLocaleLowerCase("ko-KR");
const entities = [
...publicRecords.map(recordSearchEntity),
...projects.map(projectSearchEntity),
...releases.map(releaseSearchEntity),
];
if (!normalizedQuery) return entities;
return entities.filter((entity) =>
[
entity.title,
entity.summary,
entity.topic,
entity.project,
...(entity.topics ?? []),
]
.filter((value): value is string => Boolean(value))
.some((value) =>
value.toLocaleLowerCase("ko-KR").includes(normalizedQuery),
),
);
}
/**
* The MOCK source. The functions above stay synchronous — they filter arrays
* that are already in the bundle, and making them async would only add a
* microtask to every fixture test — so the port's async shape is applied here,
* at the adapter boundary, rather than pushed into the query implementations.
*
* `async` rather than `Promise.resolve(...)` so a throw from one of these
* becomes a rejected promise like the HTTP adapter's would, instead of
* escaping synchronously past the caller's await.
*/
export const publicContentQueries = Object.freeze({
async listRecords(filters?: RecordFilters) {
return listRecords(filters);
},
async getRecord<K extends RecordKind>(kind: K, slug: string) {
return getRecord(kind, slug);
},
async getProject(slug: string) {
return getProject(slug);
},
async getRelease(version: string) {
return getRelease(version);
},
async getProjectRecords(projectSlug: string) {
return getProjectRecords(projectSlug);
},
async getProjectDecisions(projectSlug: string) {
return getProjectDecisions(projectSlug);
},
async getProjectActivity(projectSlug: string) {
return getProjectActivity(projectSlug);
},
async listTopics() {
return listTopics();
},
async getLatestEntries() {
return getLatestEntries();
},
async getHomeFocusItems() {
return getHomeFocusItems();
},
async searchPublicContent(query: string) {
return searchPublicContent(query);
},
}) satisfies PublicContentQueries;