Skip to content

Commit 60503a8

Browse files
committed
프로필/프로필 편집 아바타를 공용 background avatar 컴포넌트로 교체
1 parent 5eaa775 commit 60503a8

12 files changed

Lines changed: 315 additions & 28 deletions

File tree

AGENTS.md

Lines changed: 4 additions & 3 deletions
Large diffs are not rendered by default.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
"use client";
2+
3+
interface AvatarProps {
4+
src: string;
5+
className: string;
6+
size?: number;
7+
}
8+
9+
export function Avatar({ src, className, size }: AvatarProps) {
10+
return (
11+
<span
12+
aria-hidden="true"
13+
className={className}
14+
style={{
15+
...(size ? { width: size, height: size } : {}),
16+
display: "block",
17+
backgroundImage: `url("${src}")`,
18+
backgroundSize: "cover",
19+
backgroundPosition: "center",
20+
}}
21+
/>
22+
);
23+
}

apps/web/src/components/screens/profile-edit.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { localized } from "@/lib/i18n";
77
import { useAppNav } from "@/lib/nav";
88
import { useAppState } from "@/components/app-shell";
99
import { UI, Icon } from "./_ui";
10+
import { Avatar } from "./avatar";
1011
import type { Theme, User } from "@/domain/types";
1112

