Compare commits

..

2 Commits

Author SHA1 Message Date
Haewon Kam
796fa56106 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>
2026-09-16 09:47:01 +09:00
Haewon Kam
98a3e41837 chore(docs): 빌더 설명문 표현 정리
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 09:47:01 +09:00
13 changed files with 277 additions and 45 deletions

View File

@ -8,7 +8,7 @@ docs/reports/viewclinic/05_global_en/qb_en_*_results.jsonl(영문 60, 2026-09-10
python3 docs/business/build_mou_hub_competition_docx.py [--force] python3 docs/business/build_mou_hub_competition_docx.py [--force]
주의: 2026-09-15 이후 haewon이 이 문서를 직접 편집했다. 이 빌더로 다시 만들면 편집이 사라지므로 주의: 2026-09-15 이후 haewon이 이 문서를 직접 편집했다. 이 빌더로 다시 만들면 편집이 사라지므로
결과 파일이 있으면 멈춘다. 이후 변경은 patch_*.py 처럼 제자리 삽입으로 한다. 결과 파일이 있으면 멈춘다. 이후 변경은 patch_*.py 처럼 편집본에 직접 삽입한다.
""" """
import sys import sys
from pathlib import Path from pathlib import Path

View File

@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""haewon 편집본에 '4-10. 병원·의료 AEO/GEO 서비스 경쟁분석'과 부록 출처를 제자리 삽입한다. """haewon 편집본에 '4-10. 병원·의료 AEO/GEO 서비스 경쟁분석'과 부록 출처를 편집본에 직접 삽입한다.
문서를 다시 만들지 않는다. 기존 문단·표·서식은 그대로 두고, '5. 현재 조직으로 가능한 사업인가' 제목 앞에 문서를 다시 만들지 않는다. 기존 문단·표·서식은 그대로 두고, '5. 현재 조직으로 가능한 사업인가' 제목 앞에
새 절을, 문서 끝(부록 출처 마지막)에 출처 묶음을 끼워 넣는다. '4-10.' 제목이 이미 있으면 아무것도 하지 않는다. 새 절을, 문서 끝(부록 출처 마지막)에 출처 묶음을 끼워 넣는다. '4-10.' 제목이 이미 있으면 아무것도 하지 않는다.

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; 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>`; 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(''); }).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곳) // 다크 섹션: 예약과 저장 (큐레이션 숙소 + TourAPI 숙박 가까운 순 6곳)
const checkin = plan.arriveBy, checkout = plan.earliestDeparture ?? addDays(plan.surgeryDate, 14); 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; 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 lastPlan: Plan | null = null;
let lastTracked = ''; // 같은 입력으로 다시 그릴 때 plan_result 를 중복 전송하지 않는다 let lastTracked = ''; // 같은 입력으로 다시 그릴 때 plan_result 를 중복 전송하지 않는다
let lastFest: ReturnType<typeof festivalsDuring> = []; 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); 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)); 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); lastPlan = buildPlan({ procedure: p, surgeryDate: state.date, nationality: currentNat(), prefs: state.prefs, festivals: lastFest }, places);
savePeriod(lastPlan);
result = renderResult(lastPlan, p); result = renderResult(lastPlan, p);
const key = [p.id, state.date, state.nat, state.prefs.join('+')].join('|'); const key = [p.id, state.date, state.nat, state.prefs.join('+')].join('|');
if (key !== lastTracked) { if (key !== lastTracked) {

View File

@ -115,7 +115,8 @@
"tour_badge": "관광공사", "tour_badge": "관광공사",
"festival_pick": "행사", "festival_pick": "행사",
"readMoreShort": "홈페이지", "readMoreShort": "홈페이지",
"stat_places_d_curated": "큐레이션. {origin} 기준 거리 표시" "stat_places_d_curated": "큐레이션. {origin} 기준 거리 표시",
"fest_stay_link": "주변 안내에서 이 기간의 행사 전체 보기"
}, },
"en": { "en": {
"eyebrow": "Recovery Planner", "eyebrow": "Recovery Planner",
@ -232,6 +233,7 @@
"tour_badge": "KTO", "tour_badge": "KTO",
"festival_pick": "Event", "festival_pick": "Event",
"readMoreShort": "Website", "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 <p class="sub">{festivals.length > 0
? `${festivals.length} events${festKm ? ` within ${festKm} km` : ''}, grouped by season. Right now it is ` ? `${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> : '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"> {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> <button class="chip on" data-season="all">All <b>{festivals.length}</b></button>
{SEASONS.map((s) => { {SEASONS.map((s) => {
const n = festivals.filter((e: any) => e.season === s.key).length; const n = festivals.filter((e: any) => e.season === s.key).length;
@ -329,7 +331,7 @@ const ld = [
</div>} </div>}
<div class="cards fest"> <div class="cards fest">
{festivals.map((e: any) => ( {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"> <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>} {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> <span class="badge mono">{MONTHS[e.month] ?? ''}</span>
@ -499,16 +501,63 @@ const ld = [
.catch(function () { /* 못 받으면 숨긴 채 둔다 */ }); .catch(function () { /* 못 받으면 숨긴 채 둔다 */ });
})(); })();
var sc = document.querySelectorAll('#festivals .chip'); // 회복 일정 플래너에서 고른 체류 기간과 겹치는 행사를 먼저 보여 준다.
sc.forEach(function (c) { // 기간은 플래너 링크의 URL 해시(#plan:YYYY-MM-DD..YYYY-MM-DD) 또는 이 기기의 localStorage 에서만 읽는다. 서버로 보내지 않는다.
c.addEventListener('click', function () { // 기간이 없거나 겹치는 행사가 없으면 화면은 계절 묶음 그대로다.
sc.forEach(function (x) { x.classList.toggle('on', x === c); }); (function () {
var want = c.dataset.season; var sec = document.getElementById('festivals'); if (!sec) return;
document.querySelectorAll('#festivals .card').forEach(function (card) { var chips = Array.prototype.slice.call(sec.querySelectorAll('.chip'));
card.hidden = !(want === 'all' || card.dataset.season === want); 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> </script>
</Base> </Base>

View File

@ -321,7 +321,9 @@ const ld = [
<p class="sub">{festivals.length > 0 <p class="sub">{festivals.length > 0
? `${festKm ? `${festKm}km 안의 ` : ''}행사 ${festivals.length}건을 계절로 묶었습니다. 지금은 ` ? `${festKm ? `${festKm}km 안의 ` : ''}행사 ${festivals.length}건을 계절로 묶었습니다. 지금은 `
: '병원 위치를 확인한 뒤에 채워집니다. 확인 대기. 지금은 '}<strong>{nowSeason}</strong>입니다.</p> : '병원 위치를 확인한 뒤에 채워집니다. 확인 대기. 지금은 '}<strong>{nowSeason}</strong>입니다.</p>
<p class="sub" data-plan-note hidden></p>
{festivals.length > 0 && <div class="chips" role="group" aria-label="계절로 거르기"> {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> <button class="chip on" data-season="all">전체 <b>{festivals.length}</b></button>
{SEASONS.map((s) => { {SEASONS.map((s) => {
const n = festivals.filter((e: any) => e.season === s.key).length; const n = festivals.filter((e: any) => e.season === s.key).length;
@ -330,7 +332,7 @@ const ld = [
</div>} </div>}
<div class="cards fest"> <div class="cards fest">
{festivals.map((e: any) => ( {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"> <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>} {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> <span class="badge mono">{MONTHS[e.month] ?? ''}</span>
@ -500,16 +502,63 @@ const ld = [
.catch(function () { /* 못 받으면 숨긴 채 둔다 */ }); .catch(function () { /* 못 받으면 숨긴 채 둔다 */ });
})(); })();
var sc = document.querySelectorAll('#festivals .chip'); // 회복 일정 플래너에서 고른 체류 기간과 겹치는 행사를 먼저 보여 준다.
sc.forEach(function (c) { // 기간은 플래너 링크의 URL 해시(#plan:YYYY-MM-DD..YYYY-MM-DD) 또는 이 기기의 localStorage 에서만 읽는다. 서버로 보내지 않는다.
c.addEventListener('click', function () { // 기간이 없거나 겹치는 행사가 없으면 화면은 계절 묶음 그대로다.
sc.forEach(function (x) { x.classList.toggle('on', x === c); }); (function () {
var want = c.dataset.season; var sec = document.getElementById('festivals'); if (!sec) return;
document.querySelectorAll('#festivals .card').forEach(function (card) { var chips = Array.prototype.slice.call(sec.querySelectorAll('.chip'));
card.hidden = !(want === 'all' || card.dataset.season === want); 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> </script>
</Base> </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 em { font-style: normal; opacity: 0.7; margin-left: 0.25em; }
.stay .card .badge.mono { letter-spacing: 0.06em; } .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 .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 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; } .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; 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>`; 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(''); }).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곳) // 다크 섹션: 예약과 저장 (큐레이션 숙소 + TourAPI 숙박 가까운 순 6곳)
const checkin = plan.arriveBy, checkout = plan.earliestDeparture ?? addDays(plan.surgeryDate, 14); 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; 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 lastPlan: Plan | null = null;
let lastTracked = ''; // 같은 입력으로 다시 그릴 때 plan_result 를 중복 전송하지 않는다 let lastTracked = ''; // 같은 입력으로 다시 그릴 때 plan_result 를 중복 전송하지 않는다
let lastFest: ReturnType<typeof festivalsDuring> = []; 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); 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)); 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); lastPlan = buildPlan({ procedure: p, surgeryDate: state.date, nationality: currentNat(), prefs: state.prefs, festivals: lastFest }, places);
savePeriod(lastPlan);
result = renderResult(lastPlan, p); result = renderResult(lastPlan, p);
const key = [p.id, state.date, state.nat, state.prefs.join('+')].join('|'); const key = [p.id, state.date, state.nat, state.prefs.join('+')].join('|');
if (key !== lastTracked) { if (key !== lastTracked) {

View File

@ -115,7 +115,8 @@
"tour_badge": "관광공사", "tour_badge": "관광공사",
"festival_pick": "행사", "festival_pick": "행사",
"readMoreShort": "홈페이지", "readMoreShort": "홈페이지",
"stat_places_d_curated": "큐레이션. {origin} 기준 거리 표시" "stat_places_d_curated": "큐레이션. {origin} 기준 거리 표시",
"fest_stay_link": "주변 안내에서 이 기간의 행사 전체 보기"
}, },
"en": { "en": {
"eyebrow": "Recovery Planner", "eyebrow": "Recovery Planner",
@ -232,6 +233,7 @@
"tour_badge": "KTO", "tour_badge": "KTO",
"festival_pick": "Event", "festival_pick": "Event",
"readMoreShort": "Website", "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 <p class="sub">{festivals.length > 0
? `${festivals.length} events${festKm ? ` within ${festKm} km` : ''}, grouped by season. Right now it is ` ? `${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> : '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"> {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> <button class="chip on" data-season="all">All <b>{festivals.length}</b></button>
{SEASONS.map((s) => { {SEASONS.map((s) => {
const n = festivals.filter((e: any) => e.season === s.key).length; const n = festivals.filter((e: any) => e.season === s.key).length;
@ -329,7 +331,7 @@ const ld = [
</div>} </div>}
<div class="cards fest"> <div class="cards fest">
{festivals.map((e: any) => ( {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"> <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>} {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> <span class="badge mono">{MONTHS[e.month] ?? ''}</span>
@ -499,16 +501,63 @@ const ld = [
.catch(function () { /* 못 받으면 숨긴 채 둔다 */ }); .catch(function () { /* 못 받으면 숨긴 채 둔다 */ });
})(); })();
var sc = document.querySelectorAll('#festivals .chip'); // 회복 일정 플래너에서 고른 체류 기간과 겹치는 행사를 먼저 보여 준다.
sc.forEach(function (c) { // 기간은 플래너 링크의 URL 해시(#plan:YYYY-MM-DD..YYYY-MM-DD) 또는 이 기기의 localStorage 에서만 읽는다. 서버로 보내지 않는다.
c.addEventListener('click', function () { // 기간이 없거나 겹치는 행사가 없으면 화면은 계절 묶음 그대로다.
sc.forEach(function (x) { x.classList.toggle('on', x === c); }); (function () {
var want = c.dataset.season; var sec = document.getElementById('festivals'); if (!sec) return;
document.querySelectorAll('#festivals .card').forEach(function (card) { var chips = Array.prototype.slice.call(sec.querySelectorAll('.chip'));
card.hidden = !(want === 'all' || card.dataset.season === want); 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> </script>
</Base> </Base>

View File

@ -321,7 +321,9 @@ const ld = [
<p class="sub">{festivals.length > 0 <p class="sub">{festivals.length > 0
? `${festKm ? `${festKm}km 안의 ` : ''}행사 ${festivals.length}건을 계절로 묶었습니다. 지금은 ` ? `${festKm ? `${festKm}km 안의 ` : ''}행사 ${festivals.length}건을 계절로 묶었습니다. 지금은 `
: '병원 위치를 확인한 뒤에 채워집니다. 확인 대기. 지금은 '}<strong>{nowSeason}</strong>입니다.</p> : '병원 위치를 확인한 뒤에 채워집니다. 확인 대기. 지금은 '}<strong>{nowSeason}</strong>입니다.</p>
<p class="sub" data-plan-note hidden></p>
{festivals.length > 0 && <div class="chips" role="group" aria-label="계절로 거르기"> {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> <button class="chip on" data-season="all">전체 <b>{festivals.length}</b></button>
{SEASONS.map((s) => { {SEASONS.map((s) => {
const n = festivals.filter((e: any) => e.season === s.key).length; const n = festivals.filter((e: any) => e.season === s.key).length;
@ -330,7 +332,7 @@ const ld = [
</div>} </div>}
<div class="cards fest"> <div class="cards fest">
{festivals.map((e: any) => ( {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"> <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>} {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> <span class="badge mono">{MONTHS[e.month] ?? ''}</span>
@ -500,16 +502,63 @@ const ld = [
.catch(function () { /* 못 받으면 숨긴 채 둔다 */ }); .catch(function () { /* 못 받으면 숨긴 채 둔다 */ });
})(); })();
var sc = document.querySelectorAll('#festivals .chip'); // 회복 일정 플래너에서 고른 체류 기간과 겹치는 행사를 먼저 보여 준다.
sc.forEach(function (c) { // 기간은 플래너 링크의 URL 해시(#plan:YYYY-MM-DD..YYYY-MM-DD) 또는 이 기기의 localStorage 에서만 읽는다. 서버로 보내지 않는다.
c.addEventListener('click', function () { // 기간이 없거나 겹치는 행사가 없으면 화면은 계절 묶음 그대로다.
sc.forEach(function (x) { x.classList.toggle('on', x === c); }); (function () {
var want = c.dataset.season; var sec = document.getElementById('festivals'); if (!sec) return;
document.querySelectorAll('#festivals .card').forEach(function (card) { var chips = Array.prototype.slice.call(sec.querySelectorAll('.chip'));
card.hidden = !(want === 'all' || card.dataset.season === want); 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> </script>
</Base> </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 em { font-style: normal; opacity: 0.7; margin-left: 0.25em; }
.stay .card .badge.mono { letter-spacing: 0.06em; } .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 .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 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; } .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 에 소유 확인 메타와 플래너 계측이 이 이유로 배포되지 않았다. // 기존 병원 폴더에 닿지 않는다. 2026-09-14 에 소유 확인 메타와 플래너 계측이 이 이유로 배포되지 않았다.
// 병원별로 갈리는 것(astro.config.mjs·vercel.json 의 치환값, 구조 기준선, home_text)은 덮지 않는다. // 병원별로 갈리는 것(astro.config.mjs·vercel.json 의 치환값, 구조 기준선, home_text)은 덮지 않는다.
const CODE_DIRS = ['src/layouts', 'src/components', 'src/pages', 'src/styles', 'src/lib']; 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() { function refreshTemplateCode() {
if (!existsSync(TEMPLATE)) return '템플릿 없음'; if (!existsSync(TEMPLATE)) return '템플릿 없음';
let n = 0; let n = 0;