// Schedule (Schedule view) โ€” beautiful chronological timeline of the entire trip. // // The view is meant to feel like a "wow, the whole trip is now organised!" // moment: one click and the disorganised diagram becomes a proper itinerary // with cumulative totals, day separators, and one-click export. // // Hero actions (top of panel): // ๐Ÿ“… Add to calendar (.ics) โ€” single button, all events at once // ๐Ÿ“Š Expense reconciliationCSV โ€” categorised expense report // ๐Ÿ–จ Print โ€” print-friendly layout // // Cost categories (matches Japanese expense reports): // Transport = sum of edge.cost // Lodging = sum of node.cost where node represents lodging // Misc = remaining node.cost function Schedule({ nodes, edges, groups, notes = [], defaultCurrency, title, subtitle, onClose }){ const dt = window.tourDateTime; const nodeById = Object.fromEntries(nodes.map(n => [n.id, n])); // Two display modes: ๐ŸŽจ timeline (default) and ๐Ÿ“‹ elegant table const [viewMode, setViewMode] = React.useState('timeline'); // Show / hide linked notes inline. Default ON so the schedule reads as a // self-contained "shiori" (bookmark). Users can toggle OFF for a clean version // when sharing with management or when notes are personal-only. // Toggle is ephemeral (not persisted) โ€” every reopen starts ON so the // notes don't go missing-by-default after a reimbursement print. const [showNotes, setShowNotes] = React.useState(true); // Index notes by anchor for fast inline lookup. // notesByNode[id] / notesByEdge[id] = Note[] in the order they appear. const notesByNode = {}, notesByEdge = {}; const orphanNotes = []; // anchor === null (or anchor target deleted) notes.forEach(note => { const a = note.anchor; if (!a || !a.type || !a.id) { orphanNotes.push(note); return; } if (a.type === 'node'){ if (!nodeById[a.id]) { orphanNotes.push(note); return; } (notesByNode[a.id] = notesByNode[a.id] || []).push(note); } else if (a.type === 'edge'){ const e = edges.find(x => x.id === a.id); if (!e) { orphanNotes.push(note); return; } (notesByEdge[a.id] = notesByEdge[a.id] || []).push(note); } else { orphanNotes.push(note); } }); // โ”€โ”€ Build chronological items, grouped by date โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // Multi-day nodes (e.g. an overnight hotel with arrival on day A and // departure on day B) appear in BOTH days so Day 2 starts naturally with // the hotel rather than the first outgoing edge. Each day-instance gets // a `perspective` flag ('arrival' | 'departure' | 'full') and a sortAt // tied to whichever event is relevant on that day: // arrival day perspective โ†’ sortAt = node.arrival (first event of arrival day) // departure day perspective โ†’ sortAt = node.departure (first event of departure day) // โ†’ Day 2's hotel sortAt = 8:00 (departure), so it lands at the top of // Day 2 ahead of the Morning walk walk that leaves at 8:00 too. const groupsOfNode = (nodeId) => (groups || []).filter(g => (g.nodeIds||[]).includes(nodeId)); const dayKeyOf = (d) => d ? `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}` : '__undated__'; const dayMap = new Map(); // key โ†’ items[] const dayFirstDate = new Map(); // key โ†’ earliest Date in that day (for label/header) const pushItem = (key, dateForHeader, item) => { if (!dayMap.has(key)) dayMap.set(key, []); dayMap.get(key).push(item); if (dateForHeader){ const cur = dayFirstDate.get(key); if (!cur || dateForHeader < cur) dayFirstDate.set(key, dateForHeader); } }; nodes.forEach(n => { const a = dt?.toDate(n.arrival); const d = dt?.toDate(n.departure); const aKey = a ? dayKeyOf(a) : null; const dKey = d ? dayKeyOf(d) : null; const ngroups = groupsOfNode(n.id); const baseInfo = { kind:'node', node:n, group: ngroups[0] || null, groups: ngroups, }; if (!aKey && !dKey){ pushItem('__undated__', null, { ...baseInfo, sortAt:null, perspective:'full' }); } else if (!dKey || aKey === dKey){ // Single-day stay (or arrival-only with no departure) pushItem(aKey || dKey, a || d, { ...baseInfo, sortAt: a || d, perspective:'full' }); } else if (!aKey){ // Departure-only (e.g. start-of-trip node with only departure set) pushItem(dKey, d, { ...baseInfo, sortAt:d, perspective:'full' }); } else { // Multi-day node โ€” appears at the END of the arrival day AND at the // START of the departure day. pushItem(aKey, a, { ...baseInfo, sortAt:a, perspective:'arrival' }); pushItem(dKey, d, { ...baseInfo, sortAt:d, perspective:'departure' }); } }); edges.forEach(e => { const from = nodeById[e.from]; const to = nodeById[e.to]; const sortAt = dt?.toDate(from?.departure) || dt?.toDate(to?.arrival) || null; pushItem(sortAt ? dayKeyOf(sortAt) : '__undated__', sortAt, { kind:'edge', edge:e, from, to, sortAt }); }); // Sort items within each day. Two phases: // 1. Items with a real sortAt (datetime) โ†’ sorted by time as before. // 2. Items WITHOUT sortAt (the undated bucket) โ†’ THREADED by edge // connections so the schedule reads as "A โ†’ edge1 โ†’ B โ†’ edge2 โ†’ C" // rather than "all nodes / then all edges". const threadUndated = (items) => { const edgeItems = items.filter(i => i.kind === 'edge') .sort((a,b) => ((a.edge.seq||0) - (b.edge.seq||0))); const nodeItems = items.filter(i => i.kind === 'node'); const nodeIndex = new Map(); nodeItems.forEach(ni => { if (!nodeIndex.has(ni.node.id)) nodeIndex.set(ni.node.id, ni); }); const out = []; const placed = new Set(); edgeItems.forEach(ei => { const fromNi = nodeIndex.get(ei.edge.from); if (fromNi && !placed.has(ei.edge.from)){ out.push(fromNi); placed.add(ei.edge.from); } out.push(ei); const toNi = nodeIndex.get(ei.edge.to); if (toNi && !placed.has(ei.edge.to)){ out.push(toNi); placed.add(ei.edge.to); } }); nodeItems.forEach(ni => { if (!placed.has(ni.node.id)) out.push(ni); }); return out; }; dayMap.forEach((arr, key) => { const dated = arr.filter(i => i.sortAt); const undated = arr.filter(i => !i.sortAt); dated.sort((a, b) => { const aT = a.sortAt.getTime(); const bT = b.sortAt.getTime(); if (aT !== bT) return aT - bT; if (a.kind !== b.kind) return a.kind === 'node' ? -1 : 1; if (a.kind === 'edge') return ((a.edge.seq||0) - (b.edge.seq||0)); return 0; }); const threaded = threadUndated(undated); arr.length = 0; arr.push(...dated, ...threaded); }); const days = [...dayMap.entries()] .sort((a, b) => a[0].localeCompare(b[0])) // YYYY-MM-DD strings sort lexically .map(([key, items]) => ({ key, date: dayFirstDate.get(key) || null, items, })); // Number day labels (skip undated) let dayNum = 0; days.forEach(day => { if (day.date) day.label = `${++dayNum}`; else day.label = 'TBD'; }); // Flat list (no multi-day duplication) โ€” used for CSV export and length // checks where we DON'T want hotels counted twice. const flatItems = []; nodes.forEach(n => { const sortAt = dt?.toDate(n.arrival) || dt?.toDate(n.departure) || null; flatItems.push({ kind:'node', node:n, sortAt }); }); edges.forEach(e => { const from = nodeById[e.from]; const to = nodeById[e.to]; const sortAt = dt?.toDate(from?.departure) || dt?.toDate(to?.arrival) || null; flatItems.push({ kind:'edge', edge:e, from, to, sortAt }); }); flatItems.sort((a, b) => { const aT = a.sortAt ? a.sortAt.getTime() : Infinity; const bT = b.sortAt ? b.sortAt.getTime() : Infinity; if (aT !== bT) return aT - bT; if (a.kind !== b.kind) return a.kind === 'node' ? -1 : 1; if (a.kind === 'edge') return ((a.edge.seq||0) - (b.edge.seq||0)); return 0; }); // โ”€โ”€ Cost categorisation totals (5 buckets ร— currency) โ”€โ”€โ”€โ”€โ”€ const cat = window.nodeCostCategory || (n => (n?.icon === 'bed' || n?.icon === 'tent') ? 'lodging' : 'misc'); const fallbackCur = defaultCurrency || '$'; const extractCur = window.extractCurrency || ((_s, fb) => fb || '$'); // Each "bucket" is a Map so a trip mixing $ and ยฅ // surfaces both subtotals separately rather than producing a wrong // lumped sum (we never auto-convert between currencies). const transportBucket = new Map(); const lodgingBucket = new Map(); const miscBucket = new Map(); const meetingBucket = new Map(); const entertainmentBucket = new Map(); const grandBucket = new Map(); const addToBucket = (b, sym, amt) => b.set(sym, (b.get(sym) || 0) + amt); edges.forEach(e => { const c = parseCost(e.cost); if (c <= 0) return; const sym = extractCur(e.cost, fallbackCur); addToBucket(transportBucket, sym, c); addToBucket(grandBucket, sym, c); }); nodes.forEach(n => { const c = parseCost(n.cost); if (c <= 0) return; const sym = extractCur(n.cost, fallbackCur); const k = cat(n); if (k === 'lodging') addToBucket(lodgingBucket, sym, c); else if (k === 'meeting') addToBucket(meetingBucket, sym, c); else if (k === 'entertainment') addToBucket(entertainmentBucket, sym, c); else addToBucket(miscBucket, sym, c); addToBucket(grandBucket, sym, c); }); // Sum across all currencies inside a bucket โ€” used only where we need // a single "is this empty?" check (e.g. hiding the totals card when // nothing has any cost). For DISPLAY we always render per-currency. const bucketSum = (b) => [...b.values()].reduce((s, v) => s + v, 0); const transportCost = bucketSum(transportBucket); const lodgingCost = bucketSum(lodgingBucket); const miscCost = bucketSum(miscBucket); const meetingCost = bucketSum(meetingBucket); const entertainmentCost = bucketSum(entertainmentBucket); const grandCost = bucketSum(grandBucket); // Render helper โ€” prints "$3,450 + ยฅ1,200" sorted with default first. const renderBucket = (bucket) => { const entries = [...bucket.entries()].filter(([_, v]) => v > 0); if (entries.length === 0) return formatCostWith(fallbackCur, 0); entries.sort((a, b) => { if (a[0] === fallbackCur) return -1; if (b[0] === fallbackCur) return 1; return a[0] < b[0] ? -1 : 1; }); return entries.map(([sym, v]) => formatCostWith(sym, v)).join(' + '); }; // Helper: nice formatted clock time for the timeline column. const fmtTime = (s) => { const d = dt?.toDate(s); if (!d) return ''; return `${d.getHours()}:${String(d.getMinutes()).padStart(2,'0')}`; }; // โ”€โ”€ Calendar export (.ics) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // When showNotes=true the inline memo text is also stitched into each // event's DESCRIPTION so notes ride along into Apple/Google/Outlook. // NOTE: Use REAL newline characters ('\n') in description content. The // escapeIcs() helper converts them to the iCal-required '\\n' escape. // Earlier code used '\\n' directly here, which got double-escaped and // showed up as literal "\n" text in calendar apps. const exportIcs = () => { if (!dt) return; const events = []; const noteLines = (arr) => (arr || []).map(nt => `๐Ÿ“ ${nt.text || ''}`).join('\n'); nodes.forEach(n => { if (n.arrival && n.departure){ const costLine = n.cost ? `${cat(n)==='lodging'?'Lodging':'Misc'}: ${n.cost}` : ''; const notesPart = showNotes ? noteLines(notesByNode[n.id]) : ''; const descParts = [n.subtitle || '', costLine, notesPart].filter(Boolean); events.push({ uid: n.id + '@amix-tour', start: n.arrival, end: n.departure, summary: n.name || '(no name)', description: descParts.join('\n'), location: n.name, }); } }); edges.forEach(e => { const from = nodeById[e.from]; const to = nodeById[e.to]; if (!from?.departure || !to?.arrival) return; const rel = window.RELATIONS?.find(r => r.id === e.relation); const notesPart = showNotes ? noteLines(notesByEdge[e.id]) : ''; const costPart = e.cost ? `Travel & transit: ${e.cost}` : ''; const descParts = [costPart, notesPart].filter(Boolean); events.push({ uid: e.id + '@amix-tour', start: from.departure, end: to.arrival, summary: `${e.label || rel?.label || 'Leg'}: ${from.name} โ†’ ${to.name}`, description: descParts.join('\n'), location: '', }); }); if (events.length === 0){ alert('No events to export. Please set arrival/departure datetimes on places.'); return; } const ics = dt.buildIcs(title || 'Itinerary', events); const fname = (title || 'tour').replace(/[\\/:*?"<>|]/g, '_') + '.ics'; dt.downloadIcs(fname, ics); }; // โ”€โ”€ Expense CSV export (For expense reconciliation) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const exportCsv = () => { const fmtDate = d => d ? `${d.getFullYear()}/${d.getMonth()+1}/${d.getDate()}` : ''; const fmtCsv = s => `"${String(s ?? '').replace(/"/g, '""')}"`; // Amounts are written symbol-prefixed ("$25.50", "ยฅ190") so the column // is human-readable at a glance. The trade-off is that Excel sees the // cell as text and SUM/SUMIF won't work directly โ€” but every per- // (category ร— currency) subtotal and per-currency grand total is already // rendered in dedicated rows at the bottom of the file, so that's covered. const headers = ['Date', 'Time', 'Type', 'Content', 'Amount', 'Notes']; const lines = [headers.map(fmtCsv).join(',')]; flatItems.forEach(it => { if (it.kind === 'node'){ const n = it.node; const c = parseCost(n.cost); if (c <= 0) return; // only output rows that have a cost const category = categoryLabel(cat(n)); const d = dt?.toDate(n.arrival) || dt?.toDate(n.departure); lines.push([ fmtDate(d), fmtTime(n.arrival || n.departure), category, n.name + (n.subtitle ? ` (${n.subtitle})` : ''), formatCostWith(extractCur(n.cost, fallbackCur), c), '', ].map(fmtCsv).join(',')); } else { const e = it.edge; const c = parseCost(e.cost); if (c <= 0) return; const rel = window.RELATIONS?.find(r => r.id === e.relation); const d = dt?.toDate(it.from?.departure) || dt?.toDate(it.to?.arrival); lines.push([ fmtDate(d), fmtTime(it.from?.departure || it.to?.arrival), 'Travel & transit', `${e.label || rel?.label || 'Leg'} (${it.from?.name || '?'} โ†’ ${it.to?.name || '?'})`, formatCostWith(extractCur(e.cost, fallbackCur), c), '', ].map(fmtCsv).join(',')); } }); // Subtotal + total rows โ€” one row per (category ร— currency) so totals // stay split when the trip mixes USD and foreign currency. The Amount // column carries the currency symbol inline ("$25.50", "ยฅ190"). const pushBucketRows = (label, bucket) => { [...bucket.entries()] .filter(([_, v]) => v > 0) .sort((a, b) => a[0] === fallbackCur ? -1 : b[0] === fallbackCur ? 1 : (a[0] < b[0] ? -1 : 1)) .forEach(([sym, v]) => { lines.push(['', '', label, 'Subtotal', formatCostWith(sym, v), ''].map(fmtCsv).join(',')); }); }; lines.push(''); pushBucketRows('Travel & transit', transportBucket); pushBucketRows('Lodging', lodgingBucket); pushBucketRows('Misc', miscBucket); pushBucketRows('Meeting', meetingBucket); pushBucketRows('Entertainment', entertainmentBucket); // Grand total per currency. [...grandBucket.entries()] .filter(([_, v]) => v > 0) .sort((a, b) => a[0] === fallbackCur ? -1 : b[0] === fallbackCur ? 1 : (a[0] < b[0] ? -1 : 1)) .forEach(([sym, v]) => { lines.push(['', '', 'Total', '', formatCostWith(sym, v), ''].map(fmtCsv).join(',')); }); // BOM + CRLF for Excel compatibility const csv = '๏ปฟ' + lines.join('\r\n'); const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = (title || 'tour').replace(/[\\/:*?"<>|]/g, '_') + '_Expense.csv'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }; // โ”€โ”€ Itinerary CSV export (spreadsheet-friendly) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // Same row vocabulary as the on-screen schedule but in spreadsheet form, // useful for sharing with travel companions / pasting into a planning // doc / archiving the trip outline. Unlike the expense CSV (which only // shows rows with costs), the itinerary CSV shows EVERY entry. const exportItineraryCsv = () => { const fmtDateLong = d => d ? `${d.getFullYear()}/${d.getMonth()+1}/${d.getDate()}` : ''; const fmtCsv = s => `"${String(s ?? '').replace(/"/g, '""')}"`; // Cost is written symbol-prefixed ("$25.50", "ยฅ190") in a single column. // This is the share-it-with-travel-companions CSV โ€” readability beats // calculability here. The expense CSV ships per-currency subtotals if // you need totals, so we don't duplicate that here. const headers = ['Date', 'Time', 'Type', 'Stop / Leg', 'Detail', 'Duration', 'Cost', 'Notes']; const lines = [headers.map(fmtCsv).join(',')]; flatItems.forEach(it => { if (it.kind === 'node'){ const n = it.node; const arrD = dt?.toDate(n.arrival); const depD = dt?.toDate(n.departure); const dayD = arrD || depD; const stayMins = (arrD && depD) ? dt.minutesBetween(n.arrival, n.departure) : null; const stayTxt = (stayMins && stayMins > 0) ? dt.formatDuration(stayMins) : ''; const c = parseCost(n.cost); const cost = c > 0 ? formatCostWith(extractCur(n.cost, fallbackCur), c) : ''; const timeRange = (n.arrival && n.departure) ? `${fmtTime(n.arrival)} โ†’ ${fmtTime(n.departure)}` : (n.arrival ? `${fmtTime(n.arrival)} arr` : (n.departure ? `${fmtTime(n.departure)} dep` : '')); const notesTxt = (notesByNode[n.id] || []).map(nt => (nt.text || '').replace(/\r?\n/g, ' ')).join(' / '); lines.push([ fmtDateLong(dayD), timeRange, 'Stop', n.name || '', n.subtitle || '', stayTxt, cost, notesTxt, ].map(fmtCsv).join(',')); } else { const e = it.edge; const fromN = it.from, toN = it.to; const dep = dt?.toDate(fromN?.departure); const arr = dt?.toDate(toN?.arrival); const dayD = dep || arr; const transitMins = (fromN?.departure && toN?.arrival) ? dt.minutesBetween(fromN.departure, toN.arrival) : null; const dur = (transitMins && transitMins > 0) ? dt.formatDuration(transitMins) : ''; const c = parseCost(e.cost); const cost = c > 0 ? formatCostWith(extractCur(e.cost, fallbackCur), c) : ''; const rel = window.RELATIONS?.find(r => r.id === e.relation); const timeRange = (fromN?.departure && toN?.arrival) ? `${fmtTime(fromN.departure)} โ†’ ${fmtTime(toN.arrival)}` : (fromN?.departure ? `${fmtTime(fromN.departure)} dep` : ''); const notesTxt = (notesByEdge[e.id] || []).map(nt => (nt.text || '').replace(/\r?\n/g, ' ')).join(' / '); lines.push([ fmtDateLong(dayD), timeRange, 'Leg', `${fromN?.name || '?'} โ†’ ${toN?.name || '?'}`, e.label || rel?.label || '', dur, cost, notesTxt, ].map(fmtCsv).join(',')); } }); const csv = '๏ปฟ' + lines.join('\r\n'); const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = (title || 'tour').replace(/[\\/:*?"<>|]/g, '_') + '_itinerary.csv'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }; return (
e.stopPropagation()}> {/* โ”€โ”€ HERO HEADER โ”€โ”€ */}
Schedule

