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
310 lines
9.7 KiB
TypeScript
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;
|