Skip to content
Merged
6 changes: 6 additions & 0 deletions apps/web/content/SEOJing/presentation-scale-stabilization.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ description: 모바일 Safari에서 100vh가 화면을 넘치는 이유, vh/svh/
<strong>브라우저 UI가 나타나고 사라질 때마다 값이 바뀐다.</strong>
</Paragraph>

<ArticleImage
src="/images/content/seojing/presentation-scale-stabilization/viewport-units-comparison-01.svg"
alt="모바일 브라우저 주소창과 하단 바가 보이는 상태에서 100vh는 화면 밖으로 넘칠 수 있고 svh는 최소 높이, dvh는 현재 보이는 높이를 기준으로 맞춰지는 비교 다이어그램"
caption="전체화면 프레젠테이션 모드처럼 브라우저 UI 변화에 맞춰야 하는 영역은 100vh보다 100dvh가 안전하다."
/>

<Subtitle level={3}>어떤 단위를 써야 하는가</Subtitle>

<Paragraph>
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 8 additions & 3 deletions apps/web/src/app/blog/[...slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import { RecentlyRead } from "@/widgets/recently-read/RecentlyRead";
import { PostExplorer } from "@/widgets/post-explorer/PostExplorer";
import { ArticleToolbar } from "@/widgets/article-toolbar/ArticleToolbar";
import { ArticleAnalytics } from "@/widgets/article-analytics";
import { PostQaPanel } from "@/widgets/post-qa";
import { PostQaPanel, SectionQaPrompts } from "@/widgets/post-qa";
import { BlogAudioPlayer } from "@/widgets/blog-audio-player/BlogAudioPlayer";
import type { Metadata } from "vinext/shims/metadata";
import {
buildArticleMetadata,
Expand Down Expand Up @@ -86,14 +87,18 @@ export default async function BlogPostPage({ params }: BlogPostPageProps) {
slug={slug.join("/")}
title={content.frontmatter.title}
/>
<div data-article-content>
<div className="relative">
<ArticleHeader
title={content.frontmatter.title}
date={content.frontmatter.date}
tags={content.frontmatter.tags}
readingTime={calculateReadingTime(content.source)}
/>
<MDXContent components={mdxComponents as MDXComponents} />
<BlogAudioPlayer slug={slug.join("/")} />
<div data-article-content>
<MDXContent components={mdxComponents as MDXComponents} />
</div>
<SectionQaPrompts slug={slug.join("/")} />
</div>
<PostQaPanel slug={slug.join("/")} title={content.frontmatter.title} />
<ArticleToolbar
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { render, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { useArticleAnalytics } from "./useArticleAnalytics";

class MockIntersectionObserver {
observe = vi.fn();
disconnect = vi.fn();
unobserve = vi.fn();
}

function AnalyticsHarness() {
useArticleAnalytics({ slug: "study/backend/day1", title: "Day 1" });
return (
<article data-article-content>
<h2>요청 흐름</h2>
<p>본문</p>
</article>
);
}

describe("useArticleAnalytics TTS bridge", () => {
beforeEach(() => {
window.sessionStorage.clear();
vi.stubGlobal("IntersectionObserver", MockIntersectionObserver);
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(null, { status: 204 })),
);
Object.defineProperty(window.navigator, "sendBeacon", {
configurable: true,
value: undefined,
});
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: vi.fn(() => ({ matches: false })),
});
});

afterEach(() => {
vi.unstubAllGlobals();
window.sessionStorage.clear();
});

it("forwards privacy-safe TTS player events into the article analytics endpoint", async () => {
render(<AnalyticsHarness />);

await waitFor(() => expect(fetch).toHaveBeenCalled());
vi.mocked(fetch).mockClear();

window.dispatchEvent(
new CustomEvent("seojing:tts-interaction", {
detail: {
action: "pause",
artifact_id: "study__backend__day1__section-001",
artifact_kind: "section",
section_id: "요청-흐름",
section_heading: "요청 흐름",
playback_rate: 1.8,
audio_status: "ready",
available_artifact_kinds: ["summary-2m", "core-5m", "section"],
section_artifact_count: 1,
duration_seconds_bucket: "60-179",
position_seconds_bucket: "30-59",
progress_percent_bucket: "25-49",
},
}),
);

await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
const call = vi.mocked(fetch).mock.calls[0];
expect(call).toBeDefined();
const init = call?.[1];
const body = JSON.parse(String(init?.body));

expect(body.events[0]).toMatchObject({
event_type: "tts_interaction",
content: {
content_slug: "study/backend/day1",
content_kind: "study_post",
section_id: "요청-흐름",
section_heading: "요청 흐름",
},
event: {
action: "pause",
artifact_id: "study__backend__day1__section-001",
artifact_kind: "section",
playback_rate: 1.8,
audio_status: "ready",
available_artifact_kinds: ["summary-2m", "core-5m", "section"],
section_artifact_count: 1,
duration_seconds_bucket: "60-179",
position_seconds_bucket: "30-59",
progress_percent_bucket: "25-49",
},
});
expect(JSON.stringify(body)).not.toContain("raw");
});
});
105 changes: 104 additions & 1 deletion apps/web/src/widgets/article-analytics/useArticleAnalytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,27 @@ declare global {
action: "answer_shown" | "insufficient_context" | "invalid_request";
question_length_bucket: "1-40" | "41-120" | "121+";
}>;
"seojing:tts-interaction": CustomEvent<{
action:
| "manifest_loaded"
| "artifact_select"
| "play"
| "pause"
| "ended"
| "speed_change";
slug?: string;
artifact_id?: string;
artifact_kind?: "summary-2m" | "core-5m" | "section";
section_id?: string;
section_heading?: string;
playback_rate?: number;
audio_status?: string;
available_artifact_kinds?: string[];
section_artifact_count?: number;
duration_seconds_bucket?: string;
position_seconds_bucket?: string;
progress_percent_bucket?: string;
}>;
}
}