1213
interface Props {
@@ -60,7 +61,7 @@ export function ProfileEditView({ user, themes }: Props) {
6061

6162
<div style={{ display: "flex", justifyContent: "center", padding: "14px 0 8px" }}>
6263
<div style={{ position: "relative" }}>
63-
<img className="avatar-lg" src={user.avatarUrl} alt="" style={{ width: 86, height: 86 }} />
64+
<Avatar className="avatar-lg" src={user.avatarUrl} size={86} />
6465
<button className="avatar-cam">
6566
<Icon.camera />
6667
</button>

apps/web/src/components/screens/profile.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { localized, type Lang } from "@/lib/i18n";
66
import { useAppNav } from "@/lib/nav";
77
import { useAppState } from "@/components/app-shell";
88
import { UI, Icon } from "./_ui";
9+
import { Avatar } from "./avatar";
910
import type { Review, Spot, Theme, User } from "@/domain/types";
1011

1112
const { Signal, ThemeHueBg, Placeholder } = UI;
@@ -182,7 +183,7 @@ function ProfileStats({
182183

183184
<div style={{ padding: "6px 20px 0" }}>
184185
<div className="profile-head">
185-
<img className="avatar-lg" src={user.avatarUrl} alt="" />
186+
<Avatar className="avatar-lg" src={user.avatarUrl} />
186187
<div style={{ flex: 1, minWidth: 0 }}>
187188
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
188189
<span className="serif" style={{ fontSize: 24, lineHeight: 1.15 }}>
@@ -318,7 +319,7 @@ function ProfileList({
318319

319320
<div style={{ padding: "6px 20px 0" }}>
320321
<button className="profile-head-compact" onClick={() => nav("profileEdit")}>
321-
<img className="avatar-md" src={user.avatarUrl} alt="" />
322+
<Avatar className="avatar-md" src={user.avatarUrl} />
322323
<div style={{ flex: 1, minWidth: 0, textAlign: "left" }}>
323324
<div style={{ fontSize: 17, fontWeight: 700, letterSpacing: "-0.01em" }}>{localized(user.name, lang)}</div>
324325
<div style={{ marginTop: 4 }}>

apps/web/src/data/live/agent-wiring.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,18 @@ describe("LiveRepository.getCourse — 코스 정리 에이전트(planItinerary)
5959
for (const item of out!.items) expect(validRefs.has(item.refId)).toBe(true);
6060
});
6161

62+
it("코스 조건 옵션을 에이전트 도구 경로까지 유지한다", async () => {
63+
const live = new LiveRepository();
64+
const mock = new MockRepository();
65+
const themeId = await someThemeWithCourse(mock);
66+
67+
const out = await live.getCourse(themeId, { days: 1, pace: "calm", transport: "walk" });
68+
69+
expect(out).not.toBeNull();
70+
expect(out!.dayCount).toBe(1);
71+
expect(out!.items.every((item) => item.day === 1)).toBe(true);
72+
});
73+
6274
it("없는 테마는 null 을 반환한다", async () => {
6375
const live = new LiveRepository();
6476
expect(await live.getCourse("__nope__")).toBeNull();

apps/web/src/data/live/repository.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import { getItemDetail, listGangwonItems } from "@/server/public-api/tourapi";
3737
import { REGIONS, type RegionKey } from "@/server/public-api/regions";
3838
import { apiCache } from "@/server/cache";
3939
import { createLogger } from "@/server/log";
40+
import { buildTravelTimeLookup } from "@/server/routing/travel-time";
4041
import { L, type LocalizedText } from "@/lib/i18n";
4142
import { buildTools } from "@/server/agent/tools";
4243
import { planCourse, planItinerary } from "@/server/agent/planner";
@@ -78,6 +79,12 @@ function withSpotMappingCoords(spot: Spot): Spot {
7879
return mapping ? { ...spot, lat: mapping.lat, lon: mapping.lon } : spot;
7980
}
8081

82+
function routeCoordsFromSpots(spots: Spot[]) {
83+
return spots
84+
.filter((spot): spot is Spot & { lat: number; lon: number } => typeof spot.lat === "number" && typeof spot.lon === "number")
85+
.map((spot) => ({ lat: spot.lat, lon: spot.lon, region: spot.region.ko }));
86+
}
87+
8188
/**
8289
* 에이전트 trace 의 reorder_by_congestion 결과들을 base 코스에 반영해 최종 Course 를 만든다(순수).
8390
* planItinerary 에 주입 — LLM 이 "어느 날을 정리할지" 정하면, 여기서 그 도구 결과를 코스에 적용한다.
@@ -211,7 +218,7 @@ export class LiveRepository extends MockRepository {
211218
if (!base) return base;
212219

213220
try {
214-
const ctx = await this.buildAgentContext();
221+
const ctx = await this.buildAgentContext(options);
215222
const result = await planItinerary(
216223
{ intent: `테마 ${themeId} 코스를 현재 혼잡 기준으로 정리`, lang: "ko", themeId, at: nowKst() },
217224
{
@@ -264,6 +271,10 @@ export class LiveRepository extends MockRepository {
264271
}),
265272
);
266273
const alternativesBySpot = Object.fromEntries(altEntries);
274+
const travelSpots = [...spots, ...altEntries.flatMap(([, alternatives]) => alternatives)];
275+
const travelTimeLookup = await buildTravelTimeLookup(routeCoordsFromSpots(travelSpots), {
276+
mode: options?.transport ?? "car",
277+
});
267278

268279
return composeCourse({
269280
theme,
@@ -272,7 +283,7 @@ export class LiveRepository extends MockRepository {
272283
eats,
273284
stays,
274285
alternativesBySpot,
275-
}, options);
286+
}, { ...options, travelTimeLookup });
276287
}
277288

278289
private async getDbSpotProfilesForTheme(themeId: string): Promise<Spot[]> {
@@ -461,7 +472,7 @@ export class LiveRepository extends MockRepository {
461472
* 정적 데이터(테마/코스/스팟 메타)는 mock(메모리)에서, 동적 데이터(날씨/대기질/POI)는
462473
* 공공 API 클라이언트 + 캐시에서 가져온다. 도구 코드는 이 포트만 보므로 출처를 모른다.
463474
*/
464-
private async buildAgentContext(): Promise<ToolContext> {
475+
private async buildAgentContext(options?: ComposeCourseOptions): Promise<ToolContext> {
465476
const [themes, baseSpots] = await Promise.all([super.listThemes(), super.listSpots()]);
466477

467478
// 매핑이 있는 스팟만 도구의 행동 대상으로 노출(좌표·권역·성격 필요).
@@ -475,7 +486,7 @@ export class LiveRepository extends MockRepository {
475486
return {
476487
themes,
477488
spots,
478-
getCourse: (themeId) => this.composeSeedCourse(themeId),
489+
getCourse: (themeId) => this.composeSeedCourse(themeId, options),
479490
weather: async (meta) => {
480491
const w = await cached(`wx:${meta.id}`, TTL_MS, () => getWeatherByCoords(meta.lat, meta.lon));
481492
return { tempC: w.tempC, pop: w.pop, windMs: w.windMs, pty: w.pty, desc: w.desc };

apps/web/src/domain/course-compose.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest";
44
import { L } from "@/lib/i18n";
55
import type { Course, Eat, Spot, Stay, Theme } from "./types";
66
import { composeCourse } from "./course-compose";
7+
import type { TravelTimeLookup } from "./travel-time";
78

89
const theme: Theme = {
910
id: "east-sea-sunrise",
@@ -99,6 +100,30 @@ describe("composeCourse", () => {
99100
expect(course!.items.find((it) => it.kind === "eat")?.refId).toBe("e-gangneung");
100101
});
101102

103+
it("baseCourse 템플릿이 있어도 days 조건으로 일정을 줄인다", () => {
104+
const twoDayBase: Course = {
105+
...baseCourse,
106+
dayCount: 2,
107+
items: [
108+
...baseCourse.items,
109+
{ kind: "spot", day: 2, time: "10:00", refId: "dongmyeong-port", durationMin: 60 },
110+
],
111+
};
112+
const course = composeCourse(
113+
{
114+
theme,
115+
baseCourse: twoDayBase,
116+
spots: [busyBeach, quietBeach, port],
117+
eats,
118+
stays,
119+
},
120+
{ days: 1 },
121+
);
122+
123+
expect(course!.dayCount).toBe(1);
124+
expect(course!.items.every((it) => it.day === 1)).toBe(true);
125+
});
126+
102127
it("대체 후보 점수가 높아도 원래 장소에서 너무 멀면 가까운 후보를 우선한다", () => {
103128
const course = composeCourse({
104129
theme,
@@ -135,6 +160,38 @@ describe("composeCourse", () => {
135160
expect(course!.items.find((it) => it.time === "10:00")?.refId).toBe("near-route-beach");
136161
});
137162

163+
it("주입된 이동시간 포트가 대체 후보 랭킹에 반영된다", () => {
164+
const routeBase: Course = {
165+
...baseCourse,
166+
items: [
167+
{ kind: "spot", day: 1, time: "10:00", refId: "anmok-beach", durationMin: 60 },
168+
{ kind: "spot", day: 1, time: "14:00", refId: "dongmyeong-port", durationMin: 60 },
169+
],
170+
};
171+
const normallyGood = spot("api-slow-beach", "강릉", "calm", 90, ["바다", "한적"], { lat: 37.781, lon: 128.95 });
172+
const normallyLower = spot("api-fast-beach", "강릉", "calm", 86, ["바다", "한적"], { lat: 37.79, lon: 128.951 });
173+
const apiLookup: TravelTimeLookup = {
174+
estimate(a, b, mode = "car") {
175+
const touchesSlow = [a, b].some((c) => Math.abs(c.lat - normallyGood.lat!) < 0.001);
176+
return { distanceKm: touchesSlow ? 8 : 2, driveMinutes: touchesSlow ? 130 : 8, mode, source: "api" };
177+
},
178+
};
179+
180+
const course = composeCourse(
181+
{
182+
theme,
183+
baseCourse: routeBase,
184+
spots: [busyBeach, normallyGood, normallyLower, port],
185+
eats,
186+
stays,
187+
alternativesBySpot: { "anmok-beach": [normallyGood, normallyLower] },
188+
},
189+
{ travelTimeLookup: apiLookup },
190+
);
191+
192+
expect(course!.items.find((it) => it.time === "10:00")?.refId).toBe("api-fast-beach");
193+
});
194+
138195
it("baseCourse 가 없어도 후보 풀에서 간단한 코스를 생성한다", () => {
139196
const course = composeCourse({
140197
theme,

apps/web/src/domain/course-compose.ts

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,14 @@
1414

1515
import type { Course, CourseItem, Eat, Spot, Stay, Theme } from "./types";
1616
import { L, localized } from "@/lib/i18n";
17-
import { estimateDrive, routePenaltyPoints, type RouteContext, type TravelMode } from "./travel-time";
17+
import {
18+
approxTravelTimeLookup,
19+
estimateDrive,
20+
routePenaltyPoints,
21+
type RouteContext,
22+
type TravelMode,
23+
type TravelTimeLookup,
24+
} from "./travel-time";
1825

1926
export interface ComposeCourseInput {
2027
theme: Theme;
@@ -41,6 +48,8 @@ export interface ComposeCourseOptions {
4148
startRegion?: string;
4249
/** 이동수단. car=자가용/렌터카, transit=대중교통, walk=도보 중심. */
4350
transport?: TravelMode;
51+
/** 서버 길찾기 API 또는 근사 계산으로 구성한 이동시간 조회 포트. */
52+
travelTimeLookup?: TravelTimeLookup;
4453
/** 하루에 배치할 최대 스팟 수. 기본 3. */
4554
maxSpotsPerDay?: number;
4655
}
@@ -52,6 +61,7 @@ interface NormalizedComposeOptions {
5261
companion?: string;
5362
startRegion?: string;
5463
transport: TravelMode;
64+
travelTimeLookup: TravelTimeLookup;
5565
maxSpotsPerDay: number;
5666
}
5767

@@ -163,11 +173,16 @@ function routePenalty(original: Spot, candidate: Spot, context: RouteContext): n
163173
return routePenaltyPoints(originalCoord, candidateCoord, context);
164174
}
165175

166-
function routeLegPenalty(prev: Spot | undefined, candidate: Spot, mode: TravelMode): number {
176+
function routeLegPenalty(
177+
prev: Spot | undefined,
178+
candidate: Spot,
179+
mode: TravelMode,
180+
lookup: TravelTimeLookup,
181+
): number {
167182
const prevCoord = toRouteCoord(prev);
168183
const candidateCoord = toRouteCoord(candidate);
169184
if (!prevCoord || !candidateCoord) return 0;
170-
const drive = estimateDrive(prevCoord, candidateCoord, mode);
185+
const drive = estimateDrive(prevCoord, candidateCoord, mode, lookup);
171186
if (!drive || drive.driveMinutes <= 45) return 0;
172187
return Math.min(30, (drive.driveMinutes - 45) * 0.35);
173188
}
@@ -265,7 +280,9 @@ function pickScratchSpots(input: ComposeCourseInput, dayCount: number, options:
265280
const bestIndex = remaining
266281
.map((spot, index) => ({
267282
index,
268-
score: scoreSpot(input.theme, spot, undefined, options) - routeLegPenalty(prev, spot, options.transport),
283+
score:
284+
scoreSpot(input.theme, spot, undefined, options) -
285+
routeLegPenalty(prev, spot, options.transport, options.travelTimeLookup),
269286
}))
270287
.sort((a, b) => b.score - a.score)[0]?.index;
271288
if (bestIndex === undefined) break;
@@ -293,9 +310,11 @@ function note(changedSpotIds: string[], changedCommerce: boolean): Course["altNo
293310

294311
function composeFromTemplate(input: ComposeCourseInput, options: NormalizedComposeOptions): Course {
295312
const base = input.baseCourse as Course;
313+
const targetDayCount = options.days ?? base.dayCount;
314+
const templateItems = base.items.filter((it) => it.day <= targetDayCount);
296315
const spotsById = new Map(input.spots.map((s) => [s.id, s]));
297316
const baseSpotItemsByDay = new Map<number, CourseItem[]>();
298-
for (const it of base.items) {
317+
for (const it of templateItems) {
299318
if (it.kind !== "spot") continue;
300319
baseSpotItemsByDay.set(it.day, [...(baseSpotItemsByDay.get(it.day) ?? []), it]);
301320
}
@@ -306,7 +325,7 @@ function composeFromTemplate(input: ComposeCourseInput, options: NormalizedCompo
306325
let changedCommerce = false;
307326

308327
const spotRegionByDay = new Map<number, string>();
309-
const firstPass: CourseItem[] = base.items.map((it) => {
328+
const firstPass: CourseItem[] = templateItems.map((it) => {
310329
if (it.kind !== "spot") return { ...it };
311330
const original = spotsById.get(it.refId);
312331
const daySpotItems = baseSpotItemsByDay.get(it.day) ?? [];
@@ -319,7 +338,7 @@ function composeFromTemplate(input: ComposeCourseInput, options: NormalizedCompo
319338
input.alternativesBySpot?.[it.refId] ?? [],
320339
usedSpots,
321340
options,
322-
{ prev, next, mode: options.transport },
341+
{ prev, next, mode: options.transport, lookup: options.travelTimeLookup },
323342
);
324343
if (!picked) return { ...it };
325344
usedSpots.add(picked.id);
@@ -350,6 +369,7 @@ function composeFromTemplate(input: ComposeCourseInput, options: NormalizedCompo
350369

351370
return {
352371
...base,
372+
dayCount: targetDayCount,
353373
altNote: note(changedSpotIds, changedCommerce) ?? base.altNote,
354374
items: sortItems(items),
355375
};
@@ -401,6 +421,7 @@ export function composeCourse(input: ComposeCourseInput, options: ComposeCourseO
401421
companion: options.companion,
402422
startRegion: options.startRegion,
403423
transport: options.transport ?? "car",
424+
travelTimeLookup: options.travelTimeLookup ?? approxTravelTimeLookup,
404425
maxSpotsPerDay: options.maxSpotsPerDay ?? spotsPerDayForPace(options.pace),
405426
};
406427

0 commit comments

Comments
 (0)