feat(supporters): 주변 안내 축제를 회복 일정의 체류 기간 기준으로 먼저 보여 준다

주변 안내(/stay·/en/stay)의 축제 목록이 계절 묶음만 있고, 회복 일정 플래너에서 고른
기간과 이어지지 않았다(haewon 2026-09-16). 플래너가 체류 기간(도착일~출국 가능일)을
이 기기의 localStorage 에 남기고, 주변 안내는 그 기간과 겹치는 행사를 위로 올린다.

- Planner.astro: 결과를 그릴 때 체류 기간만 저장(시술·수술일은 저장하지 않음, 서버로도
  가지 않음). 행사 섹션에 "주변 안내에서 이 기간의 행사 전체 보기" 링크(/stay#plan:from..to)
- stay.astro·en/stay.astro: 카드에 data-start·data-end·data-km, "체류 기간" 칩(기본 숨김),
  기간 안내 문단. 스크립트가 URL 해시 또는 localStorage 에서 기간을 읽어 겹치는 카드를
  맨 앞으로 옮기고 칩을 켠다. 기간이 짧은 행사가 앞, 90일을 넘는 상시 행사는 뒤('상시 진행')
- 기간이 없거나 겹침이 없으면 화면은 이전과 같다. 저장소를 못 쓰는 브라우저는 조용히 건너뛴다
- global.css: 겹치는 카드 테두리·오른쪽 배지, 숨긴 칩
- run.mjs: planStrings.json 을 코드 갱신 목록에 넣는다. 기존 병원 폴더에 옛 문구 파일이
  남아 링크 글자가 비어 나온 것을 캡처로 발견했다

검증: 원진 빌드 게이트 통과, plan.test 14건 통과, 로컬 미리보기(Playwright)로
플래너 저장→주변 안내 정렬(20/24건, 짧은 행사 먼저)·해시 진입·기간 없음·영문·모바일 확인.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Haewon Kam 2026-09-16 09:47:01 +09:00
parent 98a3e41837
commit 796fa56106
11 changed files with 275 additions and 43 deletions

View File

@ -243,7 +243,8 @@ const originLabel = (PL as any).origin?.label?.[lang] ?? '';
const from = firstL3 && firstL3 <= f.end ? fill(t.fest_from, { d: fmtDate(firstL3 < f.start ? f.start : firstL3) }) : t.fest_notYet;
return `<div class="card fest-card" id="fest-${esc(f.id)}">${f.image ? `<img src="${esc(f.image)}" alt="${esc(f.title)}" loading="lazy" onerror="this.remove()" />` : ''}<div class="cat">${esc(fill(t.fest_when, { a: f.start.slice(5).replace('-', '.'), b: f.end.slice(5).replace('-', '.') }))} · ${esc(f.travelLabel ?? fill(t.km, { km: f.distanceKm }))}</div><h3>${esc(f.title)}</h3><p>${esc(f.blurb)}</p><p class="fine"><span class="tag ${firstL3 && firstL3 <= f.end ? 'l3' : 'l0'}">${esc(from)}</span> ${f.url ? `<a href="${esc(f.url)}" rel="noopener" target="_blank">${esc(t.readMoreShort)}</a> · ` : ''}<a href="${mapUrl(f.title)}" rel="noopener" target="_blank">${esc(t.map)}</a></p></div>`;
}).join('');
const festSec = `<section class="section light"><div class="wrap-wide">${secHead('Events', t.h_fest, t.sub_fest)}${festCards ? `<div class="grid">${festCards}</div>` : `<p class="sub" style="color:var(--slate-500)">${esc(t.fest_none)}</p>`}${tourDate ? `<p class="fine" style="margin-top:1rem">${esc(fill(t.source_tour, { date: tourDate }))}</p>` : ''}</div></section>`;
const stayLink = `<p class="fine" style="margin-top:1rem"><a href="${stayHref(plan)}" data-ga="stay-events">${esc(t.fest_stay_link)} →</a></p>`;
const festSec = `<section class="section light"><div class="wrap-wide">${secHead('Events', t.h_fest, t.sub_fest)}${festCards ? `<div class="grid">${festCards}</div>` : `<p class="sub" style="color:var(--slate-500)">${esc(t.fest_none)}</p>`}${stayLink}${tourDate ? `<p class="fine" style="margin-top:1rem">${esc(fill(t.source_tour, { date: tourDate }))}</p>` : ''}</div></section>`;
// 다크 섹션: 예약과 저장 (큐레이션 숙소 + TourAPI 숙박 가까운 순 6곳)
const checkin = plan.arriveBy, checkout = plan.earliestDeparture ?? addDays(plan.surgeryDate, 14);
@ -268,6 +269,15 @@ const originLabel = (PL as any).origin?.label?.[lang] ?? '';
return scheduleSec + rulesSec + daysSec + festSec + bookSec;
}
/** 체류 기간(도착일~출국 가능일)만 이 기기에 남긴다. 주변 안내(/stay)가 같은 기간의 행사를 먼저 보여 주는 데 쓴다.
* 시술·수술일은 저장하지 않고 서버로도 가지 않는다(머리말 원칙). 저장이 막힌 브라우저에서는 조용히 건너뛴다. */
const PERIOD_KEY = 'infinith.stay.period';
const stayPeriod = (plan: Plan) => ({ from: plan.arriveBy, to: plan.earliestDeparture ?? addDays(plan.surgeryDate, 14) });
function savePeriod(plan: Plan) {
try { localStorage.setItem(PERIOD_KEY, JSON.stringify(stayPeriod(plan))); } catch { /* 비공개 창 등 */ }
}
const stayHref = (plan: Plan) => { const { from, to } = stayPeriod(plan); return `${state.lang === 'en' ? '/en/stay' : '/stay'}#plan:${from}..${to}`; };
let lastPlan: Plan | null = null;
let lastTracked = ''; // 같은 입력으로 다시 그릴 때 plan_result 를 중복 전송하지 않는다
let lastFest: ReturnType<typeof festivalsDuring> = [];
@ -279,6 +289,7 @@ const originLabel = (PL as any).origin?.label?.[lang] ?? '';
const pre = buildPlan({ procedure: p, surgeryDate: state.date, nationality: currentNat(), prefs: state.prefs }, places);
lastFest = festivalsDuring(tourData, pre.arriveBy, pre.earliestDeparture ?? addDays(pre.surgeryDate, 14));
lastPlan = buildPlan({ procedure: p, surgeryDate: state.date, nationality: currentNat(), prefs: state.prefs, festivals: lastFest }, places);
savePeriod(lastPlan);
result = renderResult(lastPlan, p);
const key = [p.id, state.date, state.nat, state.prefs.join('+')].join('|');
if (key !== lastTracked) {

View File

@ -115,7 +115,8 @@
"tour_badge": "관광공사",
"festival_pick": "행사",
"readMoreShort": "홈페이지",
"stat_places_d_curated": "큐레이션. {origin} 기준 거리 표시"
"stat_places_d_curated": "큐레이션. {origin} 기준 거리 표시",
"fest_stay_link": "주변 안내에서 이 기간의 행사 전체 보기"
},
"en": {
"eyebrow": "Recovery Planner",
@ -232,6 +233,7 @@
"tour_badge": "KTO",
"festival_pick": "Event",
"readMoreShort": "Website",
"stat_places_d_curated": "Curated picks, with distance from {origin}"
"stat_places_d_curated": "Curated picks, with distance from {origin}",
"fest_stay_link": "See every event for these dates in the area guide"
}
}