Expand Down Expand Up @@ -141,6 +162,33 @@ function codeBlockId(block: Element | null, contentSlug: string) {
return `code_${contentSlug}_${index + 1}`.replace(/[^a-zA-Z0-9_-]/g, "_");
}

function safeShortString(value: unknown) {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed.slice(0, 120) : undefined;
}

function safePlaybackRate(value: unknown) {
return typeof value === "number" && Number.isFinite(value)
? Math.round(value * 100) / 100
: undefined;
}

function safeCount(value: unknown) {
return typeof value === "number" && Number.isFinite(value) && value >= 0
? Math.floor(value)
: undefined;
}

function safeStringArray(value: unknown) {
if (!Array.isArray(value)) return undefined;
const safeValues = value
.filter((item): item is string => typeof item === "string")
.map((item) => item.slice(0, 40))
.slice(0, 12);
return safeValues.length > 0 ? safeValues : undefined;
}

export function useArticleAnalytics({
slug,
title,
Expand Down Expand Up @@ -170,7 +218,9 @@ export function useArticleAnalytics({

useEffect(() => {
const storage = safeSessionStorage();
const dnt = navigator.doNotTrack ?? window.doNotTrack;
const dnt =
(navigator as Navigator & { doNotTrack?: string }).doNotTrack ??
(window as Window & { doNotTrack?: string }).doNotTrack;
if (!storage || isAnalyticsDisabled(storage, dnt)) {
if (storage) clearAnalyticsSession(storage);
return;
Expand Down Expand Up @@ -323,6 +373,54 @@ export function useArticleAnalytics({
});
};

const handleTtsInteraction = (event: Event) => {
if (!(event instanceof CustomEvent)) return;
const detail = event.detail ?? {};
const validAction = [
"manifest_loaded",
"artifact_select",
"play",
"pause",
"ended",
"speed_change",
].includes(detail.action);
const validKind =
detail.artifact_kind === undefined ||
["summary-2m", "core-5m", "section"].includes(detail.artifact_kind);
if (!validAction || !validKind) return;

emit(
"tts_interaction",
{
action: detail.action,
artifact_id: safeShortString(detail.artifact_id),
artifact_kind: detail.artifact_kind,
playback_rate: safePlaybackRate(detail.playback_rate),
audio_status: safeShortString(detail.audio_status),
available_artifact_kinds: safeStringArray(
detail.available_artifact_kinds,
),
section_artifact_count: safeCount(detail.section_artifact_count),
duration_seconds_bucket: safeShortString(
detail.duration_seconds_bucket,
),
position_seconds_bucket: safeShortString(
detail.position_seconds_bucket,
),
progress_percent_bucket: safeShortString(
detail.progress_percent_bucket,
),
},
typeof detail.section_id === "string"
? {
id: detail.section_id,
heading:
safeShortString(detail.section_heading) ?? detail.section_id,
}
: undefined,
);
};

const handleClick = (event: MouseEvent) => {
const target = event.target instanceof Element ? event.target : null;
const link = target?.closest<HTMLAnchorElement>(
Expand All @@ -344,6 +442,7 @@ export function useArticleAnalytics({
window.addEventListener("scroll", handleScroll, { passive: true });
window.addEventListener("seojing:code-copy", handleCodeCopy);
window.addEventListener("seojing:qa-interaction", handleQaInteraction);
window.addEventListener("seojing:tts-interaction", handleTtsInteraction);
article.addEventListener("click", handleClick);
window.addEventListener("pagehide", handlePageHide, { once: true });
handleScroll();
Expand All @@ -352,6 +451,10 @@ export function useArticleAnalytics({
window.removeEventListener("scroll", handleScroll);
window.removeEventListener("seojing:code-copy", handleCodeCopy);
window.removeEventListener("seojing:qa-interaction", handleQaInteraction);
window.removeEventListener(
"seojing:tts-interaction",
handleTtsInteraction,
);
article.removeEventListener("click", handleClick);
window.removeEventListener("pagehide", handlePageHide);
window.clearInterval(heartbeat);
Expand Down
Loading
Loading