{title || 'Itinerary'}

{subtitle &&
{subtitle}
}
{nodes.length} {nodes.length === 1 ? 'place' : 'places'} ยท {edges.length} {edges.length === 1 ? 'edge' : 'edges'} {days[0]?.date && <>ยท{days.filter(d=>d.date).length} {days.filter(d=>d.date).length === 1 ? 'day' : 'days'}}
{/* View toggle: timeline / table โ€” and notes ON/OFF */} {flatItems.length > 0 && (
{notes.length > 0 && ( )}
)} {/* โ”€โ”€ TIMELINE / TABLE โ”€โ”€ */} {flatItems.length === 0 ? (
No places or arrows yet.
) : viewMode === 'table' ? ( ) : (
{days.map((day, di) => (
{day.label}
{day.date ? `${day.date.getFullYear()}/${day.date.getMonth()+1}/${day.date.getDate()} (${'DayMTWTFSat'[day.date.getDay()]})` : 'Unscheduled'}
{day.items.map((it, i) => { if (it.kind === 'node'){ const n = it.node; const stamp = n.icon; const persp = it.perspective || 'full'; // 'full' | 'arrival' | 'departure' const isDepView = persp === 'departure'; // shown at start of Day 2 etc. const isArrView = persp === 'arrival'; // shown at end of Day 1 etc. // Cost/stay info appears once โ€” on the arrival side (or full single-day). const stayMins = !isDepView && dt && n.arrival && n.departure ? dt.minutesBetween(n.arrival, n.departure) : null; const c = isDepView ? 0 : parseCost(n.cost); const cTag = cat(n); const hi = n.highlight || null; return (
{/* Show only the relevant time per perspective so Day 2's hotel reads as "Dep 8:00" rather than the full pair. */} {(persp === 'full' || isArrView) && n.arrival && (
Arr {fmtTime(n.arrival)}
)} {(persp === 'full' || isDepView) && n.departure && (
Dep {isDepView ? {fmtTime(n.departure)} : fmtTime(n.departure)}
)} {!n.arrival && !n.departure &&
โ€”
}
{stamp && window.TourIcon ? : null}
{hi && ( Highlight )}

{n.name || '(no name)'}

{n.subtitle && {n.subtitle}} {(it.groups || []).map(g => ( {g.name} ))}
{(stayMins || c > 0) && (
{stayMins ? Stay {dt.formatDuration(stayMins)} : null} {c > 0 && ( {categoryLabel(cTag)} {formatCostWith(extractCur(n.cost, fallbackCur), c)} )}
)} {/* Inline notes โ€” one per anchored memo. Only on the arrival/full perspective so multi-day hotels don't repeat the memo on Day 2. */} {showNotes && !isDepView && (notesByNode[n.id] || []).map(note => (
{note.text || ''}
))}
); } // EDGE row const e = it.edge; const rel = window.RELATIONS?.find(rr=>rr.id===e.relation); const stamp = e.stamp || rel?.stamp; const c = parseCost(e.cost); const transitMins = (dt && it.from?.departure && it.to?.arrival) ? dt.minutesBetween(it.from.departure, it.to.arrival) : null; return (
{it.from?.departure &&
Dep {fmtTime(it.from.departure)}
} {it.to?.arrival &&
โ†’ {fmtTime(it.to.arrival)}
}
{e.seq ?? ''} {stamp && window.TourIcon ? {e.label || rel?.label || ''} : {e.label || rel?.label || ''}} {it.from?.name || '?'} โ†’ {it.to?.name || '?'}
{(transitMins || c > 0) && (
{transitMins ? Move {dt.formatDuration(transitMins)} : null} {c > 0 && ( Travel & transit {formatCostWith(extractCur(it.edge.cost, fallbackCur), c)} )}
)} {showNotes && (notesByEdge[e.id] || []).map(note => (
{note.text || ''}
))}
); })} {/* End-of-day orphan notes (anchor=null) โ€” only show on the last day so they don't repeat. */} {showNotes && di === days.length - 1 && orphanNotes.length > 0 && (
Other notes
{orphanNotes.map(note => (
{note.text || ''}
))}
)}
))}
)} {/* โ”€โ”€ TOTALS FOOTER โ”€โ”€ */} {grandCost > 0 && (
Travel & transit
{renderBucket(transportBucket)}
Lodging
{renderBucket(lodgingBucket)}
Misc
{renderBucket(miscBucket)}
{meetingCost > 0 && (
Meeting
{renderBucket(meetingBucket)}
)} {entertainmentCost > 0 && (
Entertainment
{renderBucket(entertainmentBucket)}
)}
Total
{renderBucket(grandBucket)}
Expense CSV with these categorieswill be exported โ€” Excel / Google Sheets / Existing expense form ready.
)}
); } // Cost category โ†’ JP label mapping (used in tags / table cells / CSV). function categoryLabel(k){ switch(k){ case 'lodging': return 'Lodging'; case 'meeting': return 'Meeting'; case 'entertainment': return 'Entertainment'; default: return 'Misc'; } } function parseCost(s){ if (!s) return 0; // Same symbol set as SummaryBar โ€” keep them in sync so totals match. const t = String(s) .replace(/A\$/g, '') .replace(/CHF/gi, '') .replace(/[,ยฅ$โ‚ฌยฃๅ††ๅ…ƒโ‚ฉเธฟโ‚ซ\s]/g,'') .trim(); const n = parseFloat(t); return isFinite(n) ? n : 0; } // Format an amount with an arbitrary currency symbol prefix. Integer-only // currencies (yen, won) display whole numbers; fractional currencies // (USD, EUR, etc.) keep up to 2 decimals to avoid rounding loss. function formatCostWith(symbol, amt){ const sym = symbol || '$'; const v = Math.round((amt || 0) * 100) / 100; if (v === Math.floor(v)) return sym + v.toLocaleString('en-US'); return sym + v.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } // Backwards-compat: `formatCost` assumes $ โ€” used in spots that haven't // been threaded with the trip's defaultCurrency yet. function formatCost(amt){ return formatCostWith('$', amt); } // โ”€โ”€ Table view โ€” Excel-like elegance, optimized for printing โ”€โ”€โ”€ function ScheduleTable({ days, dt, fmtTime, cat, defaultCurrency, notesByNode = {}, notesByEdge = {}, orphanNotes = [], showNotes = true }){ const fmtDateOnly = (d) => d ? `${d.getMonth()+1}/${d.getDate()}` : 'โ€”'; const fallbackCur = defaultCurrency || '$'; const extractCur = window.extractCurrency || ((_s, fb) => fb || '$'); return (
{days.map((day, di) => ( {day.items.map((it, i) => { if (it.kind === 'node'){ const n = it.node; const persp = it.perspective || 'full'; const isDepView = persp === 'departure'; const isArrView = persp === 'arrival'; const c = isDepView ? 0 : parseCost(n.cost); const cTag = cat(n); const stayMins = !isDepView && dt && n.arrival && n.departure ? dt.minutesBetween(n.arrival, n.departure) : null; const d = isDepView ? (dt?.toDate(n.departure) || null) : (dt?.toDate(n.arrival) || dt?.toDate(n.departure)); const hi = n.highlight || null; const ngroups = it.groups || []; return ( {/* Inline notes โ€” second row spans the content + right cols */} {showNotes && !isDepView && (notesByNode[n.id] || []).map(note => ( ))} ); } const e = it.edge; const rel = window.RELATIONS?.find(rr=>rr.id===e.relation); const c = parseCost(e.cost); const transitMins = (dt && it.from?.departure && it.to?.arrival) ? dt.minutesBetween(it.from.departure, it.to.arrival) : null; const d = dt?.toDate(it.from?.departure) || dt?.toDate(it.to?.arrival); return ( {showNotes && (notesByEdge[e.id] || []).map(note => ( ))} ); })} {/* Orphan notes at end of table (last day only) */} {showNotes && di === days.length - 1 && orphanNotes.length > 0 && ( )} ))}
Date Time Type Content Duration Amount Category
{day.label} {day.date ? `${day.date.getFullYear()}/${day.date.getMonth()+1}/${day.date.getDate()} (${'DayMTWTFSat'[day.date.getDay()]})` : 'Unscheduled'}
{fmtDateOnly(d)} {(persp==='full' || isArrView) && n.arrival && (
Arr {fmtTime(n.arrival)}
)} {(persp==='full' || isDepView) && n.departure && (
Dep {isDepView ? {fmtTime(n.departure)} : fmtTime(n.departure)}
)}
Place {ngroups.length > 0 && (
{ngroups.map(g => ( {g.name} ))}
)}
{hi && ( Highlight )} {n.name || '(no name)'} {n.subtitle && {n.subtitle}} {stayMins ? Stay {dt.formatDuration(stayMins)} : ''} {c > 0 ? formatCostWith(extractCur(it.kind === 'node' ? it.node.cost : it.edge.cost, fallbackCur), c) : ''} {c > 0 && {categoryLabel(cTag)}}
{note.text || ''}
{fmtDateOnly(d)} {it.from?.departure &&
Dep {fmtTime(it.from.departure)}
} {it.to?.arrival &&
โ†’ {fmtTime(it.to.arrival)}
}
{e.seq ?? ''} {e.label || rel?.label || ''} {it.from?.name || '?'} โ†’ {it.to?.name || '?'} {transitMins ? Move {dt.formatDuration(transitMins)} : ''} {c > 0 ? formatCost(c) : ''} {c > 0 && Travel & transit}
{note.text || ''}
Other notes
{orphanNotes.map(note => (
{note.text || ''}
))}
); } window.Schedule = Schedule;