View File

@ -320,7 +320,9 @@ const ld = [
<p class="sub">{festivals.length > 0
? `${festivals.length} events${festKm ? ` within ${festKm} km` : ''}, grouped by season. Right now it is `
: 'Events will follow once the clinic\'s location is confirmed. Right now it is '}<strong>{nowSeason}</strong>.</p>
<p class="sub" data-plan-note hidden></p>
{festivals.length > 0 && <div class="chips" role="group" aria-label="Filter by season">
<button class="chip" data-season="plan" hidden>Your stay <b>0</b></button>
<button class="chip on" data-season="all">All <b>{festivals.length}</b></button>
{SEASONS.map((s) => {
const n = festivals.filter((e: any) => e.season === s.key).length;
@ -329,7 +331,7 @@ const ld = [
</div>}
<div class="cards fest">
{festivals.map((e: any) => (
<article class="card" data-season={e.season}>
<article class="card" data-season={e.season} data-start={e.startDate || ""} data-end={e.endDate || ""} data-km={e.distanceKm ?? ""}>
<div class="thumb">
{e.image ? <img src={e.image} alt={`${e.title} : festival near ${clinic}`} loading="lazy" referrerpolicy="no-referrer" /> : <span class="nophoto">{shortName(e.title)}</span>}
<span class="badge mono">{MONTHS[e.month] ?? ''}</span>
@ -499,16 +501,63 @@ const ld = [
.catch(function () { /* 못 받으면 숨긴 채 둔다 */ });
})();
var sc = document.querySelectorAll('#festivals .chip');
sc.forEach(function (c) {
c.addEventListener('click', function () {
sc.forEach(function (x) { x.classList.toggle('on', x === c); });
var want = c.dataset.season;
document.querySelectorAll('#festivals .card').forEach(function (card) {
card.hidden = !(want === 'all' || card.dataset.season === want);
// 회복 일정 플래너에서 고른 체류 기간과 겹치는 행사를 먼저 보여 준다.
// 기간은 플래너 링크의 URL 해시(#plan:YYYY-MM-DD..YYYY-MM-DD) 또는 이 기기의 localStorage 에서만 읽는다. 서버로 보내지 않는다.
// 기간이 없거나 겹치는 행사가 없으면 화면은 계절 묶음 그대로다.
(function () {
var sec = document.getElementById('festivals'); if (!sec) return;
var chips = Array.prototype.slice.call(sec.querySelectorAll('.chip'));
var cards = Array.prototype.slice.call(sec.querySelectorAll('.card'));
var planChip = sec.querySelector('.chip[data-season="plan"]');
var note = sec.querySelector('[data-plan-note]');
function apply(want) {
chips.forEach(function (x) { x.classList.toggle('on', x.dataset.season === want); });
cards.forEach(function (card) {
card.hidden = !(want === 'all' || (want === 'plan' ? card.dataset.inplan === '1' : card.dataset.season === want));
});
}
chips.forEach(function (c) { c.addEventListener('click', function () { apply(c.dataset.season); }); });
var ISO = /^\d{4}-\d{2}-\d{2}$/;
function readPeriod() {
var m = /plan:(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})/.exec(location.hash);
if (m && m[1] <= m[2]) return { from: m[1], to: m[2], fromHash: true };
try {
var p = JSON.parse(localStorage.getItem('infinith.stay.period') || 'null');
if (p && ISO.test(p.from) && ISO.test(p.to) && p.from <= p.to) return { from: p.from, to: p.to, fromHash: false };
} catch (e) { /* 저장소를 못 쓰는 브라우저 */ }
return null;
}
var period = readPeriod();
if (!period || !planChip || !note) return;
var iso = function (d) { return d && d.length === 8 ? d.slice(0, 4) + '-' + d.slice(4, 6) + '-' + d.slice(6, 8) : ''; };
var hits = cards.filter(function (card) {
var s = iso(card.dataset.start), e = iso(card.dataset.end);
var ok = Boolean(s && e && s <= period.to && e >= period.from);
card.dataset.inplan = ok ? '1' : '0';
return ok;
});
var fmt = function (d) { return new Date(d + 'T00:00:00Z').toLocaleDateString('en', { month: 'short', day: 'numeric', timeZone: 'UTC' }); };
if (!hits.length) { note.textContent = 'No events overlap the stay you chose in the recovery planner (' + fmt(period.from) + ' to ' + fmt(period.to) + '). Showing all events by season.'; note.hidden = false; return; }
// 기간이 짧은 행사가 먼저다. 1년 내내 열리는 상시 행사는 체류 기간과 늘 겹치므로 뒤로 보내고 '상시'로 표시한다.
var LONG = 90;
var days = function (card) { var s = iso(card.dataset.start), e = iso(card.dataset.end); return s && e ? Math.round((Date.parse(e + 'T00:00:00Z') - Date.parse(s + 'T00:00:00Z')) / 86400000) + 1 : 9999; };
hits.sort(function (a, b) { return (days(a) > LONG) - (days(b) > LONG) || days(a) - days(b) || (+a.dataset.km || 0) - (+b.dataset.km || 0); });
var grid = sec.querySelector('.cards.fest');
hits.forEach(function (card) {
card.classList.add('in-plan');
var tag = document.createElement('span'); tag.className = 'badge plan'; tag.textContent = days(card) > LONG ? 'Year-round' : 'Your stay';
card.querySelector('.thumb').appendChild(tag);
grid.appendChild(card); // 겹치는 카드를 가까운 순으로 뒤에 다시 붙인 뒤,
});
hits.slice().reverse().forEach(function (card) { grid.insertBefore(card, grid.firstChild); }); // 그 순서 그대로 맨 앞으로 (짧은 행사 → 상시 행사 → 나머지)
planChip.querySelector('b').textContent = String(hits.length);
planChip.hidden = false;
note.textContent = 'Showing ' + hits.length + ' events that overlap the stay you chose in the recovery planner (' + fmt(period.from) + ' to ' + fmt(period.to) + '). Short-run events come first; year-round ones are listed after them.';
note.hidden = false;
apply('plan');
if (period.fromHash) sec.scrollIntoView();
})();
})();
</script>
</Base>

View File

@ -321,7 +321,9 @@ const ld = [
<p class="sub">{festivals.length > 0
? `${festKm ? `${festKm}km 안의 ` : ''}행사 ${festivals.length}건을 계절로 묶었습니다. 지금은 `
: '병원 위치를 확인한 뒤에 채워집니다. 확인 대기. 지금은 '}<strong>{nowSeason}</strong>입니다.</p>
<p class="sub" data-plan-note hidden></p>
{festivals.length > 0 && <div class="chips" role="group" aria-label="계절로 거르기">
<button class="chip" data-season="plan" hidden>체류 기간 <b>0</b></button>
<button class="chip on" data-season="all">전체 <b>{festivals.length}</b></button>
{SEASONS.map((s) => {
const n = festivals.filter((e: any) => e.season === s.key).length;
@ -330,7 +332,7 @@ const ld = [
</div>}
<div class="cards fest">
{festivals.map((e: any) => (
<article class="card" data-season={e.season}>
<article class="card" data-season={e.season} data-start={e.startDate || ""} data-end={e.endDate || ""} data-km={e.distanceKm ?? ""}>
<div class="thumb">
{e.image ? <img src={e.image} alt={`${e.title}, ${clinic} 주변 축제`} loading="lazy" referrerpolicy="no-referrer" /> : <span class="nophoto">{shortName(e.title)}</span>}
<span class="badge mono">{MONTHS[e.month] ?? ''}</span>
@ -500,16 +502,63 @@ const ld = [
.catch(function () { /* 못 받으면 숨긴 채 둔다 */ });
})();
var sc = document.querySelectorAll('#festivals .chip');
sc.forEach(function (c) {
c.addEventListener('click', function () {
sc.forEach(function (x) { x.classList.toggle('on', x === c); });
var want = c.dataset.season;
document.querySelectorAll('#festivals .card').forEach(function (card) {
card.hidden = !(want === 'all' || card.dataset.season === want);
// 회복 일정 플래너에서 고른 체류 기간과 겹치는 행사를 먼저 보여 준다.
// 기간은 플래너 링크의 URL 해시(#plan:YYYY-MM-DD..YYYY-MM-DD) 또는 이 기기의 localStorage 에서만 읽는다. 서버로 보내지 않는다.
// 기간이 없거나 겹치는 행사가 없으면 화면은 계절 묶음 그대로다.
(function () {
var sec = document.getElementById('festivals'); if (!sec) return;
var chips = Array.prototype.slice.call(sec.querySelectorAll('.chip'));
var cards = Array.prototype.slice.call(sec.querySelectorAll('.card'));
var planChip = sec.querySelector('.chip[data-season="plan"]');
var note = sec.querySelector('[data-plan-note]');
function apply(want) {
chips.forEach(function (x) { x.classList.toggle('on', x.dataset.season === want); });
cards.forEach(function (card) {
card.hidden = !(want === 'all' || (want === 'plan' ? card.dataset.inplan === '1' : card.dataset.season === want));
});
}
chips.forEach(function (c) { c.addEventListener('click', function () { apply(c.dataset.season); }); });
var ISO = /^\d{4}-\d{2}-\d{2}$/;
function readPeriod() {
var m = /plan:(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})/.exec(location.hash);
if (m && m[1] <= m[2]) return { from: m[1], to: m[2], fromHash: true };
try {
var p = JSON.parse(localStorage.getItem('infinith.stay.period') || 'null');
if (p && ISO.test(p.from) && ISO.test(p.to) && p.from <= p.to) return { from: p.from, to: p.to, fromHash: false };
} catch (e) { /* 저장소를 못 쓰는 브라우저 */ }
return null;
}
var period = readPeriod();
if (!period || !planChip || !note) return;
var iso = function (d) { return d && d.length === 8 ? d.slice(0, 4) + '-' + d.slice(4, 6) + '-' + d.slice(6, 8) : ''; };
var hits = cards.filter(function (card) {
var s = iso(card.dataset.start), e = iso(card.dataset.end);
var ok = Boolean(s && e && s <= period.to && e >= period.from);
card.dataset.inplan = ok ? '1' : '0';
return ok;
});
var fmt = function (d) { return (+d.slice(5, 7)) + '월 ' + (+d.slice(8, 10)) + '일'; };
if (!hits.length) { note.textContent = '회복 일정에서 고른 체류 기간(' + fmt(period.from) + ' ~ ' + fmt(period.to) + ')과 겹치는 행사가 없습니다. 계절별 목록을 보여 드립니다.'; note.hidden = false; return; }
// 기간이 짧은 행사가 먼저다. 1년 내내 열리는 상시 행사는 체류 기간과 늘 겹치므로 뒤로 보내고 '상시'로 표시한다.
var LONG = 90;
var days = function (card) { var s = iso(card.dataset.start), e = iso(card.dataset.end); return s && e ? Math.round((Date.parse(e + 'T00:00:00Z') - Date.parse(s + 'T00:00:00Z')) / 86400000) + 1 : 9999; };
hits.sort(function (a, b) { return (days(a) > LONG) - (days(b) > LONG) || days(a) - days(b) || (+a.dataset.km || 0) - (+b.dataset.km || 0); });
var grid = sec.querySelector('.cards.fest');
hits.forEach(function (card) {
card.classList.add('in-plan');
var tag = document.createElement('span'); tag.className = 'badge plan'; tag.textContent = days(card) > LONG ? '상시 진행' : '체류 기간';
card.querySelector('.thumb').appendChild(tag);
grid.appendChild(card); // 겹치는 카드를 가까운 순으로 뒤에 다시 붙인 뒤,
});
hits.slice().reverse().forEach(function (card) { grid.insertBefore(card, grid.firstChild); }); // 그 순서 그대로 맨 앞으로 (짧은 행사 → 상시 행사 → 나머지)
planChip.querySelector('b').textContent = String(hits.length);
planChip.hidden = false;
note.textContent = '회복 일정에서 고른 체류 기간(' + fmt(period.from) + ' ~ ' + fmt(period.to) + ')과 겹치는 행사 ' + hits.length + '건을 먼저 보여 드립니다. 기간이 짧은 행사가 앞이고, 1년 내내 열리는 상시 행사는 뒤에 있습니다.';
note.hidden = false;
apply('plan');
if (period.fromHash) sec.scrollIntoView();
})();
})();
</script>
</Base>

View File

@ -321,6 +321,10 @@ td { color: var(--slate-700); }
}
.stay .card .badge em { font-style: normal; opacity: 0.7; margin-left: 0.25em; }
.stay .card .badge.mono { letter-spacing: 0.06em; }
/* 회복 일정의 체류 기간과 겹치는 행사: 카드 테두리와 오른쪽 아래 표시. 월 배지(왼쪽)와 겹치지 않게 오른쪽에 둔다 */
.stay .card.in-plan { border-color: var(--accent, #6C5CE7); box-shadow: 3px 4px 12px rgba(108, 92, 231, 0.16); }
.stay .card .badge.plan { left: auto; right: 0.6rem; background: var(--accent, #6C5CE7); }
.stay .chip[hidden] { display: none; }
.stay .card .body { padding: 0.85rem 0.95rem 1rem; display: flex; flex-direction: column; flex: 1; }
.stay .card h4 { margin: 0 0 0.35rem; font-size: 0.98rem; }
.stay .card .when { font-size: 0.8rem; color: var(--slate-600, #475569); margin: 0 0 0.4rem; }

View File

@ -243,7 +243,8 @@ const originLabel = (PL as any).origin?.label?.[lang] ?? '';
const from = firstL3 && firstL3 <= f.end ? fill(t.fest_from, { d: fmtDate(firstL3 < f.start ? f.start : firstL3) }) : t.fest_notYet;
return `<div class="card fest-card" id="fest-${esc(f.id)}">${f.image ? `<img src="${esc(f.image)}" alt="${esc(f.title)}" loading="lazy" onerror="this.remove()" />` : ''}<div class="cat">${esc(fill(t.fest_when, { a: f.start.slice(5).replace('-', '.'), b: f.end.slice(5).replace('-', '.') }))} · ${esc(f.travelLabel ?? fill(t.km, { km: f.distanceKm }))}</div><h3>${esc(f.title)}</h3><p>${esc(f.blurb)}</p><p class="fine"><span class="tag ${firstL3 && firstL3 <= f.end ? 'l3' : 'l0'}">${esc(from)}</span> ${f.url ? `<a href="${esc(f.url)}" rel="noopener" target="_blank">${esc(t.readMoreShort)}</a> · ` : ''}<a href="${mapUrl(f.title)}" rel="noopener" target="_blank">${esc(t.map)}</a></p></div>`;
}).join('');
const festSec = `<section class="section light"><div class="wrap-wide">${secHead('Events', t.h_fest, t.sub_fest)}${festCards ? `<div class="grid">${festCards}</div>` : `<p class="sub" style="color:var(--slate-500)">${esc(t.fest_none)}</p>`}${tourDate ? `<p class="fine" style="margin-top:1rem">${esc(fill(t.source_tour, { date: tourDate }))}</p>` : ''}</div></section>`;
const stayLink = `<p class="fine" style="margin-top:1rem"><a href="${stayHref(plan)}" data-ga="stay-events">${esc(t.fest_stay_link)} →</a></p>`;
const festSec = `<section class="section light"><div class="wrap-wide">${secHead('Events', t.h_fest, t.sub_fest)}${festCards ? `<div class="grid">${festCards}</div>` : `<p class="sub" style="color:var(--slate-500)">${esc(t.fest_none)}</p>`}${stayLink}${tourDate ? `<p class="fine" style="margin-top:1rem">${esc(fill(t.source_tour, { date: tourDate }))}</p>` : ''}</div></section>`;
// 다크 섹션: 예약과 저장 (큐레이션 숙소 + TourAPI 숙박 가까운 순 6곳)
const checkin = plan.arriveBy, checkout = plan.earliestDeparture ?? addDays(plan.surgeryDate, 14);
@ -268,6 +269,15 @@ const originLabel = (PL as any).origin?.label?.[lang] ?? '';
return scheduleSec + rulesSec + daysSec + festSec + bookSec;
}
/** 체류 기간(도착일~출국 가능일)만 이 기기에 남긴다. 주변 안내(/stay)가 같은 기간의 행사를 먼저 보여 주는 데 쓴다.
* 시술·수술일은 저장하지 않고 서버로도 가지 않는다(머리말 원칙). 저장이 막힌 브라우저에서는 조용히 건너뛴다. */
const PERIOD_KEY = 'infinith.stay.period';
const stayPeriod = (plan: Plan) => ({ from: plan.arriveBy, to: plan.earliestDeparture ?? addDays(plan.surgeryDate, 14) });
function savePeriod(plan: Plan) {
try { localStorage.setItem(PERIOD_KEY, JSON.stringify(stayPeriod(plan))); } catch { /* 비공개 창 등 */ }
}
const stayHref = (plan: Plan) => { const { from, to } = stayPeriod(plan); return `${state.lang === 'en' ? '/en/stay' : '/stay'}#plan:${from}..${to}`; };
let lastPlan: Plan | null = null;
let lastTracked = ''; // 같은 입력으로 다시 그릴 때 plan_result 를 중복 전송하지 않는다
let lastFest: ReturnType<typeof festivalsDuring> = [];
@ -279,6 +289,7 @@ const originLabel = (PL as any).origin?.label?.[lang] ?? '';
const pre = buildPlan({ procedure: p, surgeryDate: state.date, nationality: currentNat(), prefs: state.prefs }, places);
lastFest = festivalsDuring(tourData, pre.arriveBy, pre.earliestDeparture ?? addDays(pre.surgeryDate, 14));
lastPlan = buildPlan({ procedure: p, surgeryDate: state.date, nationality: currentNat(), prefs: state.prefs, festivals: lastFest }, places);
savePeriod(lastPlan);
result = renderResult(lastPlan, p);
const key = [p.id, state.date, state.nat, state.prefs.join('+')].join('|');
if (key !== lastTracked) {

View File

@ -115,7 +115,8 @@
"tour_badge": "관광공사",
"festival_pick": "행사",
"readMoreShort": "홈페이지",
"stat_places_d_curated": "큐레이션. {origin} 기준 거리 표시"
"stat_places_d_curated": "큐레이션. {origin} 기준 거리 표시",
"fest_stay_link": "주변 안내에서 이 기간의 행사 전체 보기"
},
"en": {
"eyebrow": "Recovery Planner",
@ -232,6 +233,7 @@
"tour_badge": "KTO",
"festival_pick": "Event",
"readMoreShort": "Website",
"stat_places_d_curated": "Curated picks, with distance from {origin}"
"stat_places_d_curated": "Curated picks, with distance from {origin}",
"fest_stay_link": "See every event for these dates in the area guide"
}
}

View File

@ -320,7 +320,9 @@ const ld = [
<p class="sub">{festivals.length > 0
? `${festivals.length} events${festKm ? ` within ${festKm} km` : ''}, grouped by season. Right now it is `
: 'Events will follow once the clinic\'s location is confirmed. Right now it is '}<strong>{nowSeason}</strong>.</p>
<p class="sub" data-plan-note hidden></p>
{festivals.length > 0 && <div class="chips" role="group" aria-label="Filter by season">
<button class="chip" data-season="plan" hidden>Your stay <b>0</b></button>
<button class="chip on" data-season="all">All <b>{festivals.length}</b></button>
{SEASONS.map((s) => {
const n = festivals.filter((e: any) => e.season === s.key).length;
@ -329,7 +331,7 @@ const ld = [
</div>}
<div class="cards fest">
{festivals.map((e: any) => (
<article class="card" data-season={e.season}>
<article class="card" data-season={e.season} data-start={e.startDate || ""} data-end={e.endDate || ""} data-km={e.distanceKm ?? ""}>
<div class="thumb">
{e.image ? <img src={e.image} alt={`${e.title} : festival near ${clinic}`} loading="lazy" referrerpolicy="no-referrer" /> : <span class="nophoto">{shortName(e.title)}</span>}
<span class="badge mono">{MONTHS[e.month] ?? ''}</span>
@ -499,16 +501,63 @@ const ld = [
.catch(function () { /* 못 받으면 숨긴 채 둔다 */ });
})();
var sc = document.querySelectorAll('#festivals .chip');
sc.forEach(function (c) {
c.addEventListener('click', function () {
sc.forEach(function (x) { x.classList.toggle('on', x === c); });
var want = c.dataset.season;
document.querySelectorAll('#festivals .card').forEach(function (card) {
card.hidden = !(want === 'all' || card.dataset.season === want);
// 회복 일정 플래너에서 고른 체류 기간과 겹치는 행사를 먼저 보여 준다.
// 기간은 플래너 링크의 URL 해시(#plan:YYYY-MM-DD..YYYY-MM-DD) 또는 이 기기의 localStorage 에서만 읽는다. 서버로 보내지 않는다.
// 기간이 없거나 겹치는 행사가 없으면 화면은 계절 묶음 그대로다.
(function () {
var sec = document.getElementById('festivals'); if (!sec) return;
var chips = Array.prototype.slice.call(sec.querySelectorAll('.chip'));
var cards = Array.prototype.slice.call(sec.querySelectorAll('.card'));
var planChip = sec.querySelector('.chip[data-season="plan"]');
var note = sec.querySelector('[data-plan-note]');
function apply(want) {
chips.forEach(function (x) { x.classList.toggle('on', x.dataset.season === want); });
cards.forEach(function (card) {
card.hidden = !(want === 'all' || (want === 'plan' ? card.dataset.inplan === '1' : card.dataset.season === want));
});
}
chips.forEach(function (c) { c.addEventListener('click', function () { apply(c.dataset.season); }); });
var ISO = /^\d{4}-\d{2}-\d{2}$/;
function readPeriod() {
var m = /plan:(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})/.exec(location.hash);
if (m && m[1] <= m[2]) return { from: m[1], to: m[2], fromHash: true };
try {
var p = JSON.parse(localStorage.getItem('infinith.stay.period') || 'null');
if (p && ISO.test(p.from) && ISO.test(p.to) && p.from <= p.to) return { from: p.from, to: p.to, fromHash: false };
} catch (e) { /* 저장소를 못 쓰는 브라우저 */ }
return null;
}
var period = readPeriod();
if (!period || !planChip || !note) return;
var iso = function (d) { return d && d.length === 8 ? d.slice(0, 4) + '-' + d.slice(4, 6) + '-' + d.slice(6, 8) : ''; };
var hits = cards.filter(function (card) {
var s = iso(card.dataset.start), e = iso(card.dataset.end);
var ok = Boolean(s && e && s <= period.to && e >= period.from);
card.dataset.inplan = ok ? '1' : '0';
return ok;
});
var fmt = function (d) { return new Date(d + 'T00:00:00Z').toLocaleDateString('en', { month: 'short', day: 'numeric', timeZone: 'UTC' }); };
if (!hits.length) { note.textContent = 'No events overlap the stay you chose in the recovery planner (' + fmt(period.from) + ' to ' + fmt(period.to) + '). Showing all events by season.'; note.hidden = false; return; }
// 기간이 짧은 행사가 먼저다. 1년 내내 열리는 상시 행사는 체류 기간과 늘 겹치므로 뒤로 보내고 '상시'로 표시한다.
var LONG = 90;
var days = function (card) { var s = iso(card.dataset.start), e = iso(card.dataset.end); return s && e ? Math.round((Date.parse(e + 'T00:00:00Z') - Date.parse(s + 'T00:00:00Z')) / 86400000) + 1 : 9999; };
hits.sort(function (a, b) { return (days(a) > LONG) - (days(b) > LONG) || days(a) - days(b) || (+a.dataset.km || 0) - (+b.dataset.km || 0); });
var grid = sec.querySelector('.cards.fest');
hits.forEach(function (card) {
card.classList.add('in-plan');
var tag = document.createElement('span'); tag.className = 'badge plan'; tag.textContent = days(card) > LONG ? 'Year-round' : 'Your stay';
card.querySelector('.thumb').appendChild(tag);
grid.appendChild(card); // 겹치는 카드를 가까운 순으로 뒤에 다시 붙인 뒤,
});
hits.slice().reverse().forEach(function (card) { grid.insertBefore(card, grid.firstChild); }); // 그 순서 그대로 맨 앞으로 (짧은 행사 → 상시 행사 → 나머지)
planChip.querySelector('b').textContent = String(hits.length);
planChip.hidden = false;
note.textContent = 'Showing ' + hits.length + ' events that overlap the stay you chose in the recovery planner (' + fmt(period.from) + ' to ' + fmt(period.to) + '). Short-run events come first; year-round ones are listed after them.';
note.hidden = false;
apply('plan');
if (period.fromHash) sec.scrollIntoView();
})();
})();
</script>
</Base>

View File

@ -321,7 +321,9 @@ const ld = [
<p class="sub">{festivals.length > 0
? `${festKm ? `${festKm}km 안의 ` : ''}행사 ${festivals.length}건을 계절로 묶었습니다. 지금은 `
: '병원 위치를 확인한 뒤에 채워집니다. 확인 대기. 지금은 '}<strong>{nowSeason}</strong>입니다.</p>
<p class="sub" data-plan-note hidden></p>
{festivals.length > 0 && <div class="chips" role="group" aria-label="계절로 거르기">
<button class="chip" data-season="plan" hidden>체류 기간 <b>0</b></button>
<button class="chip on" data-season="all">전체 <b>{festivals.length}</b></button>
{SEASONS.map((s) => {
const n = festivals.filter((e: any) => e.season === s.key).length;
@ -330,7 +332,7 @@ const ld = [
</div>}
<div class="cards fest">
{festivals.map((e: any) => (
<article class="card" data-season={e.season}>
<article class="card" data-season={e.season} data-start={e.startDate || ""} data-end={e.endDate || ""} data-km={e.distanceKm ?? ""}>
<div class="thumb">
{e.image ? <img src={e.image} alt={`${e.title}, ${clinic} 주변 축제`} loading="lazy" referrerpolicy="no-referrer" /> : <span class="nophoto">{shortName(e.title)}</span>}
<span class="badge mono">{MONTHS[e.month] ?? ''}</span>
@ -500,16 +502,63 @@ const ld = [
.catch(function () { /* 못 받으면 숨긴 채 둔다 */ });
})();
var sc = document.querySelectorAll('#festivals .chip');
sc.forEach(function (c) {
c.addEventListener('click', function () {
sc.forEach(function (x) { x.classList.toggle('on', x === c); });
var want = c.dataset.season;
document.querySelectorAll('#festivals .card').forEach(function (card) {
card.hidden = !(want === 'all' || card.dataset.season === want);
// 회복 일정 플래너에서 고른 체류 기간과 겹치는 행사를 먼저 보여 준다.
// 기간은 플래너 링크의 URL 해시(#plan:YYYY-MM-DD..YYYY-MM-DD) 또는 이 기기의 localStorage 에서만 읽는다. 서버로 보내지 않는다.
// 기간이 없거나 겹치는 행사가 없으면 화면은 계절 묶음 그대로다.
(function () {
var sec = document.getElementById('festivals'); if (!sec) return;
var chips = Array.prototype.slice.call(sec.querySelectorAll('.chip'));
var cards = Array.prototype.slice.call(sec.querySelectorAll('.card'));
var planChip = sec.querySelector('.chip[data-season="plan"]');
var note = sec.querySelector('[data-plan-note]');
function apply(want) {
chips.forEach(function (x) { x.classList.toggle('on', x.dataset.season === want); });
cards.forEach(function (card) {
card.hidden = !(want === 'all' || (want === 'plan' ? card.dataset.inplan === '1' : card.dataset.season === want));
});
}
chips.forEach(function (c) { c.addEventListener('click', function () { apply(c.dataset.season); }); });
var ISO = /^\d{4}-\d{2}-\d{2}$/;
function readPeriod() {
var m = /plan:(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})/.exec(location.hash);
if (m && m[1] <= m[2]) return { from: m[1], to: m[2], fromHash: true };
try {
var p = JSON.parse(localStorage.getItem('infinith.stay.period') || 'null');
if (p && ISO.test(p.from) && ISO.test(p.to) && p.from <= p.to) return { from: p.from, to: p.to, fromHash: false };
} catch (e) { /* 저장소를 못 쓰는 브라우저 */ }
return null;
}
var period = readPeriod();
if (!period || !planChip || !note) return;
var iso = function (d) { return d && d.length === 8 ? d.slice(0, 4) + '-' + d.slice(4, 6) + '-' + d.slice(6, 8) : ''; };
var hits = cards.filter(function (card) {
var s = iso(card.dataset.start), e = iso(card.dataset.end);
var ok = Boolean(s && e && s <= period.to && e >= period.from);
card.dataset.inplan = ok ? '1' : '0';
return ok;
});
var fmt = function (d) { return (+d.slice(5, 7)) + '월 ' + (+d.slice(8, 10)) + '일'; };
if (!hits.length) { note.textContent = '회복 일정에서 고른 체류 기간(' + fmt(period.from) + ' ~ ' + fmt(period.to) + ')과 겹치는 행사가 없습니다. 계절별 목록을 보여 드립니다.'; note.hidden = false; return; }
// 기간이 짧은 행사가 먼저다. 1년 내내 열리는 상시 행사는 체류 기간과 늘 겹치므로 뒤로 보내고 '상시'로 표시한다.
var LONG = 90;
var days = function (card) { var s = iso(card.dataset.start), e = iso(card.dataset.end); return s && e ? Math.round((Date.parse(e + 'T00:00:00Z') - Date.parse(s + 'T00:00:00Z')) / 86400000) + 1 : 9999; };
hits.sort(function (a, b) { return (days(a) > LONG) - (days(b) > LONG) || days(a) - days(b) || (+a.dataset.km || 0) - (+b.dataset.km || 0); });
var grid = sec.querySelector('.cards.fest');
hits.forEach(function (card) {
card.classList.add('in-plan');
var tag = document.createElement('span'); tag.className = 'badge plan'; tag.textContent = days(card) > LONG ? '상시 진행' : '체류 기간';
card.querySelector('.thumb').appendChild(tag);
grid.appendChild(card); // 겹치는 카드를 가까운 순으로 뒤에 다시 붙인 뒤,
});
hits.slice().reverse().forEach(function (card) { grid.insertBefore(card, grid.firstChild); }); // 그 순서 그대로 맨 앞으로 (짧은 행사 → 상시 행사 → 나머지)
planChip.querySelector('b').textContent = String(hits.length);
planChip.hidden = false;
note.textContent = '회복 일정에서 고른 체류 기간(' + fmt(period.from) + ' ~ ' + fmt(period.to) + ')과 겹치는 행사 ' + hits.length + '건을 먼저 보여 드립니다. 기간이 짧은 행사가 앞이고, 1년 내내 열리는 상시 행사는 뒤에 있습니다.';
note.hidden = false;
apply('plan');
if (period.fromHash) sec.scrollIntoView();
})();
})();
</script>
</Base>

View File

@ -321,6 +321,10 @@ td { color: var(--slate-700); }
}
.stay .card .badge em { font-style: normal; opacity: 0.7; margin-left: 0.25em; }
.stay .card .badge.mono { letter-spacing: 0.06em; }
/* 회복 일정의 체류 기간과 겹치는 행사: 카드 테두리와 오른쪽 아래 표시. 월 배지(왼쪽)와 겹치지 않게 오른쪽에 둔다 */
.stay .card.in-plan { border-color: var(--accent, #6C5CE7); box-shadow: 3px 4px 12px rgba(108, 92, 231, 0.16); }
.stay .card .badge.plan { left: auto; right: 0.6rem; background: var(--accent, #6C5CE7); }
.stay .chip[hidden] { display: none; }
.stay .card .body { padding: 0.85rem 0.95rem 1rem; display: flex; flex-direction: column; flex: 1; }
.stay .card h4 { margin: 0 0 0.35rem; font-size: 0.98rem; }
.stay .card .when { font-size: 0.8rem; color: var(--slate-600, #475569); margin: 0 0 0.4rem; }

View File

@ -60,7 +60,9 @@ function applySiteUrl() {
// 기존 병원 폴더에 닿지 않는다. 2026-09-14 에 소유 확인 메타와 플래너 계측이 이 이유로 배포되지 않았다.
// 병원별로 갈리는 것(astro.config.mjs·vercel.json 의 치환값, 구조 기준선, home_text)은 덮지 않는다.
const CODE_DIRS = ['src/layouts', 'src/components', 'src/pages', 'src/styles', 'src/lib'];
const CODE_FILES = ['src/lib.ts', 'src/content.config.ts'];
// src/data 는 병원 데이터라 덮지 않지만, planStrings.json 은 플래너 화면 문구(한·영)라 코드와 함께 간다.
// 2026-09-16: 플래너에 새 문구를 넣었는데 기존 병원 폴더의 옛 파일이 남아 링크 글자가 비어 나온 것을 캡처로 발견했다.
const CODE_FILES = ['src/lib.ts', 'src/content.config.ts', 'src/data/planStrings.json'];
function refreshTemplateCode() {
if (!existsSync(TEMPLATE)) return '템플릿 없음';
let n = 0;