// 工程表 (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): // 📅 カレンダーに登録 (.ics) — single button, all events at once // 📊 経費精算CSV — categorised expense report // 🖨 印刷 — print-friendly layout // // Cost categories (matches Japanese expense reports): // 旅費交通費 = sum of edge.cost // 宿泊費 = sum of node.cost where node represents lodging // 雑費 = 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" (栞). 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 朝の散歩 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" (which loses the graph // structure when no times are set). // Phase 1 is per-day sort; phase 2 only kicks in when sortAt is null. const threadUndated = (items) => { // items here all have sortAt = null. Walk edges in seq order and emit // from-node → edge → to-node, skipping nodes already placed. Orphan // nodes (not referenced by any edge) get appended at the end. 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 => { // A node may have multiple instances if it spans days (perspective). // For undated bucket, perspective is always 'full' so 1 instance. 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); } }); // Orphan nodes (no incoming/outgoing edges) — append at end. 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 = '未定'; }); // 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 + $25.50" 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 will convert them to the iCal-required '\\n' escape // when emitting the file. Earlier code used '\\n' here directly, which // got double-escaped and showed up as literal "\n" text in calendar apps // instead of an actual line break. 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'?'宿泊費':'雑費'}: ${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 ? `旅費交通費: ${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 || '区間'}:${from.name} → ${to.name}`, description: descParts.join('\n'), location: '', }); }); if (events.length === 0){ alert('カレンダーに書き出す予定がありません。各地点の到着・出発日時を入力してください。'); return; } const ics = dt.buildIcs(title || '旅程', events); const fname = (title || 'tour').replace(/[\\/:*?"<>|]/g, '_') + '.ics'; dt.downloadIcs(fname, ics); }; // ── Expense CSV export (経費精算用) ─────────────────────── const exportCsv = () => { const fmtDate = d => d ? `${d.getFullYear()}/${d.getMonth()+1}/${d.getDate()}` : ''; const fmtCsv = s => `"${String(s ?? '').replace(/"/g, '""')}"`; // 金額は "¥190" / "$25.50" のように記号 + 数値で1セルに書き出します。 // SUMIF で通貨ごとに合計したい場合は通貨記号がある別列が必要ですが、 // この CSV は (種別 × 通貨) ごとの小計行を末尾にまとめて出すので、 // ユーザーは目視で必要な合計を読み取れます。Excel の SUM が直接効か // ない代わりに、見た時点で「¥190」と把握しやすいのを優先しました。 const headers = ['日付', '時刻', '種別', '内容', '金額', '備考']; 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), '旅費交通費', `${e.label || rel?.label || '区間'} (${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 JPY and foreign currency. The amount // column carries the currency symbol inline ("¥190", "$25.50"). 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, '小計', formatCostWith(sym, v), ''].map(fmtCsv).join(',')); }); }; lines.push(''); pushBucketRows('旅費交通費', transportBucket); pushBucketRows('宿泊費', lodgingBucket); pushBucketRows('雑費', miscBucket); pushBucketRows('会議費', meetingBucket); pushBucketRows('交際費', 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(['', '', '合計', '', 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, '_') + '_経費.csv'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }; // ── Itinerary CSV export (旅程用) ─────────────────────────── // 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, '""')}"`; // 費用は "¥190" / "$25.50" のように記号付きで 1 セルに書き出します。 // 共有ドキュメントとして読みやすさを優先 — Excel の SUM は効きませんが、 // 経費精算 CSV 側に通貨別小計が出ているのでこの旅程 CSV では不要です。 const headers = ['日付', '時刻', '種別', '地点/区間', '内容', '所要時間', '費用', '備考']; 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)} 着` : (n.departure ? `${fmtTime(n.departure)} 発` : '')); const notesTxt = (notesByNode[n.id] || []).map(nt => (nt.text || '').replace(/\r?\n/g, ' ')).join(' / '); lines.push([ fmtDateLong(dayD), timeRange, '地点', 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)} 発` : ''); const notesTxt = (notesByEdge[e.id] || []).map(nt => (nt.text || '').replace(/\r?\n/g, ' ')).join(' / '); lines.push([ fmtDateLong(dayD), timeRange, '区間', `${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, '_') + '_旅程.csv'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }; return (
e.stopPropagation()}> {/* ── HERO HEADER ── */}
工程表

