From 796fa5610671575fd05acfde23353e82ecb775db Mon Sep 17 00:00:00 2001 From: Haewon Kam Date: Wed, 16 Sep 2026 09:47:01 +0900 Subject: [PATCH] =?UTF-8?q?feat(supporters):=20=EC=A3=BC=EB=B3=80=20?= =?UTF-8?q?=EC=95=88=EB=82=B4=20=EC=B6=95=EC=A0=9C=EB=A5=BC=20=ED=9A=8C?= =?UTF-8?q?=EB=B3=B5=20=EC=9D=BC=EC=A0=95=EC=9D=98=20=EC=B2=B4=EB=A5=98=20?= =?UTF-8?q?=EA=B8=B0=EA=B0=84=20=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=A8=BC=EC=A0=80=20=EB=B3=B4=EC=97=AC=20=EC=A4=80=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 주변 안내(/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 --- supporters/src/components/Planner.astro | 13 +++- supporters/src/data/planStrings.json | 6 +- supporters/src/pages/en/stay.astro | 67 ++++++++++++++++--- supporters/src/pages/stay.astro | 67 ++++++++++++++++--- supporters/src/styles/global.css | 4 ++ .../src/components/Planner.astro | 13 +++- .../src/data/planStrings.json | 6 +- .../supporters-astro/src/pages/en/stay.astro | 67 ++++++++++++++++--- .../supporters-astro/src/pages/stay.astro | 67 ++++++++++++++++--- .../supporters-astro/src/styles/global.css | 4 ++ workers/supporters-build/run.mjs | 4 +- 11 files changed, 275 insertions(+), 43 deletions(-) diff --git a/supporters/src/components/Planner.astro b/supporters/src/components/Planner.astro index 413425e..33f08e6 100644 --- a/supporters/src/components/Planner.astro +++ b/supporters/src/components/Planner.astro @@ -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 `
${f.image ? `${esc(f.title)}` : ''}
${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 }))}

${esc(f.title)}

${esc(f.blurb)}

${esc(from)} ${f.url ? `${esc(t.readMoreShort)} · ` : ''}${esc(t.map)}

`; }).join(''); - const festSec = `
${secHead('Events', t.h_fest, t.sub_fest)}${festCards ? `
${festCards}
` : `

${esc(t.fest_none)}

`}${tourDate ? `

${esc(fill(t.source_tour, { date: tourDate }))}

` : ''}
`; + const stayLink = `

${esc(t.fest_stay_link)} →

`; + const festSec = `
${secHead('Events', t.h_fest, t.sub_fest)}${festCards ? `
${festCards}
` : `

${esc(t.fest_none)}

`}${stayLink}${tourDate ? `

${esc(fill(t.source_tour, { date: tourDate }))}

` : ''}
`; // 다크 섹션: 예약과 저장 (큐레이션 숙소 + 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 = []; @@ -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) { diff --git a/supporters/src/data/planStrings.json b/supporters/src/data/planStrings.json index 0802864..64c5fdf 100644 --- a/supporters/src/data/planStrings.json +++ b/supporters/src/data/planStrings.json @@ -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" } } diff --git a/supporters/src/pages/en/stay.astro b/supporters/src/pages/en/stay.astro index da0f913..477075d 100644 --- a/supporters/src/pages/en/stay.astro +++ b/supporters/src/pages/en/stay.astro @@ -320,7 +320,9 @@ const ld = [

