Travel — Itinerary Timeline
A time-anchored itinerary primitive for a single travel day, rendered as a vertical timeline of stops along a coloured rail. Each stop pairs an emoji icon, an auto-computed clock time, a duration and a travel-time connector to the next, colour-keyed by type for sightseeing, food, transport and hotel. Drag a handle to reorder and every time recalculates instantly; add or remove stops, step durations up and down, collapse details, and watch a running total of stops, active hours, travel minutes and a wrap-up time stay in sync.
MCP
Code
:root {
--bg: #fbf7f1;
--surface: #ffffff;
--ink: #241f1a;
--muted: #6b6259;
--teal: #1f8a8a;
--teal-deep: #156a6a;
--coral: #e8623f;
--sand: #e7d8c3;
--line: rgba(36, 31, 26, 0.12);
--line-soft: rgba(36, 31, 26, 0.07);
/* stop type accents */
--c-sight: #1f8a8a;
--c-sight-bg: #e3f2f1;
--c-food: #e8623f;
--c-food-bg: #fbe6df;
--c-transport: #7a6cc4;
--c-transport-bg: #ece9f8;
--c-hotel: #c79a3a;
--c-hotel-bg: #f6ecd4;
--shadow-sm: 0 1px 2px rgba(36, 31, 26, 0.06);
--shadow-md: 0 10px 30px -16px rgba(36, 31, 26, 0.35);
--radius: 16px;
--radius-sm: 10px;
--serif: "Fraunces", Georgia, "Times New Roman", serif;
--sans: "Work Sans", system-ui, -apple-system, "Segoe UI", sans-serif;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
font-family: var(--sans);
color: var(--ink);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background:
radial-gradient(120% 70% at 100% -10%, #f3e7d6 0%, transparent 55%),
radial-gradient(90% 60% at -10% 0%, #e6f1ef 0%, transparent 50%),
var(--bg);
min-height: 100vh;
}
.page {
max-width: 1080px;
margin: 0 auto;
padding: clamp(20px, 4vw, 48px) clamp(16px, 4vw, 40px) 56px;
}
/* ---------- Masthead ---------- */
.masthead {
position: relative;
padding: clamp(26px, 4vw, 40px);
border-radius: var(--radius);
overflow: hidden;
color: #fffaf2;
box-shadow: var(--shadow-md);
background:
linear-gradient(180deg, rgba(20, 30, 36, 0.05), rgba(20, 30, 36, 0.5)),
linear-gradient(115deg, #163b46 0%, #1f8a8a 42%, #e8623f 100%);
}
.masthead::before {
/* layered horizon "photo" */
content: "";
position: absolute;
inset: auto 0 0 0;
height: 56%;
background:
radial-gradient(60% 120% at 78% 120%, rgba(255, 214, 150, 0.55), transparent 60%),
linear-gradient(180deg, transparent, rgba(15, 23, 28, 0.55)),
repeating-linear-gradient(135deg, rgba(255, 255, 255, 0.05) 0 8px, transparent 8px 16px);
-webkit-mask: linear-gradient(180deg, transparent, #000 28%);
mask: linear-gradient(180deg, transparent, #000 28%);
pointer-events: none;
}
.masthead::after {
/* sun */
content: "";
position: absolute;
right: clamp(20px, 8vw, 64px);
top: clamp(18px, 5vw, 34px);
width: 64px;
height: 64px;
border-radius: 50%;
background: radial-gradient(circle at 40% 40%, #fff3d6, #ffd07e 60%, rgba(255, 208, 126, 0));
box-shadow: 0 0 60px 18px rgba(255, 210, 130, 0.4);
pointer-events: none;
}
.kicker,
.title,
.lede {
position: relative;
z-index: 1;
}
.kicker {
margin: 0 0 10px;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.16em;
text-transform: uppercase;
color: rgba(255, 250, 242, 0.86);
}
.title {
margin: 0;
font-family: var(--serif);
font-weight: 600;
font-size: clamp(2rem, 6vw, 3.2rem);
line-height: 1.05;
letter-spacing: -0.01em;
text-wrap: balance;
}
.lede {
margin: 14px 0 0;
max-width: 46ch;
color: rgba(255, 250, 242, 0.92);
font-size: clamp(0.95rem, 2.2vw, 1.05rem);
}
/* ---------- Planner layout ---------- */
.planner {
display: grid;
grid-template-columns: minmax(0, 1fr) 280px;
gap: clamp(18px, 3vw, 28px);
margin-top: clamp(20px, 3vw, 30px);
align-items: start;
}
.board {
min-width: 0;
}
.board-head {
display: flex;
flex-wrap: wrap;
gap: 14px;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 20px;
}
.board-title {
margin: 0;
font-family: var(--serif);
font-size: clamp(1.4rem, 3.5vw, 1.7rem);
font-weight: 600;
}
.board-sub {
margin: 4px 0 0;
font-size: 0.82rem;
color: var(--muted);
}
.board-controls {
display: flex;
gap: 10px;
align-items: flex-end;
}
.start-field {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--muted);
}
.start-field input {
font-family: var(--sans);
font-size: 0.95rem;
font-weight: 600;
color: var(--ink);
padding: 8px 10px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface);
box-shadow: var(--shadow-sm);
}
/* ---------- Buttons ---------- */
.btn {
font-family: var(--sans);
font-size: 0.86rem;
font-weight: 600;
border-radius: 999px;
border: 1px solid transparent;
padding: 9px 16px;
cursor: pointer;
transition: transform 0.12s ease, background 0.18s ease, box-shadow 0.18s ease,
border-color 0.18s ease;
}
.btn:active {
transform: translateY(1px);
}
.btn-ghost {
background: var(--surface);
color: var(--teal-deep);
border-color: var(--line);
box-shadow: var(--shadow-sm);
}
.btn-ghost:hover {
border-color: var(--teal);
color: var(--teal);
}
.btn-solid {
width: 100%;
background: var(--ink);
color: #fbf7f1;
margin-top: 16px;
}
.btn-solid:hover {
background: #3a312a;
}
:focus-visible {
outline: 3px solid var(--teal);
outline-offset: 2px;
border-radius: 8px;
}
/* ---------- Timeline ---------- */
.timeline {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
}
.stop {
position: relative;
display: grid;
grid-template-columns: 34px 1fr;
column-gap: 14px;
}
.stop.dragging {
opacity: 0.45;
}
.stop.drag-over .card {
border-color: var(--teal);
box-shadow: 0 0 0 2px rgba(31, 138, 138, 0.25), var(--shadow-md);
}
/* left rail */
.rail {
position: relative;
display: flex;
justify-content: center;
padding-top: 22px;
}
.rail-node {
position: relative;
z-index: 1;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--surface);
border: 3px solid var(--accent, var(--teal));
box-shadow: 0 0 0 4px var(--accent-bg, var(--c-sight-bg));
}
.rail-line {
position: absolute;
top: 22px;
bottom: -2px;
width: 2px;
background: linear-gradient(var(--line), var(--line-soft));
}
.stop:last-child .rail-line {
display: none;
}
/* card */
.card {
position: relative;
display: flex;
align-items: stretch;
gap: 8px;
background: var(--surface);
border: 1px solid var(--line);
border-left: 4px solid var(--accent, var(--teal));
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
margin: 12px 0;
transition: box-shadow 0.18s ease, border-color 0.18s ease, transform 0.12s ease;
overflow: hidden;
}
.card:hover {
box-shadow: var(--shadow-md);
}
.drag-handle {
flex: 0 0 auto;
align-self: stretch;
width: 30px;
border: 0;
background: transparent;
color: var(--muted);
font-size: 1.1rem;
cursor: grab;
touch-action: none;
border-right: 1px solid var(--line-soft);
transition: color 0.15s ease, background 0.15s ease;
}
.drag-handle:hover {
color: var(--ink);
background: rgba(36, 31, 26, 0.04);
}
.drag-handle:active {
cursor: grabbing;
}
.card-main {
flex: 1 1 auto;
min-width: 0;
}
.stop-head {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
text-align: left;
background: transparent;
border: 0;
padding: 14px 16px 14px 6px;
cursor: pointer;
font-family: inherit;
color: inherit;
}
.stop-icon {
flex: 0 0 auto;
width: 40px;
height: 40px;
display: grid;
place-items: center;
font-size: 1.2rem;
border-radius: 12px;
background: var(--accent-bg, var(--c-sight-bg));
}
.stop-text {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.stop-title {
font-weight: 600;
font-size: 1rem;
color: var(--ink);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.stop-meta {
display: flex;
align-items: center;
gap: 7px;
font-size: 0.82rem;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.stop-time {
font-weight: 600;
color: var(--accent, var(--teal-deep));
}
.dotsep {
opacity: 0.5;
}
.chevron {
flex: 0 0 auto;
font-size: 1.1rem;
color: var(--muted);
transition: transform 0.2s ease;
}
.stop-head[aria-expanded="false"] .chevron {
transform: rotate(-90deg);
}
/* expandable body */
.stop-body {
display: grid;
grid-template-rows: 1fr;
transition: grid-template-rows 0.24s ease, opacity 0.2s ease;
padding: 0 16px;
}
.stop-head[aria-expanded="false"] + .stop-body {
grid-template-rows: 0fr;
opacity: 0;
}
.stop-body > * {
min-height: 0;
}
.stop-note {
margin: 0;
padding-bottom: 12px;
font-size: 0.9rem;
color: var(--muted);
border-top: 1px solid var(--line-soft);
padding-top: 12px;
}
.stop-head[aria-expanded="false"] + .stop-body .stop-note {
overflow: hidden;
}
.stop-tools {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px;
padding-bottom: 14px;
}
.dur-stepper {
display: inline-flex;
align-items: center;
gap: 4px;
background: var(--bg);
border: 1px solid var(--line);
border-radius: 999px;
padding: 3px;
}
.step {
border: 0;
background: transparent;
font-family: inherit;
font-size: 0.78rem;
font-weight: 600;
color: var(--teal-deep);
padding: 5px 9px;
border-radius: 999px;
cursor: pointer;
font-variant-numeric: tabular-nums;
}
.step:hover {
background: var(--surface);
}
.dur-readout {
min-width: 52px;
text-align: center;
font-size: 0.82rem;
font-weight: 600;
color: var(--ink);
font-variant-numeric: tabular-nums;
}
.remove {
border: 1px solid var(--line);
background: var(--surface);
color: var(--coral);
font-family: inherit;
font-size: 0.8rem;
font-weight: 600;
padding: 6px 13px;
border-radius: 999px;
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease;
}
.remove:hover {
background: var(--c-food-bg);
border-color: var(--coral);
}
/* travel connector between stops */
.connector {
grid-column: 2;
display: flex;
align-items: center;
gap: 8px;
margin: -4px 0 -4px 6px;
font-size: 0.78rem;
font-weight: 500;
color: var(--muted);
}
.connector-icon {
display: inline-grid;
place-items: center;
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--sand);
color: var(--ink);
font-size: 0.8rem;
}
.stop:last-child .connector {
display: none;
}
/* accent classes (set on .stop) */
.stop.t-sight {
--accent: var(--c-sight);
--accent-bg: var(--c-sight-bg);
}
.stop.t-food {
--accent: var(--c-food);
--accent-bg: var(--c-food-bg);
}
.stop.t-transport {
--accent: var(--c-transport);
--accent-bg: var(--c-transport-bg);
}
.stop.t-hotel {
--accent: var(--c-hotel);
--accent-bg: var(--c-hotel-bg);
}
.empty-state {
margin: 8px 0;
padding: 28px;
text-align: center;
color: var(--muted);
border: 1px dashed var(--line);
border-radius: var(--radius);
background: rgba(255, 255, 255, 0.5);
}
/* ---------- Summary ---------- */
.summary {
position: sticky;
top: 18px;
}
.summary-card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 20px;
box-shadow: var(--shadow-sm);
}
.summary-title {
margin: 0 0 14px;
font-family: var(--serif);
font-size: 1.2rem;
font-weight: 600;
}
.stat-grid {
margin: 0;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.stat {
background: var(--bg);
border: 1px solid var(--line-soft);
border-radius: var(--radius-sm);
padding: 12px;
}
.stat dt {
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--muted);
}
.stat dd {
margin: 4px 0 0;
font-family: var(--serif);
font-size: 1.4rem;
font-weight: 600;
color: var(--ink);
font-variant-numeric: tabular-nums;
}
.legend {
margin-top: 18px;
padding-top: 16px;
border-top: 1px solid var(--line-soft);
}
.legend-title {
margin: 0 0 10px;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--muted);
}
.legend-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 8px;
font-size: 0.86rem;
color: var(--ink);
}
.legend-list li {
display: flex;
align-items: center;
gap: 9px;
}
.dot {
width: 12px;
height: 12px;
border-radius: 50%;
flex: 0 0 auto;
}
.dot.t-sight { background: var(--c-sight); }
.dot.t-food { background: var(--c-food); }
.dot.t-transport { background: var(--c-transport); }
.dot.t-hotel { background: var(--c-hotel); }
/* ---------- Footer ---------- */
.foot {
margin-top: 40px;
padding-top: 18px;
border-top: 1px solid var(--line);
font-size: 0.8rem;
color: var(--muted);
text-align: center;
}
/* ---------- Toast ---------- */
.toast {
position: fixed;
left: 50%;
bottom: 24px;
transform: translate(-50%, 16px);
background: var(--ink);
color: #fbf7f1;
padding: 11px 18px;
border-radius: 999px;
font-size: 0.86rem;
font-weight: 500;
box-shadow: var(--shadow-md);
opacity: 0;
pointer-events: none;
transition: opacity 0.22s ease, transform 0.22s ease;
z-index: 50;
max-width: calc(100vw - 32px);
}
.toast.show {
opacity: 1;
transform: translate(-50%, 0);
}
/* ---------- Responsive ---------- */
@media (max-width: 760px) {
.planner {
grid-template-columns: 1fr;
}
.summary {
position: static;
order: -1;
}
.stat-grid {
grid-template-columns: repeat(4, 1fr);
}
}
@media (max-width: 480px) {
.board-head {
align-items: stretch;
}
.board-controls {
width: 100%;
justify-content: space-between;
}
.stat-grid {
grid-template-columns: 1fr 1fr;
}
.stop {
grid-template-columns: 26px 1fr;
column-gap: 10px;
}
.stop-icon {
width: 34px;
height: 34px;
font-size: 1.05rem;
}
.stop-tools {
justify-content: flex-start;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
transition-duration: 0.001ms !important;
}
}(function () {
"use strict";
/* ---------- Stop type catalog ---------- */
var TYPES = {
sight: { label: "Sightseeing", icon: "🏛️", move: "🚶" },
food: { label: "Food & drink", icon: "🍽️", move: "🚶" },
transport: { label: "Transport", icon: "🚌", move: "🚏" },
hotel: { label: "Hotel & rest", icon: "🛎️", move: "🚶" }
};
/* ---------- Seed itinerary (fictional) ---------- */
function seed() {
return [
{
id: uid(),
type: "food",
title: "Breakfast at Café Marea",
note: "Pastel de nata and a cortado on the harbour terrace before the crowds arrive.",
duration: 45,
travel: 10
},
{
id: uid(),
type: "transport",
title: "Tram 7 to the cliff trailhead",
note: "Old wooden tram up the hillside. Tap on with the day pass; sit on the left for sea views.",
duration: 20,
travel: 5
},
{
id: uid(),
type: "sight",
title: "Sea-cliff coastal walk",
note: "Clifftop path past the old lighthouse with wide views over the bay. Easy gradient, shaded benches.",
duration: 90,
travel: 25
},
{
id: uid(),
type: "food",
title: "Long lunch at Taberna do Sol",
note: "Grilled catch of the day, a carafe of vinho verde, and zero rush. Reserve the courtyard table.",
duration: 75,
travel: 15
},
{
id: uid(),
type: "sight",
title: "Azulejo Museum & old town",
note: "Tiled cloisters and a quiet courtyard. Wander the lanes afterwards for ceramics shops.",
duration: 60,
travel: 20
},
{
id: uid(),
type: "hotel",
title: "Sunset on the rooftop terrace",
note: "Back to the guesthouse to freshen up, then golden hour with a vermouth before dinner plans.",
duration: 60,
travel: 0
}
];
}
/* ---------- State ---------- */
var state = {
start: "08:30",
stops: seed()
};
/* ---------- Elements ---------- */
var timelineEl = document.getElementById("timeline");
var template = document.getElementById("stop-template");
var startInput = document.getElementById("start-time");
var emptyState = document.getElementById("empty-state");
var toastEl = document.getElementById("toast");
var statStops = document.getElementById("stat-stops");
var statHours = document.getElementById("stat-hours");
var statTravel = document.getElementById("stat-travel");
var statEnd = document.getElementById("stat-end");
var dragId = null;
/* ---------- Helpers ---------- */
function uid() {
return "s" + Math.random().toString(36).slice(2, 9);
}
function toMinutes(hhmm) {
var parts = (hhmm || "0:0").split(":");
return parseInt(parts[0], 10) * 60 + parseInt(parts[1], 10);
}
function fmtClock(mins) {
mins = ((mins % 1440) + 1440) % 1440;
var h = Math.floor(mins / 60);
var m = mins % 60;
var ampm = h < 12 ? "am" : "pm";
var h12 = h % 12;
if (h12 === 0) h12 = 12;
return h12 + ":" + (m < 10 ? "0" : "") + m + ampm;
}
function fmtDur(mins) {
if (mins < 60) return mins + "m";
var h = Math.floor(mins / 60);
var m = mins % 60;
return m === 0 ? h + "h" : h + "h " + m + "m";
}
var toastTimer;
function toast(msg) {
toastEl.textContent = msg;
toastEl.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () {
toastEl.classList.remove("show");
}, 2200);
}
function findIndex(id) {
for (var i = 0; i < state.stops.length; i++) {
if (state.stops[i].id === id) return i;
}
return -1;
}
/* ---------- Render ---------- */
function render() {
timelineEl.innerHTML = "";
emptyState.hidden = state.stops.length > 0;
var clock = toMinutes(state.start);
state.stops.forEach(function (stop, i) {
var node = template.content.firstElementChild.cloneNode(true);
var meta = TYPES[stop.type] || TYPES.sight;
node.dataset.id = stop.id;
node.classList.add("t-" + stop.type);
node.querySelector(".stop-icon").textContent = meta.icon;
node.querySelector(".stop-title").textContent = stop.title;
node.querySelector(".stop-time").textContent = fmtClock(clock);
node.querySelector(".stop-dur").textContent = fmtDur(stop.duration);
node.querySelector(".stop-note").textContent = stop.note;
node.querySelector(".dur-readout").textContent = fmtDur(stop.duration);
// travel connector to the NEXT stop
var connectorLabel = node.querySelector(".connector-label");
var connectorIcon = node.querySelector(".connector-icon");
if (i < state.stops.length - 1) {
if (stop.travel > 0) {
connectorLabel.textContent = meta.move + " " + stop.travel + " min to next stop";
connectorIcon.textContent = "↧";
} else {
connectorLabel.textContent = "Stay put — next stop here";
connectorIcon.textContent = "•";
}
}
// expand state preserved across renders
var head = node.querySelector(".stop-head");
head.setAttribute(
"aria-expanded",
stop.collapsed ? "false" : "true"
);
wireStop(node, stop);
timelineEl.appendChild(node);
// advance the running clock: this stop + travel to next
clock += stop.duration + (i < state.stops.length - 1 ? stop.travel : 0);
});
updateSummary(clock);
}
function updateSummary(endClock) {
var totalActive = 0;
var totalTravel = 0;
state.stops.forEach(function (s, i) {
totalActive += s.duration;
if (i < state.stops.length - 1) totalTravel += s.travel;
});
statStops.textContent = String(state.stops.length);
statHours.textContent = state.stops.length ? fmtDur(totalActive) : "0h";
statTravel.textContent = totalTravel > 0 ? fmtDur(totalTravel) : "0m";
statEnd.textContent = state.stops.length ? fmtClock(endClock) : "—";
}
/* ---------- Per-stop wiring ---------- */
function wireStop(node, stop) {
var head = node.querySelector(".stop-head");
var handle = node.querySelector(".drag-handle");
// collapse / expand
head.addEventListener("click", function () {
stop.collapsed = !stop.collapsed;
head.setAttribute("aria-expanded", stop.collapsed ? "false" : "true");
});
// duration steppers
node.querySelectorAll(".step").forEach(function (btn) {
btn.addEventListener("click", function () {
var delta = parseInt(btn.dataset.step, 10);
var next = stop.duration + delta;
if (next < 15) {
toast("Minimum stop is 15 minutes");
return;
}
if (next > 480) {
toast("That is a long stop — capped at 8h");
return;
}
stop.duration = next;
render();
});
});
// remove
node.querySelector(".remove").addEventListener("click", function () {
var idx = findIndex(stop.id);
if (idx > -1) {
state.stops.splice(idx, 1);
render();
toast("Removed “" + truncate(stop.title) + "”");
}
});
// drag to reorder (handle is the drag source)
handle.addEventListener("mousedown", enableDrag);
handle.addEventListener("touchstart", enableTouchDrag, { passive: true });
node.addEventListener("dragstart", function (e) {
dragId = stop.id;
node.classList.add("dragging");
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", stop.id);
}
});
node.addEventListener("dragend", function () {
node.classList.remove("dragging");
clearDragOver();
node.setAttribute("draggable", "false");
dragId = null;
});
node.addEventListener("dragover", function (e) {
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
if (dragId && dragId !== stop.id) {
clearDragOver();
node.classList.add("drag-over");
}
});
node.addEventListener("drop", function (e) {
e.preventDefault();
node.classList.remove("drag-over");
if (dragId && dragId !== stop.id) {
reorder(dragId, stop.id);
}
});
function enableDrag() {
node.setAttribute("draggable", "true");
}
function enableTouchDrag() {
node.setAttribute("draggable", "true");
}
}
function clearDragOver() {
var prev = timelineEl.querySelectorAll(".drag-over");
prev.forEach(function (el) {
el.classList.remove("drag-over");
});
}
function reorder(fromId, toId) {
var from = findIndex(fromId);
var to = findIndex(toId);
if (from < 0 || to < 0 || from === to) return;
var moved = state.stops.splice(from, 1)[0];
state.stops.splice(to, 0, moved);
render();
toast("Reordered — times recalculated");
}
function truncate(str) {
return str.length > 28 ? str.slice(0, 27) + "…" : str;
}
/* ---------- Add stop ---------- */
var addIdeas = [
{
type: "food",
title: "Coffee & cake at Doce Hora",
note: "A quick pit stop — flat white and an almond tart in a sunny window seat.",
duration: 30,
travel: 10
},
{
type: "sight",
title: "Hidden cove swim stop",
note: "Steps down to a sheltered cove. Calm water, bring a towel — worth the detour.",
duration: 45,
travel: 15
},
{
type: "transport",
title: "Ferry across the bay",
note: "Ten-minute crossing with a breeze and skyline views. Buy tickets at the kiosk.",
duration: 15,
travel: 10
},
{
type: "hotel",
title: "Siesta break at the guesthouse",
note: "Recharge through the heat of the afternoon before the evening picks up.",
duration: 60,
travel: 5
}
];
var addCursor = 0;
document.getElementById("add-stop").addEventListener("click", function () {
var idea = addIdeas[addCursor % addIdeas.length];
addCursor++;
var stop = {
id: uid(),
type: idea.type,
title: idea.title,
note: idea.note,
duration: idea.duration,
travel: idea.travel,
collapsed: false
};
state.stops.push(stop);
render();
toast("Added “" + truncate(stop.title) + "”");
// bring the new stop into view
var last = timelineEl.lastElementChild;
if (last && last.scrollIntoView) {
last.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
});
/* ---------- Start time ---------- */
startInput.addEventListener("input", function () {
if (startInput.value) {
state.start = startInput.value;
render();
}
});
/* ---------- Reset ---------- */
document.getElementById("reset-day").addEventListener("click", function () {
state.start = "08:30";
startInput.value = "08:30";
state.stops = seed();
addCursor = 0;
render();
toast("Day reset to the original plan");
});
/* ---------- Init ---------- */
startInput.value = state.start;
render();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Travel — Itinerary Timeline</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600;9..144,700&family=Work+Sans:wght@400;500;600&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="page">
<header class="masthead" role="banner">
<p class="kicker">Stealthis Travel · Day Planner</p>
<h1 class="title">A Day in Vela Sereno</h1>
<p class="lede">
One slow coastal day — sea-cliff trails, a long lunch, and a sunset terrace.
Drag the stops to reshape the day; the clock recalculates itself.
</p>
</header>
<main class="planner" role="main" aria-labelledby="plan-heading">
<section class="board" aria-label="Itinerary for the day">
<div class="board-head">
<div>
<h2 id="plan-heading" class="board-title">Saturday Itinerary</h2>
<p class="board-sub">Tap a stop to expand · drag the handle to reorder</p>
</div>
<div class="board-controls">
<label class="start-field">
<span>Start</span>
<input
type="time"
id="start-time"
value="08:30"
aria-label="Day start time"
/>
</label>
<button type="button" class="btn btn-ghost" id="add-stop">
<span aria-hidden="true">+</span> Add stop
</button>
</div>
</div>
<ol class="timeline" id="timeline" aria-label="Ordered list of stops">
<!-- stops injected by script.js -->
</ol>
<p class="empty-state" id="empty-state" hidden>
No stops yet. Add one to start building the day.
</p>
</section>
<aside class="summary" aria-label="Day summary">
<div class="summary-card">
<h2 class="summary-title">Day at a glance</h2>
<dl class="stat-grid">
<div class="stat">
<dt>Stops</dt>
<dd id="stat-stops">0</dd>
</div>
<div class="stat">
<dt>Active hours</dt>
<dd id="stat-hours">0h</dd>
</div>
<div class="stat">
<dt>On the move</dt>
<dd id="stat-travel">0m</dd>
</div>
<div class="stat">
<dt>Wraps up</dt>
<dd id="stat-end">—</dd>
</div>
</dl>
<div class="legend" aria-hidden="false">
<p class="legend-title">Stop types</p>
<ul class="legend-list">
<li><span class="dot t-sight"></span> Sightseeing</li>
<li><span class="dot t-food"></span> Food & drink</li>
<li><span class="dot t-transport"></span> Transport</li>
<li><span class="dot t-hotel"></span> Hotel & rest</li>
</ul>
</div>
<button type="button" class="btn btn-solid" id="reset-day">
Reset the day
</button>
</div>
</aside>
</main>
<footer class="foot" role="contentinfo">
Illustrative travel UI only — fictional destinations, times, and prices.
</footer>
</div>
<!-- Template for one timeline stop, cloned by JS -->
<template id="stop-template">
<li class="stop" draggable="false">
<div class="rail" aria-hidden="true">
<span class="rail-node"></span>
<span class="rail-line"></span>
</div>
<div class="card">
<button
type="button"
class="drag-handle"
aria-label="Drag to reorder this stop"
title="Drag to reorder"
>
<span aria-hidden="true">⠿</span>
</button>
<div class="card-main">
<button
type="button"
class="stop-head"
aria-expanded="true"
>
<span class="stop-icon" aria-hidden="true"></span>
<span class="stop-text">
<span class="stop-title"></span>
<span class="stop-meta">
<span class="stop-time"></span>
<span class="dotsep" aria-hidden="true">·</span>
<span class="stop-dur"></span>
</span>
</span>
<span class="chevron" aria-hidden="true">⌄</span>
</button>
<div class="stop-body">
<p class="stop-note"></p>
<div class="stop-tools">
<div class="dur-stepper" role="group" aria-label="Adjust duration">
<button type="button" class="step" data-step="-15" aria-label="Shorten by 15 minutes">−15m</button>
<span class="dur-readout" aria-live="polite"></span>
<button type="button" class="step" data-step="15" aria-label="Lengthen by 15 minutes">+15m</button>
</div>
<button type="button" class="remove" aria-label="Remove this stop">Remove</button>
</div>
</div>
</div>
</div>
<div class="connector" aria-hidden="true">
<span class="connector-icon">↧</span>
<span class="connector-label"></span>
</div>
</li>
</template>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Itinerary Timeline
A reusable single-day itinerary built for trip planners. Stops sit on a vertical, time-anchored rail: each card carries a type icon, a computed start time, a duration, and a small travel-time connector that bridges it to the next stop. Four accent colours — teal for sightseeing, coral for food, violet for transport, gold for hotel and rest — make the rhythm of the day readable at a glance, and a sticky summary panel tallies the number of stops, total active hours, minutes spent moving, and the time the day wraps up.
Every interaction actually works. Drag a stop by its handle to drop it anywhere in the order and all the clock times recompute from your chosen start time. The time field at the top reshapes the whole day; the duration stepper nudges any stop in fifteen-minute increments (clamped to a sensible floor and ceiling); and each card collapses to hide or reveal its notes. Adding a stop pulls from a rotating set of on-theme ideas and scrolls it into view, while remove and reset keep the plan easy to rebuild — each change confirmed by a small toast.
The layout is a two-column planner on the desktop that collapses to a single stacked column — summary first — on narrow screens, staying legible and tappable down to about 360px. Controls are real buttons and inputs with visible focus rings, the steppers and handles are keyboard-reachable, and reduced-motion preferences are respected.
Illustrative travel UI only — fictional destinations, prices, and maps.