{title || '旅程'}

{subtitle &&
{subtitle}
}
{nodes.length} 地点 · {edges.length} 区間 {days[0]?.date && <>·{days.filter(d=>d.date).length}}
{/* View toggle: timeline / table — and notes ON/OFF */} {flatItems.length > 0 && (
{notes.length > 0 && ( )}
)} {/* ── TIMELINE / TABLE ── */} {flatItems.length === 0 ? (
まだ地点・矢印がありません。
) : viewMode === 'table' ? ( ) : (
{days.map((day, di) => (
{day.label}
{day.date ? `${day.date.getFullYear()}/${day.date.getMonth()+1}/${day.date.getDate()} (${'日月火水木金土'[day.date.getDay()]})` : '日時未定'}
{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 "発 8:00" rather than the full pair. */} {(persp === 'full' || isArrView) && n.arrival && (
{fmtTime(n.arrival)}
)} {(persp === 'full' || isDepView) && n.departure && (
発 {isDepView ? {fmtTime(n.departure)} : fmtTime(n.departure)}
)} {!n.arrival && !n.departure &&
}
{stamp && window.TourIcon ? : null}
{hi && ( 注目 )}

{n.name || '(無名)'}

{n.subtitle && {n.subtitle}} {(it.groups || []).map(g => ( {g.name} ))}
{(stayMins || c > 0) && (
{stayMins ? 滞在 {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 &&
{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 ? 移動 {dt.formatDuration(transitMins)} : null} {c > 0 && ( 旅費交通費 {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 && (
その他のメモ
{orphanNotes.map(note => (
{note.text || ''}
))}
)}
))}
)} {/* ── TOTALS FOOTER ── */} {grandCost > 0 && (
旅費交通費
{renderBucket(transportBucket)}
宿泊費
{renderBucket(lodgingBucket)}
雑費
{renderBucket(miscBucket)}
{meetingCost > 0 && (
会議費
{renderBucket(meetingBucket)}
)} {entertainmentCost > 0 && (
交際費
{renderBucket(entertainmentBucket)}
)}
合計
{renderBucket(grandBucket)}
経費精算 CSV はこの分類のまま書き出されます — Excel / Google Sheets / 既存の精算フォームに貼付け可能。
)}
); } // Cost category → JP label mapping (used in tags / table cells / CSV). function categoryLabel(k){ switch(k){ case 'lodging': return '宿泊費'; case 'meeting': return '会議費'; case 'entertainment': return '交際費'; default: return '雑費'; } } 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(yen){ return formatCostWith('¥', yen); } // ── 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 && ( )} ))}
日付 時刻 種別 内容 時間 金額 分類
{day.label} {day.date ? `${day.date.getFullYear()}/${day.date.getMonth()+1}/${day.date.getDate()} (${'日月火水木金土'[day.date.getDay()]})` : '日時未定'}
{fmtDateOnly(d)} {(persp==='full' || isArrView) && n.arrival && (
{fmtTime(n.arrival)}
)} {(persp==='full' || isDepView) && n.departure && (
発 {isDepView ? {fmtTime(n.departure)} : fmtTime(n.departure)}
)}
地点 {ngroups.length > 0 && (
{ngroups.map(g => ( {g.name} ))}
)}
{hi && ( 注目 )} {n.name || '(無名)'} {n.subtitle && {n.subtitle}} {stayMins ? 滞在 {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 &&
{fmtTime(it.from.departure)}
} {it.to?.arrival &&
→ {fmtTime(it.to.arrival)}
}
{e.seq ?? ''} {e.label || rel?.label || ''} {it.from?.name || '?'} → {it.to?.name || '?'} {transitMins ? 移動 {dt.formatDuration(transitMins)} : ''} {c > 0 ? formatCost(c) : ''} {c > 0 && 旅費交通費}
{note.text || ''}
その他のメモ
{orphanNotes.map(note => (
{note.text || ''}
))}
); } window.Schedule = Schedule;