tripkit 1.1.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +48 -5
- package/README.md +34 -3
- package/agent/SKILL.md +168 -0
- package/convert.js +63 -0
- package/docs/MEDIA-GUIDE.md +298 -0
- package/docs/README.md +1 -0
- package/examples/new-england-fall-2026.yaml +471 -0
- package/examples/nyc-long-weekend-2026.yaml +283 -0
- package/examples/oregon-spring-2026.yaml +17 -0
- package/examples/southwest-parks-2026.yaml +463 -0
- package/media-ingest.js +389 -0
- package/package.json +14 -3
- package/renderers/html/tripkit-renderer.html +341 -33
- package/schema/tripkit.schema.yaml +25 -0
- package/scripts/check-skill-coverage.js +66 -0
- package/scripts/test-media-lifecycle.js +190 -0
- package/validate.js +97 -0
- package/agent/AGENT-SKILL.md +0 -105
|
@@ -9,11 +9,45 @@
|
|
|
9
9
|
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;1,9..40,300;1,9..40,400&family=Instrument+Serif:ital@0;1&display=swap" rel="stylesheet">
|
|
10
10
|
<style>
|
|
11
11
|
*{margin:0;padding:0;box-sizing:border-box}
|
|
12
|
-
:root{
|
|
13
|
-
--
|
|
14
|
-
--
|
|
15
|
-
--
|
|
16
|
-
--
|
|
12
|
+
:root{
|
|
13
|
+
--bg:#f4f1eb;--card:#fff;--text:#1a1a18;--muted:#7a7768;--subtle:#b5b1a4;
|
|
14
|
+
--accent:#1b5e3b;--accent-soft:#dceee4;
|
|
15
|
+
--warn:#c44b25;--warn-soft:#fdeee8;
|
|
16
|
+
--blue:#2563a8;--blue-soft:#e8f0fa;
|
|
17
|
+
--purple:#5b44b0;--purple-soft:#eeeafb;
|
|
18
|
+
--amber:#a16207;--amber-soft:#fdf4e3;
|
|
19
|
+
--border:rgba(0,0,0,.08);
|
|
20
|
+
--shadow:0 2px 10px rgba(0,0,0,.08);
|
|
21
|
+
--shadow-lift:0 6px 18px rgba(0,0,0,.10);
|
|
22
|
+
--b-hike-bg:#dceee4;--b-hike-fg:#1b5e3b;
|
|
23
|
+
--b-scenic-bg:#e8f0fa;--b-scenic-fg:#2563a8;
|
|
24
|
+
--b-food-bg:#fdeee8;--b-food-fg:#c44b25;
|
|
25
|
+
--b-city-bg:#eeeafb;--b-city-fg:#5b44b0;
|
|
26
|
+
--b-activity-bg:#fdf4e3;--b-activity-fg:#a16207;
|
|
27
|
+
--b-beach-bg:#e0f7fa;--b-beach-fg:#00838f;
|
|
28
|
+
--b-museum-bg:#fce4ec;--b-museum-fg:#c62828;
|
|
29
|
+
--b-shopping-bg:#f3e5f5;--b-shopping-fg:#7b1fa2;
|
|
30
|
+
--font:'DM Sans',system-ui,sans-serif;--serif:'Instrument Serif',Georgia,serif;--r:10px
|
|
31
|
+
}
|
|
32
|
+
[data-theme="dark"]{
|
|
33
|
+
--bg:#14140f;--card:#1f1d18;--text:#f0ede4;--muted:#908a78;--subtle:#5a564a;
|
|
34
|
+
--accent:#5dad7d;--accent-soft:rgba(93,173,125,.15);
|
|
35
|
+
--warn:#e07555;--warn-soft:rgba(224,117,85,.13);
|
|
36
|
+
--blue:#5b9adc;--blue-soft:rgba(91,154,220,.13);
|
|
37
|
+
--purple:#9582d8;--purple-soft:rgba(149,130,216,.13);
|
|
38
|
+
--amber:#d4a444;--amber-soft:rgba(212,164,68,.13);
|
|
39
|
+
--border:rgba(255,255,255,.08);
|
|
40
|
+
--shadow:0 2px 12px rgba(0,0,0,.4);
|
|
41
|
+
--shadow-lift:0 8px 24px rgba(0,0,0,.5);
|
|
42
|
+
--b-hike-bg:rgba(93,173,125,.18);--b-hike-fg:#7dc99f;
|
|
43
|
+
--b-scenic-bg:rgba(91,154,220,.18);--b-scenic-fg:#7dadde;
|
|
44
|
+
--b-food-bg:rgba(224,117,85,.18);--b-food-fg:#ec9270;
|
|
45
|
+
--b-city-bg:rgba(149,130,216,.18);--b-city-fg:#b09fe0;
|
|
46
|
+
--b-activity-bg:rgba(212,164,68,.18);--b-activity-fg:#dcb966;
|
|
47
|
+
--b-beach-bg:rgba(50,180,200,.18);--b-beach-fg:#5fc8d8;
|
|
48
|
+
--b-museum-bg:rgba(220,90,120,.18);--b-museum-fg:#e08aa0;
|
|
49
|
+
--b-shopping-bg:rgba(180,100,200,.18);--b-shopping-fg:#c898d8;
|
|
50
|
+
}
|
|
17
51
|
html,body{height:100%;overflow:hidden}
|
|
18
52
|
body{font-family:var(--font);background:var(--bg);color:var(--text)}
|
|
19
53
|
.app{display:grid;grid-template-columns:460px 1fr;height:100vh}
|
|
@@ -37,6 +71,10 @@ body{font-family:var(--font);background:var(--bg);color:var(--text)}
|
|
|
37
71
|
.day-btn:hover{background:var(--bg);color:var(--text)}
|
|
38
72
|
.day-btn.active{background:var(--accent);color:#fff}
|
|
39
73
|
.day-btn.has-alert{box-shadow:inset 0 -2px 0 var(--warn)}
|
|
74
|
+
.day-btn[data-status="completed"]{opacity:.5}
|
|
75
|
+
.day-btn[data-status="completed"].active{opacity:1}
|
|
76
|
+
.day-btn[data-status="active"]::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--accent);display:inline-block;margin-right:6px;vertical-align:middle}
|
|
77
|
+
.day-btn[data-status="active"].active::before{background:#fff}
|
|
40
78
|
.day-panel{display:none;padding:16px 22px 40px}
|
|
41
79
|
.day-panel.active{display:block;animation:fadeIn .25s ease}
|
|
42
80
|
@keyframes fadeIn{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
|
|
@@ -55,16 +93,16 @@ body{font-family:var(--font);background:var(--bg);color:var(--text)}
|
|
|
55
93
|
.callout.sleep{background:var(--purple-soft);border-left:3px solid var(--purple)}
|
|
56
94
|
.cl{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;margin-bottom:3px}
|
|
57
95
|
.callout.warn .cl{color:var(--warn)}.callout.tip .cl{color:var(--accent)}.callout.weather .cl{color:var(--blue)}.callout.eat .cl{color:var(--amber)}.callout.sleep .cl{color:var(--purple)}
|
|
58
|
-
.stop{display:flex;gap:10px;padding:10px 0;border-top:1px solid var(--border);cursor:pointer;transition:background .
|
|
59
|
-
.stop:hover{background:var(--bg);margin:0 -10px;padding:10px;border-radius:8px}
|
|
96
|
+
.stop{display:flex;gap:10px;padding:10px 0;border-top:1px solid var(--border);cursor:pointer;transition:background .15s,transform .15s,box-shadow .15s}
|
|
97
|
+
.stop:hover{background:var(--bg);margin:0 -10px;padding:10px;border-radius:8px;box-shadow:var(--shadow-lift);transform:translateY(-1px)}
|
|
60
98
|
.stop:first-of-type{border-top:none}
|
|
61
99
|
.stop-img{width:72px;height:54px;border-radius:7px;object-fit:cover;flex-shrink:0;background:linear-gradient(135deg,var(--accent-soft),var(--blue-soft))}
|
|
62
100
|
.stop-body{flex:1;min-width:0}
|
|
63
101
|
.stop-name{font-weight:500;font-size:13px;display:flex;align-items:center;gap:6px}
|
|
64
102
|
.badge{font-size:8px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;padding:2px 6px;border-radius:3px;flex-shrink:0}
|
|
65
|
-
.b-hike{background
|
|
66
|
-
.b-city{background
|
|
67
|
-
.b-museum{background
|
|
103
|
+
.b-hike{background:var(--b-hike-bg);color:var(--b-hike-fg)}.b-scenic{background:var(--b-scenic-bg);color:var(--b-scenic-fg)}.b-food{background:var(--b-food-bg);color:var(--b-food-fg)}
|
|
104
|
+
.b-city{background:var(--b-city-bg);color:var(--b-city-fg)}.b-activity{background:var(--b-activity-bg);color:var(--b-activity-fg)}.b-beach{background:var(--b-beach-bg);color:var(--b-beach-fg)}
|
|
105
|
+
.b-museum{background:var(--b-museum-bg);color:var(--b-museum-fg)}.b-shopping{background:var(--b-shopping-bg);color:var(--b-shopping-fg)}
|
|
68
106
|
.stop-desc{font-size:11px;color:var(--muted);line-height:1.45;margin-top:2px}
|
|
69
107
|
.stop-meta{display:flex;gap:8px;margin-top:3px;font-size:10px;color:var(--subtle)}
|
|
70
108
|
.sec-label{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--subtle);margin:14px 0 6px;padding-top:6px;border-top:1px solid var(--border)}
|
|
@@ -79,21 +117,59 @@ body{font-family:var(--font);background:var(--bg);color:var(--text)}
|
|
|
79
117
|
.lr{display:flex;align-items:center;gap:6px}.lr i{width:16px;height:3px;border-radius:2px;display:inline-block}
|
|
80
118
|
.fullbtn{position:absolute;bottom:16px;right:12px;z-index:1000;background:var(--card);border:1px solid var(--border);border-radius:8px;padding:6px 12px;font-family:var(--font);font-size:11px;font-weight:500;cursor:pointer;box-shadow:0 2px 10px rgba(0,0,0,.1)}
|
|
81
119
|
.fullbtn:hover{background:var(--text);color:#fff}
|
|
82
|
-
.leaflet-popup-content-wrapper{border-radius:10px!important;box-shadow:0 4px 16px rgba(0,0,0,.
|
|
120
|
+
.leaflet-popup-content-wrapper{border-radius:10px!important;box-shadow:0 4px 16px rgba(0,0,0,.18)!important;border:1px solid var(--border)!important;padding:0!important;overflow:hidden;background:var(--card)!important;color:var(--text)!important}
|
|
83
121
|
.leaflet-popup-content{margin:0!important;width:280px!important;font-family:var(--font)!important}
|
|
84
|
-
.leaflet-popup-tip{border-top-color:var(--card)!important}
|
|
122
|
+
.leaflet-popup-tip{background:var(--card)!important;border-top-color:var(--card)!important}
|
|
85
123
|
.pp-img{width:100%;height:140px;object-fit:cover;display:block;background:linear-gradient(135deg,var(--accent-soft),var(--blue-soft))}
|
|
86
124
|
.pp-body{padding:12px 14px}
|
|
87
125
|
.pp-day{font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);margin-bottom:3px}
|
|
88
126
|
.pp-name{font-family:var(--serif);font-size:16px;line-height:1.25;margin-bottom:5px}
|
|
89
127
|
.pp-desc{font-size:11px;color:var(--muted);line-height:1.5}
|
|
90
|
-
.cm{display:flex;align-items:center;justify-content:center;width:26px;height:26px;border-radius:50%;font-weight:600;font-size:10px;color:#fff;box-shadow:0 2px 5px rgba(0,0,0,.3);border:2px solid #fff;font-family:var(--font);cursor:pointer;transition:transform .12s}
|
|
128
|
+
.cm{display:flex;align-items:center;justify-content:center;width:26px;height:26px;border-radius:50%;font-weight:600;font-size:10px;color:#fff;box-shadow:0 2px 5px rgba(0,0,0,.3);border:2px solid #fff;font-family:var(--font);cursor:pointer;transition:transform .12s;position:relative}
|
|
91
129
|
.cm:hover{transform:scale(1.2)}
|
|
130
|
+
/* Media cue on a stop marker: camera + count, clickable straight to the lightbox. */
|
|
131
|
+
.cm-media{position:absolute;top:-7px;right:-9px;display:flex;align-items:center;gap:1px;height:15px;padding:0 4px;border-radius:8px;background:#fff;color:var(--accent);font-size:9px;font-weight:700;line-height:1;box-shadow:0 1px 4px rgba(0,0,0,.35);border:1.5px solid var(--accent);cursor:pointer;transition:transform .12s}
|
|
132
|
+
.cm-media:hover{transform:scale(1.18)}
|
|
133
|
+
.cm-media .ic{font-size:8px;line-height:1}
|
|
92
134
|
.hm{display:flex;align-items:center;justify-content:center;width:32px;height:32px;border-radius:6px;
|
|
93
135
|
font-weight:700;font-size:9px;color:#1b5e3b;background:#fff;box-shadow:0 2px 8px rgba(0,0,0,.35);
|
|
94
136
|
border:2px solid #1b5e3b;font-family:var(--font);cursor:pointer;transition:transform .12s;letter-spacing:-.02em}
|
|
95
137
|
.hm:hover{transform:scale(1.15)}
|
|
96
138
|
.pp-conf{font-size:11px;font-weight:600;color:var(--accent);background:var(--accent-soft);padding:4px 8px;border-radius:4px;display:inline-block;margin-top:6px;letter-spacing:.02em}
|
|
139
|
+
.endpin{display:flex;align-items:center;justify-content:center;width:28px;height:34px;font-size:11px;font-weight:700;color:#fff;font-family:var(--font);border-radius:14px 14px 14px 2px;transform:rotate(-45deg);box-shadow:0 2px 6px rgba(0,0,0,.35);border:2px solid #fff;cursor:pointer}
|
|
140
|
+
.endpin span{transform:rotate(45deg);letter-spacing:.02em}
|
|
141
|
+
.endpin.start{background:#1b5e3b}
|
|
142
|
+
.endpin.end{background:#7a1f1f}
|
|
143
|
+
/* --- Media gallery (sidebar strip + count badge) --- */
|
|
144
|
+
.stop-media{display:flex;gap:4px;margin-top:6px;flex-wrap:wrap}
|
|
145
|
+
.stop-media .gthumb{width:46px;height:46px;border-radius:6px;object-fit:cover;cursor:pointer;background:var(--bg);border:1px solid var(--border);transition:transform .12s}
|
|
146
|
+
.stop-media .gthumb:hover{transform:scale(1.06)}
|
|
147
|
+
.stop-media .gmore{display:flex;align-items:center;justify-content:center;width:46px;height:46px;border-radius:6px;background:var(--accent-soft);color:var(--accent);font-size:11px;font-weight:600;cursor:pointer;border:1px solid var(--border)}
|
|
148
|
+
.gthumb-wrap{position:relative;display:inline-block;line-height:0}
|
|
149
|
+
.gthumb-wrap .vbadge{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:0;height:0;border-left:11px solid #fff;border-top:7px solid transparent;border-bottom:7px solid transparent;filter:drop-shadow(0 0 2px rgba(0,0,0,.6));pointer-events:none}
|
|
150
|
+
.pp-media{display:flex;gap:4px;padding:8px 14px 0;flex-wrap:wrap}
|
|
151
|
+
.pp-media .gthumb{width:40px;height:40px;border-radius:5px;object-fit:cover;cursor:pointer;border:1px solid var(--border)}
|
|
152
|
+
.media-count{display:inline-flex;align-items:center;gap:3px;font-size:9px;font-weight:600;color:var(--accent);background:var(--accent-soft);padding:1px 6px;border-radius:10px;margin-left:4px}
|
|
153
|
+
/* --- Photo-pin marker --- */
|
|
154
|
+
.photopin{width:14px;height:14px;border-radius:50%;background:#fff;border:2px solid var(--accent);box-shadow:0 1px 4px rgba(0,0,0,.4);cursor:pointer;transition:transform .12s}
|
|
155
|
+
.photopin:hover{transform:scale(1.35)}
|
|
156
|
+
.mbtn.toggle.active{background:var(--accent);color:#fff;border-color:var(--accent)}
|
|
157
|
+
/* --- Lightbox --- */
|
|
158
|
+
.lightbox{position:fixed;inset:0;z-index:5000;background:rgba(0,0,0,.92);display:none;align-items:center;justify-content:center;flex-direction:column}
|
|
159
|
+
.lightbox.open{display:flex;animation:lbFade .2s ease}
|
|
160
|
+
@keyframes lbFade{from{opacity:0}to{opacity:1}}
|
|
161
|
+
.lb-stage{position:relative;max-width:92vw;max-height:78vh;display:flex;align-items:center;justify-content:center}
|
|
162
|
+
.lb-stage img,.lb-stage video{max-width:92vw;max-height:78vh;border-radius:8px;box-shadow:0 8px 40px rgba(0,0,0,.6);object-fit:contain;background:#000}
|
|
163
|
+
.lb-cap{color:#f0ede4;font-family:var(--font);font-size:13px;text-align:center;margin-top:14px;max-width:80vw;line-height:1.5;min-height:18px}
|
|
164
|
+
.lb-meta{color:#908a78;font-size:11px;margin-top:4px}
|
|
165
|
+
.lb-count{position:absolute;top:20px;left:0;right:0;text-align:center;color:#cbc8bd;font-size:12px;font-family:var(--font);letter-spacing:.04em;pointer-events:none}
|
|
166
|
+
.lb-close{position:absolute;top:16px;right:22px;width:38px;height:38px;border:none;border-radius:50%;background:rgba(255,255,255,.12);color:#fff;font-size:20px;cursor:pointer;line-height:1;z-index:2}
|
|
167
|
+
.lb-close:hover{background:rgba(255,255,255,.24)}
|
|
168
|
+
.lb-nav{position:absolute;top:50%;transform:translateY(-50%);width:48px;height:48px;border:none;border-radius:50%;background:rgba(255,255,255,.12);color:#fff;font-size:24px;cursor:pointer;display:flex;align-items:center;justify-content:center}
|
|
169
|
+
.lb-nav:hover{background:rgba(255,255,255,.24)}
|
|
170
|
+
.lb-prev{left:18px}.lb-next{right:18px}
|
|
171
|
+
.lb-nav[disabled]{opacity:.25;cursor:default}
|
|
172
|
+
@media(max-width:480px){.lb-nav{width:40px;height:40px;font-size:20px}.lb-close{top:10px;right:12px}}
|
|
97
173
|
@media(max-width:480px){
|
|
98
174
|
.hero{height:140px}.hero-text{padding:12px 14px}.hero-text h1{font-size:20px}
|
|
99
175
|
.day-nav{padding:8px 10px;gap:1px}.day-btn{padding:5px 8px;font-size:10px}
|
|
@@ -123,6 +199,15 @@ body{font-family:var(--font);background:var(--bg);color:var(--text)}
|
|
|
123
199
|
<div class="legend" id="legend"></div>
|
|
124
200
|
<button class="fullbtn" onclick="showAll()">Full route</button>
|
|
125
201
|
</div></div>
|
|
202
|
+
<div class="lightbox" id="lightbox" role="dialog" aria-modal="true" aria-label="Photo viewer">
|
|
203
|
+
<button class="lb-close" id="lbClose" aria-label="Close">×</button>
|
|
204
|
+
<div class="lb-count" id="lbCount"></div>
|
|
205
|
+
<button class="lb-nav lb-prev" id="lbPrev" aria-label="Previous">‹</button>
|
|
206
|
+
<div class="lb-stage" id="lbStage"></div>
|
|
207
|
+
<button class="lb-nav lb-next" id="lbNext" aria-label="Next">›</button>
|
|
208
|
+
<div class="lb-cap" id="lbCap"></div>
|
|
209
|
+
<div class="lb-meta" id="lbMeta"></div>
|
|
210
|
+
</div>
|
|
126
211
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script>
|
|
127
212
|
<script>
|
|
128
213
|
// ============================================================
|
|
@@ -152,6 +237,7 @@ const TRIP_DATA = {};
|
|
|
152
237
|
const theme = data.theme || {};
|
|
153
238
|
|
|
154
239
|
// --- Apply theme ---
|
|
240
|
+
if (theme.dark_mode) document.documentElement.setAttribute('data-theme', 'dark');
|
|
155
241
|
if (theme.accent_color) document.documentElement.style.setProperty('--accent', theme.accent_color);
|
|
156
242
|
if (theme.font_family) document.documentElement.style.setProperty('--font', theme.font_family);
|
|
157
243
|
|
|
@@ -172,6 +258,39 @@ const TRIP_DATA = {};
|
|
|
172
258
|
return stop.image || defaultImages[stop.type] || defaultImages.default;
|
|
173
259
|
}
|
|
174
260
|
|
|
261
|
+
// --- Post-trip media helpers ---
|
|
262
|
+
// Normalize a stop's media[] into a clean list. `src`/`thumb` may be a relative
|
|
263
|
+
// path or a URL; the renderer just assigns them as element src/href either way.
|
|
264
|
+
function getStopMedia(stop) {
|
|
265
|
+
if (!Array.isArray(stop.media)) return [];
|
|
266
|
+
return stop.media.filter(m => m && m.src).map(m => ({
|
|
267
|
+
src: m.src,
|
|
268
|
+
thumb: m.thumb || m.src,
|
|
269
|
+
type: m.type === 'video' ? 'video' : 'photo',
|
|
270
|
+
caption: m.caption || '',
|
|
271
|
+
lat: Number.isFinite(m.lat) ? m.lat : null,
|
|
272
|
+
lng: Number.isFinite(m.lng) ? m.lng : null,
|
|
273
|
+
taken_at: m.taken_at || ''
|
|
274
|
+
}));
|
|
275
|
+
}
|
|
276
|
+
function escAttr(s) { return String(s).replace(/"/g, '"'); }
|
|
277
|
+
function escHtml(s) { return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
|
278
|
+
|
|
279
|
+
// Build the sidebar thumbnail strip for a stop's media (up to 4 shown + "+N").
|
|
280
|
+
function mediaStripHTML(di, si, media) {
|
|
281
|
+
if (!media.length) return '';
|
|
282
|
+
const MAX = 4;
|
|
283
|
+
let html = '<div class="stop-media">';
|
|
284
|
+
media.slice(0, MAX).forEach((m, mi) => {
|
|
285
|
+
html += `<span class="gthumb-wrap"><img class="gthumb" src="${escAttr(m.thumb)}" alt="${escAttr(m.caption || 'photo')}" loading="lazy" onclick="event.stopPropagation();openLightbox(${di},${si},${mi})" onerror="this.style.opacity=.25">${m.type === 'video' ? '<span class="vbadge"></span>' : ''}</span>`;
|
|
286
|
+
});
|
|
287
|
+
if (media.length > MAX) {
|
|
288
|
+
html += `<span class="gmore" onclick="event.stopPropagation();openLightbox(${di},${si},${MAX})">+${media.length - MAX}</span>`;
|
|
289
|
+
}
|
|
290
|
+
html += '</div>';
|
|
291
|
+
return html;
|
|
292
|
+
}
|
|
293
|
+
|
|
175
294
|
function getDayHero(day) {
|
|
176
295
|
// Use first stop's image as day hero
|
|
177
296
|
if (day.stops && day.stops.length > 0) return getImage(day.stops[0]);
|
|
@@ -202,6 +321,7 @@ const TRIP_DATA = {};
|
|
|
202
321
|
// Nav button
|
|
203
322
|
const b = document.createElement('button');
|
|
204
323
|
b.className = 'day-btn' + (i === 0 ? ' active' : '') + (hasAlerts ? ' has-alert' : '');
|
|
324
|
+
if (d.status) b.dataset.status = d.status;
|
|
205
325
|
b.textContent = `Day ${d.number}`;
|
|
206
326
|
b.onclick = () => sel(i);
|
|
207
327
|
nav.appendChild(b);
|
|
@@ -262,13 +382,16 @@ const TRIP_DATA = {};
|
|
|
262
382
|
h += `<div class="stop" onclick="tripFocus(${i},${si})">`;
|
|
263
383
|
h += `<img class="stop-img" src="${img}" alt="${s.name}" loading="lazy" onerror="this.style.opacity=0">`;
|
|
264
384
|
h += `<div class="stop-body">`;
|
|
265
|
-
|
|
385
|
+
const sMedia = getStopMedia(s);
|
|
386
|
+
const countBadge = sMedia.length ? `<span class="media-count">📷 ${sMedia.length}</span>` : '';
|
|
387
|
+
h += `<div class="stop-name"><span class="badge ${bc}">${s.label || s.type}</span>${s.name}${countBadge}</div>`;
|
|
266
388
|
h += `<div class="stop-desc">${s.description || ''}</div>`;
|
|
267
389
|
const meta = [];
|
|
268
390
|
if (s.duration) meta.push(`⏱ ${s.duration}`);
|
|
269
391
|
if (s.parking_fee) meta.push(`🅿 ${s.parking_fee}`);
|
|
270
392
|
if (s.kid_friendly === false) meta.push(`⚠ Not kid-friendly`);
|
|
271
393
|
if (meta.length) h += `<div class="stop-meta">${meta.map(m => `<span>${m}</span>`).join('')}</div>`;
|
|
394
|
+
h += mediaStripHTML(i, si, sMedia);
|
|
272
395
|
h += `</div></div>`;
|
|
273
396
|
});
|
|
274
397
|
}
|
|
@@ -329,14 +452,33 @@ const TRIP_DATA = {};
|
|
|
329
452
|
L.polyline(r.points, { color: r.color || '#666', weight: r.width || 4, opacity: 0.8 }).addTo(map);
|
|
330
453
|
});
|
|
331
454
|
} else {
|
|
332
|
-
// Auto-generate routes
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
455
|
+
// Auto-generate routes. For each day, the polyline runs:
|
|
456
|
+
// prev day's lodging (if any) → today's stops → today's lodging (if any)
|
|
457
|
+
// This stitches consecutive days together so the map reads as one continuous trip.
|
|
458
|
+
// Day 1 also anchors to trip.origin, and the final day returns to the
|
|
459
|
+
// destination (or origin, for a round trip), so the Start/End pins connect to
|
|
460
|
+
// the route instead of floating unattached.
|
|
461
|
+
const isAnchorLodging = (l) => l && l.lat && l.lng && l.name !== 'Home';
|
|
462
|
+
const hasOrigin = Number.isFinite(trip.origin_lat) && Number.isFinite(trip.origin_lng);
|
|
463
|
+
const destLat = Number.isFinite(trip.destination_lat) ? trip.destination_lat : (hasOrigin ? trip.origin_lat : null);
|
|
464
|
+
const destLng = Number.isFinite(trip.destination_lng) ? trip.destination_lng : (hasOrigin ? trip.origin_lng : null);
|
|
465
|
+
const pushUnique = (pts, p) => {
|
|
466
|
+
const last = pts[pts.length - 1];
|
|
467
|
+
if (!last || last[0] !== p[0] || last[1] !== p[1]) pts.push(p);
|
|
468
|
+
};
|
|
469
|
+
days.forEach((d, di) => {
|
|
470
|
+
const pts = [];
|
|
471
|
+
if (di === 0 && hasOrigin) pts.push([trip.origin_lat, trip.origin_lng]);
|
|
472
|
+
const prevLodging = di > 0 ? days[di - 1].lodging : null;
|
|
473
|
+
if (isAnchorLodging(prevLodging)) pushUnique(pts, [prevLodging.lat, prevLodging.lng]);
|
|
474
|
+
(d.stops || []).forEach(s => { if (s.lat && s.lng) pushUnique(pts, [s.lat, s.lng]); });
|
|
475
|
+
if (isAnchorLodging(d.lodging)) pushUnique(pts, [d.lodging.lat, d.lodging.lng]);
|
|
476
|
+
// On the last day, close the loop back to the destination/origin so the trip
|
|
477
|
+
// visibly returns home (covers the common `lodging.name: "Home"` last day).
|
|
478
|
+
if (di === days.length - 1 && destLat != null && destLng != null) pushUnique(pts, [destLat, destLng]);
|
|
479
|
+
if (pts.length > 1) {
|
|
480
|
+
L.polyline(pts, { color: '#fff', weight: 7, opacity: 0.4 }).addTo(map);
|
|
481
|
+
L.polyline(pts, { color: d.color || '#666', weight: 4, opacity: 0.8 }).addTo(map);
|
|
340
482
|
}
|
|
341
483
|
});
|
|
342
484
|
}
|
|
@@ -347,21 +489,37 @@ const TRIP_DATA = {};
|
|
|
347
489
|
const dm = [];
|
|
348
490
|
d.stops.forEach((s, si) => {
|
|
349
491
|
if (!s.lat || !s.lng) return;
|
|
492
|
+
const sMedia = getStopMedia(s);
|
|
493
|
+
// When a stop has media, overlay a camera+count badge on its marker that jumps
|
|
494
|
+
// straight to the lightbox (the badge stops propagation so the popup stays put).
|
|
495
|
+
const mediaBadge = sMedia.length
|
|
496
|
+
? `<span class="cm-media" title="${sMedia.length} photo${sMedia.length === 1 ? '' : 's'}/video" onclick="event.stopPropagation();openLightbox(${di},${si},0)"><span class="ic">📷</span>${sMedia.length}</span>`
|
|
497
|
+
: '';
|
|
350
498
|
const icon = L.divIcon({
|
|
351
499
|
className: '',
|
|
352
|
-
html: `<div class="cm" style="background:${d.color || '#666'}">${d.number}</div>`,
|
|
500
|
+
html: `<div class="cm" style="background:${d.color || '#666'}">${d.number}${mediaBadge}</div>`,
|
|
353
501
|
iconSize: [26, 26], iconAnchor: [13, 13]
|
|
354
502
|
});
|
|
355
503
|
const m = L.marker([s.lat, s.lng], { icon }).addTo(map);
|
|
356
|
-
|
|
504
|
+
// Lead the popup with the first real photo when available; else the hero/Unsplash image.
|
|
505
|
+
const img = sMedia.length ? sMedia[0].thumb : getImage(s);
|
|
506
|
+
let ppMedia = '';
|
|
507
|
+
if (sMedia.length) {
|
|
508
|
+
ppMedia = '<div class="pp-media">';
|
|
509
|
+
sMedia.slice(0, 5).forEach((mm, mi) => {
|
|
510
|
+
ppMedia += `<span class="gthumb-wrap"><img class="gthumb" src="${escAttr(mm.thumb)}" alt="${escAttr(mm.caption || 'photo')}" onclick="openLightbox(${di},${si},${mi})" onerror="this.style.opacity=.25">${mm.type === 'video' ? '<span class="vbadge"></span>' : ''}</span>`;
|
|
511
|
+
});
|
|
512
|
+
ppMedia += '</div>';
|
|
513
|
+
}
|
|
357
514
|
m.bindPopup(`<div>
|
|
358
|
-
<img class="pp-img" src="${img}" alt="${s.name}" onerror="this.style.opacity=0">
|
|
515
|
+
<img class="pp-img" src="${escAttr(img)}" alt="${escAttr(s.name)}" ${sMedia.length ? `style="cursor:pointer" onclick="openLightbox(${di},${si},0)"` : ''} onerror="this.style.opacity=0">
|
|
359
516
|
<div class="pp-body">
|
|
360
517
|
<div class="pp-day">Day ${d.number} · ${d.date}</div>
|
|
361
518
|
<div class="pp-name">${s.name}</div>
|
|
362
519
|
<div class="pp-desc">${s.description || ''}</div>
|
|
363
520
|
${s.navigate_url ? `<a href="${s.navigate_url}" target="_blank" style="display:inline-block;margin-top:6px;font-size:11px;color:var(--accent);text-decoration:none">📍 Navigate →</a>` : ''}
|
|
364
521
|
</div>
|
|
522
|
+
${ppMedia}
|
|
365
523
|
</div>`, { maxWidth: 280 });
|
|
366
524
|
m.on('click', () => sel(di));
|
|
367
525
|
dm.push(m);
|
|
@@ -369,8 +527,34 @@ const TRIP_DATA = {};
|
|
|
369
527
|
markers.push(dm);
|
|
370
528
|
});
|
|
371
529
|
|
|
530
|
+
// --- Start / End pins (origin & destination) ---
|
|
531
|
+
function endpoint(lat, lng, kind, name) {
|
|
532
|
+
const labelTxt = kind === 'start' ? 'A' : 'B';
|
|
533
|
+
const titleTxt = kind === 'start' ? 'Start' : 'End';
|
|
534
|
+
const icon = L.divIcon({
|
|
535
|
+
className: '',
|
|
536
|
+
html: `<div class="endpin ${kind}"><span>${labelTxt}</span></div>`,
|
|
537
|
+
iconSize: [28, 34], iconAnchor: [4, 32]
|
|
538
|
+
});
|
|
539
|
+
const m = L.marker([lat, lng], { icon, zIndexOffset: 800 }).addTo(map);
|
|
540
|
+
m.bindPopup(`<div class="pp-body" style="padding:14px">
|
|
541
|
+
<div class="pp-day">${titleTxt}</div>
|
|
542
|
+
<div class="pp-name">${name || titleTxt}</div>
|
|
543
|
+
</div>`, { maxWidth: 240 });
|
|
544
|
+
}
|
|
545
|
+
if (Number.isFinite(trip.origin_lat) && Number.isFinite(trip.origin_lng)) {
|
|
546
|
+
endpoint(trip.origin_lat, trip.origin_lng, 'start', trip.origin);
|
|
547
|
+
const destLat = Number.isFinite(trip.destination_lat) ? trip.destination_lat : trip.origin_lat;
|
|
548
|
+
const destLng = Number.isFinite(trip.destination_lng) ? trip.destination_lng : trip.origin_lng;
|
|
549
|
+
// Suppress the "End" pin if it's at the same coords as the Start (round trip).
|
|
550
|
+
if (Math.abs(destLat - trip.origin_lat) > 0.001 || Math.abs(destLng - trip.origin_lng) > 0.001) {
|
|
551
|
+
endpoint(destLat, destLng, 'end', trip.destination || trip.origin);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
372
555
|
// --- Hotel markers ---
|
|
373
|
-
|
|
556
|
+
// Build per-night list, then dedupe by lat/lng so multi-night stays render as one marker.
|
|
557
|
+
const nights = days
|
|
374
558
|
.filter(d => d.lodging && d.lodging.lat && d.lodging.lng && d.lodging.name !== 'Home')
|
|
375
559
|
.map((d, i) => ({
|
|
376
560
|
name: d.lodging.name,
|
|
@@ -384,11 +568,24 @@ const TRIP_DATA = {};
|
|
|
384
568
|
navigate_url: d.lodging.navigate_url
|
|
385
569
|
}));
|
|
386
570
|
|
|
387
|
-
const
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
571
|
+
const hotelGroups = new Map();
|
|
572
|
+
nights.forEach(n => {
|
|
573
|
+
const key = n.lat.toFixed(4) + ',' + n.lng.toFixed(4);
|
|
574
|
+
if (!hotelGroups.has(key)) hotelGroups.set(key, []);
|
|
575
|
+
hotelGroups.get(key).push(n);
|
|
576
|
+
});
|
|
577
|
+
const hotels = Array.from(hotelGroups.values());
|
|
578
|
+
|
|
579
|
+
const hotelLabel = theme.hotel_label
|
|
580
|
+
|| (data.agent_context?.preferences?.accommodation_chain
|
|
581
|
+
? data.agent_context.preferences.accommodation_chain.split(' ')[0].substring(0, 3).toUpperCase()
|
|
582
|
+
: '🏨');
|
|
583
|
+
|
|
584
|
+
hotels.forEach(group => {
|
|
585
|
+
const h = group[0];
|
|
586
|
+
const ns = group.map(g => g.night);
|
|
587
|
+
const nightLabel = ns.length === 1 ? `Night ${ns[0]}` : `Nights ${ns[0]}–${ns[ns.length - 1]}`;
|
|
588
|
+
const dateLabel = group.length === 1 ? h.day : `${h.day} – ${group[group.length - 1].day}`;
|
|
392
589
|
const icon = L.divIcon({
|
|
393
590
|
className: '',
|
|
394
591
|
html: `<div class="hm">${hotelLabel}</div>`,
|
|
@@ -396,7 +593,7 @@ const TRIP_DATA = {};
|
|
|
396
593
|
});
|
|
397
594
|
const m = L.marker([h.lat, h.lng], { icon, zIndexOffset: 1000 }).addTo(map);
|
|
398
595
|
let popupHTML = `<div class="pp-body" style="padding:16px">
|
|
399
|
-
<div class="pp-day"
|
|
596
|
+
<div class="pp-day">${nightLabel} · ${dateLabel}</div>
|
|
400
597
|
<div class="pp-name">${h.name}</div>
|
|
401
598
|
<div style="font-size:12px;color:var(--muted);margin-bottom:6px">${h.loc}</div>`;
|
|
402
599
|
if (h.booked && h.conf) popupHTML += `<div class="pp-conf">Conf# ${h.conf}</div>`;
|
|
@@ -421,7 +618,13 @@ const TRIP_DATA = {};
|
|
|
421
618
|
legendEl.querySelector('.legend-toggle')?.addEventListener('click', () => legendEl.classList.toggle('collapsed'));
|
|
422
619
|
|
|
423
620
|
// --- Fit bounds ---
|
|
424
|
-
|
|
621
|
+
// Include stops, hotels, and origin/destination pins so the initial fit covers everything.
|
|
622
|
+
const allPts = [
|
|
623
|
+
...days.flatMap(d => (d.stops || []).filter(s => s.lat && s.lng).map(s => [s.lat, s.lng])),
|
|
624
|
+
...days.flatMap(d => (d.lodging && d.lodging.lat && d.lodging.lng && d.lodging.name !== 'Home') ? [[d.lodging.lat, d.lodging.lng]] : []),
|
|
625
|
+
];
|
|
626
|
+
if (Number.isFinite(trip.origin_lat) && Number.isFinite(trip.origin_lng)) allPts.push([trip.origin_lat, trip.origin_lng]);
|
|
627
|
+
if (Number.isFinite(trip.destination_lat) && Number.isFinite(trip.destination_lng)) allPts.push([trip.destination_lat, trip.destination_lng]);
|
|
425
628
|
const fullBounds = L.latLngBounds(allPts).pad(0.06);
|
|
426
629
|
map.fitBounds(fullBounds);
|
|
427
630
|
|
|
@@ -452,6 +655,111 @@ const TRIP_DATA = {};
|
|
|
452
655
|
map.fitBounds(fullBounds, { animate: true });
|
|
453
656
|
};
|
|
454
657
|
|
|
658
|
+
// === LIGHTBOX ===
|
|
659
|
+
// Resolve a stop's media list on demand so it always reflects current data.
|
|
660
|
+
const lb = document.getElementById('lightbox');
|
|
661
|
+
const lbStage = document.getElementById('lbStage');
|
|
662
|
+
const lbCap = document.getElementById('lbCap');
|
|
663
|
+
const lbMeta = document.getElementById('lbMeta');
|
|
664
|
+
const lbCount = document.getElementById('lbCount');
|
|
665
|
+
const lbPrev = document.getElementById('lbPrev');
|
|
666
|
+
const lbNext = document.getElementById('lbNext');
|
|
667
|
+
let lbList = [];
|
|
668
|
+
let lbIdx = 0;
|
|
669
|
+
|
|
670
|
+
function lbRender() {
|
|
671
|
+
const m = lbList[lbIdx];
|
|
672
|
+
if (!m) return;
|
|
673
|
+
lbStage.innerHTML = m.type === 'video'
|
|
674
|
+
? `<video src="${escAttr(m.src)}" controls autoplay playsinline></video>`
|
|
675
|
+
: `<img src="${escAttr(m.src)}" alt="${escAttr(m.caption || 'photo')}">`;
|
|
676
|
+
lbCap.textContent = m.caption || '';
|
|
677
|
+
const meta = [];
|
|
678
|
+
if (m.taken_at) meta.push(new Date(m.taken_at).toString() !== 'Invalid Date' ? new Date(m.taken_at).toLocaleString() : m.taken_at);
|
|
679
|
+
if (m.lat != null && m.lng != null) meta.push(`${m.lat.toFixed(4)}, ${m.lng.toFixed(4)}`);
|
|
680
|
+
lbMeta.textContent = meta.join(' · ');
|
|
681
|
+
lbCount.textContent = lbList.length > 1 ? `${lbIdx + 1} / ${lbList.length}` : '';
|
|
682
|
+
lbPrev.disabled = lbIdx === 0;
|
|
683
|
+
lbNext.disabled = lbIdx === lbList.length - 1;
|
|
684
|
+
}
|
|
685
|
+
function lbStep(delta) {
|
|
686
|
+
const n = lbIdx + delta;
|
|
687
|
+
if (n < 0 || n >= lbList.length) return;
|
|
688
|
+
lbIdx = n;
|
|
689
|
+
lbRender();
|
|
690
|
+
}
|
|
691
|
+
function lbClose() {
|
|
692
|
+
lb.classList.remove('open');
|
|
693
|
+
lbStage.innerHTML = ''; // stop any playing video
|
|
694
|
+
}
|
|
695
|
+
// Open from a stop (di, si) at media index mi, or from an explicit list.
|
|
696
|
+
window.openLightbox = function (di, si, mi) {
|
|
697
|
+
lbList = getStopMedia(days[di].stops[si]);
|
|
698
|
+
if (!lbList.length) return;
|
|
699
|
+
lbIdx = Math.max(0, Math.min(mi || 0, lbList.length - 1));
|
|
700
|
+
lb.classList.add('open');
|
|
701
|
+
lbRender();
|
|
702
|
+
};
|
|
703
|
+
window.openLightboxList = function (list, idx) {
|
|
704
|
+
lbList = list;
|
|
705
|
+
if (!lbList.length) return;
|
|
706
|
+
lbIdx = Math.max(0, Math.min(idx || 0, lbList.length - 1));
|
|
707
|
+
lb.classList.add('open');
|
|
708
|
+
lbRender();
|
|
709
|
+
};
|
|
710
|
+
lbPrev.onclick = () => lbStep(-1);
|
|
711
|
+
lbNext.onclick = () => lbStep(1);
|
|
712
|
+
document.getElementById('lbClose').onclick = lbClose;
|
|
713
|
+
lb.addEventListener('click', (e) => { if (e.target === lb) lbClose(); });
|
|
714
|
+
document.addEventListener('keydown', (e) => {
|
|
715
|
+
if (!lb.classList.contains('open')) return;
|
|
716
|
+
if (e.key === 'Escape') lbClose();
|
|
717
|
+
else if (e.key === 'ArrowLeft') lbStep(-1);
|
|
718
|
+
else if (e.key === 'ArrowRight') lbStep(1);
|
|
719
|
+
});
|
|
720
|
+
// Basic swipe nav on touch devices.
|
|
721
|
+
let touchX = null;
|
|
722
|
+
lbStage.addEventListener('touchstart', (e) => { touchX = e.changedTouches[0].clientX; }, { passive: true });
|
|
723
|
+
lbStage.addEventListener('touchend', (e) => {
|
|
724
|
+
if (touchX == null) return;
|
|
725
|
+
const dx = e.changedTouches[0].clientX - touchX;
|
|
726
|
+
if (Math.abs(dx) > 40) lbStep(dx < 0 ? 1 : -1);
|
|
727
|
+
touchX = null;
|
|
728
|
+
}, { passive: true });
|
|
729
|
+
|
|
730
|
+
// === PHOTO-PIN LAYER ===
|
|
731
|
+
// Each media item that carries its own EXIF lat/lng becomes a small pin,
|
|
732
|
+
// distinct from the day-numbered stop markers. Toggleable via the map control.
|
|
733
|
+
const photoLayer = L.layerGroup();
|
|
734
|
+
let photoLayerOn = false;
|
|
735
|
+
days.forEach((d, di) => {
|
|
736
|
+
(d.stops || []).forEach((s, si) => {
|
|
737
|
+
getStopMedia(s).forEach((m, mi) => {
|
|
738
|
+
if (m.lat == null || m.lng == null) return;
|
|
739
|
+
const icon = L.divIcon({ className: '', html: '<div class="photopin"></div>', iconSize: [14, 14], iconAnchor: [7, 7] });
|
|
740
|
+
const pin = L.marker([m.lat, m.lng], { icon });
|
|
741
|
+
pin.on('click', () => window.openLightbox(di, si, mi));
|
|
742
|
+
photoLayer.addLayer(pin);
|
|
743
|
+
});
|
|
744
|
+
});
|
|
745
|
+
});
|
|
746
|
+
const photoPinCount = photoLayer.getLayers().length;
|
|
747
|
+
if (photoPinCount > 0) {
|
|
748
|
+
const btn = document.createElement('button');
|
|
749
|
+
btn.className = 'mbtn toggle';
|
|
750
|
+
btn.style.marginLeft = '6px';
|
|
751
|
+
btn.style.borderRadius = '5px';
|
|
752
|
+
btn.style.borderLeft = '1px solid var(--border)';
|
|
753
|
+
btn.textContent = `📷 Photos`;
|
|
754
|
+
btn.title = `${photoPinCount} geotagged media`;
|
|
755
|
+
btn.addEventListener('click', () => {
|
|
756
|
+
photoLayerOn = !photoLayerOn;
|
|
757
|
+
btn.classList.toggle('active', photoLayerOn);
|
|
758
|
+
if (photoLayerOn) photoLayer.addTo(map); else map.removeLayer(photoLayer);
|
|
759
|
+
});
|
|
760
|
+
document.querySelector('.map-ctrl').appendChild(btn);
|
|
761
|
+
}
|
|
762
|
+
|
|
455
763
|
// Set page title
|
|
456
764
|
document.title = trip.title + ' — TripKit';
|
|
457
765
|
|
|
@@ -18,6 +18,10 @@ trip:
|
|
|
18
18
|
children: integer
|
|
19
19
|
ages: string # e.g. "Kids: 14, 17"
|
|
20
20
|
origin: string # e.g. "Folsom, CA"
|
|
21
|
+
origin_lat: number # Optional — if set with origin_lng, renders a "Start" pin on the map
|
|
22
|
+
origin_lng: number
|
|
23
|
+
destination_lat: number # Optional — if absent, defaults to origin coords (round trip)
|
|
24
|
+
destination_lng: number # Set explicitly only if the trip ends somewhere different from where it started
|
|
21
25
|
vehicle: string # e.g. "SUV", "Sedan", "RV"
|
|
22
26
|
|
|
23
27
|
# ============================================================
|
|
@@ -58,6 +62,7 @@ days:
|
|
|
58
62
|
lng: number
|
|
59
63
|
notes: string # Free-form: pool, breakfast, special features
|
|
60
64
|
navigate_url: string # Google Maps nav link
|
|
65
|
+
media: # Optional — same shape as stops[].media (see below). Post-trip photos of the stay.
|
|
61
66
|
|
|
62
67
|
alerts: # Optional array of important warnings
|
|
63
68
|
- string # e.g. "⏰ Dad has a 7:30 PM meeting..."
|
|
@@ -82,6 +87,23 @@ days:
|
|
|
82
87
|
kid_friendly: boolean # Optional
|
|
83
88
|
accessibility: string # Optional — wheelchair, stroller notes
|
|
84
89
|
|
|
90
|
+
# POST-TRIP MEDIA (optional) — actual photos/videos captured at this stop.
|
|
91
|
+
# Populated by `tripkit media <folder> <trip.yaml>` from geotagged files,
|
|
92
|
+
# reviewed/captioned, then merged with `tripkit media apply`. Purely additive:
|
|
93
|
+
# stops without `media` render exactly as before. `image` above remains the
|
|
94
|
+
# single hero/primary image; `media` is the gallery shown in cards, popups,
|
|
95
|
+
# the lightbox, and (when items carry lat/lng) the toggleable photo-pin layer.
|
|
96
|
+
media:
|
|
97
|
+
- src: string # Relative path (e.g. "media/IMG_2401.jpg") OR full URL.
|
|
98
|
+
# URLs keep the output a shareable single file.
|
|
99
|
+
type: string # "photo" | "video"
|
|
100
|
+
thumb: string # Optional pre-generated thumbnail path/URL (sharp, if available);
|
|
101
|
+
# renderer falls back to `src` when absent.
|
|
102
|
+
caption: string # Optional human/agent caption shown in the lightbox
|
|
103
|
+
lat: number # Optional — original EXIF GPS; drives the photo-pin map layer
|
|
104
|
+
lng: number
|
|
105
|
+
taken_at: string # Optional — EXIF timestamp (ISO 8601), used for day matching
|
|
106
|
+
|
|
85
107
|
# ============================================================
|
|
86
108
|
# ROUTES — map polylines connecting stops
|
|
87
109
|
# Optional: if omitted, renderer draws straight lines between stops
|
|
@@ -102,6 +124,9 @@ theme:
|
|
|
102
124
|
map_style: string # "terrain" | "satellite" | "topo" | "street"
|
|
103
125
|
dark_mode: boolean # Default: false
|
|
104
126
|
hero_style: string # "photo" | "gradient" | "minimal"
|
|
127
|
+
hotel_label: string # Optional 1–4 char label for hotel markers (e.g. "MAR", "BW", "INN").
|
|
128
|
+
# Falls back to first 3 chars of agent_context.preferences.accommodation_chain,
|
|
129
|
+
# then 🏨 emoji.
|
|
105
130
|
|
|
106
131
|
# ============================================================
|
|
107
132
|
# AGENT CONTEXT — metadata for the planning agent
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Coverage check: every renderer-meaningful schema field must be mentioned
|
|
4
|
+
* by name in the agent skill. Prevents drift when fields are added.
|
|
5
|
+
*
|
|
6
|
+
* Run: node scripts/check-skill-coverage.js
|
|
7
|
+
* (also wired into `npm test`)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
|
|
13
|
+
const ROOT = path.join(__dirname, '..');
|
|
14
|
+
const skill = fs.readFileSync(path.join(ROOT, 'agent', 'SKILL.md'), 'utf8');
|
|
15
|
+
|
|
16
|
+
// Hand-curated list of fields the agent must know about. When you add a renderer-
|
|
17
|
+
// meaningful field, add it here and to agent/SKILL.md in the same change.
|
|
18
|
+
const REQUIRED_FIELDS = [
|
|
19
|
+
// trip
|
|
20
|
+
'trip.title', 'trip.dates', 'trip.total_days', 'trip.total_stops',
|
|
21
|
+
'trip.origin', 'trip.origin_lat', 'trip.origin_lng',
|
|
22
|
+
'trip.destination_lat', 'trip.destination_lng',
|
|
23
|
+
// days
|
|
24
|
+
'days[].number', 'days[].title', 'days[].date', 'days[].status', 'days[].color',
|
|
25
|
+
'days[].summary', 'days[].weather', 'days[].meals', 'days[].lodging',
|
|
26
|
+
'days[].alerts', 'days[].tips', 'days[].stops',
|
|
27
|
+
// stops
|
|
28
|
+
'stops[].name', 'stops[].lat', 'stops[].lng', 'stops[].type', 'stops[].label',
|
|
29
|
+
'stops[].description', 'stops[].kid_friendly', 'stops[].reservation_required',
|
|
30
|
+
'stops[].navigate_url', 'stops[].media',
|
|
31
|
+
// lodging
|
|
32
|
+
'lodging.name', 'lodging.lat', 'lodging.lng', 'lodging.confirmation', 'lodging.booked',
|
|
33
|
+
// routes
|
|
34
|
+
'routes[]',
|
|
35
|
+
// theme
|
|
36
|
+
'theme.font_family', 'theme.accent_color', 'theme.map_style', 'theme.dark_mode',
|
|
37
|
+
'theme.hotel_label',
|
|
38
|
+
// agent_context
|
|
39
|
+
'agent_context.preferences', 'agent_context.constraints', 'agent_context.iteration_log',
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
// Each entry above resolves to one or more "tokens" the skill must contain.
|
|
43
|
+
// Strip the path prefix so the lookup is forgiving across formats like
|
|
44
|
+
// `trip.origin_lat`, ``trip.origin_lat``, or just `origin_lat`.
|
|
45
|
+
function lookupTokens(field) {
|
|
46
|
+
const leaf = field.replace(/^.*\./, '').replace(/\[\]$/, '');
|
|
47
|
+
return [field, leaf];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const missing = [];
|
|
51
|
+
for (const field of REQUIRED_FIELDS) {
|
|
52
|
+
const tokens = lookupTokens(field);
|
|
53
|
+
const found = tokens.some(t => skill.includes(t));
|
|
54
|
+
if (!found) missing.push(field);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (missing.length === 0) {
|
|
58
|
+
console.log(`✓ agent/SKILL.md covers all ${REQUIRED_FIELDS.length} required fields`);
|
|
59
|
+
process.exit(0);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
console.error(`✖ agent/SKILL.md is missing references to ${missing.length} required field(s):`);
|
|
63
|
+
for (const f of missing) console.error(` - ${f}`);
|
|
64
|
+
console.error('');
|
|
65
|
+
console.error('Either add a mention to agent/agent/SKILL.md, or remove it from REQUIRED_FIELDS in this script if the field is no longer renderer-meaningful.');
|
|
66
|
+
process.exit(1);
|