{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 '}{nowSeason}.

+ {festivals.length > 0 &&
+ {SEASONS.map((s) => { const n = festivals.filter((e: any) => e.season === s.key).length; @@ -329,7 +331,7 @@ const ld = [
}
{festivals.map((e: any) => ( -
+
{e.image ? {`${e.title} : {shortName(e.title)}} {MONTHS[e.month] ?? ''} @@ -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(); + })(); })(); diff --git a/supporters/src/pages/stay.astro b/supporters/src/pages/stay.astro index edb22f8..594bbb8 100644 --- a/supporters/src/pages/stay.astro +++ b/supporters/src/pages/stay.astro @@ -321,7 +321,9 @@ const ld = [

{festivals.length > 0 ? `${festKm ? `${festKm}km 안의 ` : ''}행사 ${festivals.length}건을 계절로 묶었습니다. 지금은 ` : '병원 위치를 확인한 뒤에 채워집니다. 확인 대기. 지금은 '}{nowSeason}입니다.

+ {festivals.length > 0 &&
+ {SEASONS.map((s) => { const n = festivals.filter((e: any) => e.season === s.key).length; @@ -330,7 +332,7 @@ const ld = [
}
{festivals.map((e: any) => ( -
+
{e.image ? {`${e.title}, : {shortName(e.title)}} {MONTHS[e.month] ?? ''} @@ -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(); + })(); })(); diff --git a/supporters/src/styles/global.css b/supporters/src/styles/global.css index 39b3144..721ad63 100644 --- a/supporters/src/styles/global.css +++ b/supporters/src/styles/global.css @@ -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; } diff --git a/templates/supporters-astro/src/components/Planner.astro b/templates/supporters-astro/src/components/Planner.astro index 413425e..33f08e6 100644 --- a/templates/supporters-astro/src/components/Planner.astro +++ b/templates/supporters-astro/src/components/Planner.astro @@ -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 `
${f.image ? `${esc(f.title)}` : ''}
${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 }))}

${esc(f.title)}

${esc(f.blurb)}

${esc(from)} ${f.url ? `${esc(t.readMoreShort)} · ` : ''}${esc(t.map)}

`; }).join(''); - const festSec = `
${secHead('Events', t.h_fest, t.sub_fest)}${festCards ? `
${festCards}
` : `

${esc(t.fest_none)}

`}${tourDate ? `

${esc(fill(t.source_tour, { date: tourDate }))}

` : ''}
`; + const stayLink = `

${esc(t.fest_stay_link)} →

`; + const festSec = `
${secHead('Events', t.h_fest, t.sub_fest)}${festCards ? `
${festCards}
` : `

${esc(t.fest_none)}

`}${stayLink}${tourDate ? `

${esc(fill(t.source_tour, { date: tourDate }))}

` : ''}
`; // 다크 섹션: 예약과 저장 (큐레이션 숙소 + 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 = []; @@ -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) { diff --git a/templates/supporters-astro/src/data/planStrings.json b/templates/supporters-astro/src/data/planStrings.json index 0802864..64c5fdf 100644 --- a/templates/supporters-astro/src/data/planStrings.json +++ b/templates/supporters-astro/src/data/planStrings.json @@ -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" } } diff --git a/templates/supporters-astro/src/pages/en/stay.astro b/templates/supporters-astro/src/pages/en/stay.astro index da0f913..477075d 100644 --- a/templates/supporters-astro/src/pages/en/stay.astro +++ b/templates/supporters-astro/src/pages/en/stay.astro @@ -320,7 +320,9 @@ const ld = [

{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 '}{nowSeason}.

+ {festivals.length > 0 &&
+ {SEASONS.map((s) => { const n = festivals.filter((e: any) => e.season === s.key).length; @@ -329,7 +331,7 @@ const ld = [
}
{festivals.map((e: any) => ( -
+
{e.image ? {`${e.title} : {shortName(e.title)}} {MONTHS[e.month] ?? ''} @@ -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(); + })(); })(); diff --git a/templates/supporters-astro/src/pages/stay.astro b/templates/supporters-astro/src/pages/stay.astro index edb22f8..594bbb8 100644 --- a/templates/supporters-astro/src/pages/stay.astro +++ b/templates/supporters-astro/src/pages/stay.astro @@ -321,7 +321,9 @@ const ld = [

{festivals.length > 0 ? `${festKm ? `${festKm}km 안의 ` : ''}행사 ${festivals.length}건을 계절로 묶었습니다. 지금은 ` : '병원 위치를 확인한 뒤에 채워집니다. 확인 대기. 지금은 '}{nowSeason}입니다.

+ {festivals.length > 0 &&
+ {SEASONS.map((s) => { const n = festivals.filter((e: any) => e.season === s.key).length; @@ -330,7 +332,7 @@ const ld = [
}
{festivals.map((e: any) => ( -
+
{e.image ? {`${e.title}, : {shortName(e.title)}} {MONTHS[e.month] ?? ''} @@ -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(); + })(); })(); diff --git a/templates/supporters-astro/src/styles/global.css b/templates/supporters-astro/src/styles/global.css index 39b3144..721ad63 100644 --- a/templates/supporters-astro/src/styles/global.css +++ b/templates/supporters-astro/src/styles/global.css @@ -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; } diff --git a/workers/supporters-build/run.mjs b/workers/supporters-build/run.mjs index 2316609..eccdfc7 100644 --- a/workers/supporters-build/run.mjs +++ b/workers/supporters-build/run.mjs @@ -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;