trip-day-tracker/index.html

1247 lines
57 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="theme-color" content="#0b1220">
<link rel="manifest" href="manifest.webmanifest">
<link rel="apple-touch-icon" href="icon-512.png">
<link rel="icon" href="icon-512.png">
<title>Business Trip Day Tracker</title>
<style>
:root{
--bg:#f4f5f7; --card:#ffffff; --ink:#111827; --muted:#6b7280; --line:#e2e5ea;
--accent:#b8862b; --accent-ink:#7a5a14;
--biz:#166534; --biz-bg:#dcfce7;
--trv:#1d4ed8; --trv-bg:#dbeafe;
--per:#9a3412; --per-bg:#ffedd5;
--danger:#b91c1c; --danger-bg:#fee2e2;
--radius:14px;
--tap:48px;
--safe-b:env(safe-area-inset-bottom,0px);
}
@media (prefers-color-scheme: dark){
:root{
--bg:#0b1220; --card:#151d2b; --ink:#e8ecf3; --muted:#95a1b4; --line:#26303f;
--accent:#d7a94a; --accent-ink:#e8c375;
--biz:#86efac; --biz-bg:#123524;
--trv:#93c5fd; --trv-bg:#132844;
--per:#fdba74; --per-bg:#3a2410;
--danger:#fca5a5; --danger-bg:#3b1414;
}
}
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
html,body{margin:0;padding:0}
body{
background:var(--bg); color:var(--ink);
font:16px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
padding-bottom:calc(78px + var(--safe-b));
overscroll-behavior-y:none;
}
h1,h2,h3{margin:0}
button{font:inherit;color:inherit}
/* ---------- app bar ---------- */
#appbar{
position:sticky; top:0; z-index:20;
background:var(--card); border-bottom:1px solid var(--line);
padding:calc(10px + env(safe-area-inset-top,0px)) 16px 10px;
}
#barTitle{font-size:19px;font-weight:700;letter-spacing:-.01em;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
#barSub{font-size:13px;color:var(--muted);margin-top:2px}
#savedFlash{
position:fixed; left:50%; transform:translateX(-50%) translateY(-10px);
top:calc(8px + env(safe-area-inset-top,0px)); z-index:60;
background:var(--ink); color:var(--bg); font-size:13px; font-weight:600;
padding:6px 14px; border-radius:999px; opacity:0; pointer-events:none;
transition:opacity .18s ease, transform .18s ease;
}
#savedFlash.on{opacity:.92; transform:translateX(-50%) translateY(0)}
/* ---------- layout ---------- */
main{padding:14px 12px 8px; max-width:720px; margin:0 auto}
.view[hidden]{display:none}
.section-title{font-size:13px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin:20px 4px 8px}
.section-title:first-child{margin-top:4px}
.card{
background:var(--card); border:1px solid var(--line); border-radius:var(--radius);
margin-bottom:12px; overflow:hidden;
}
.pad{padding:14px}
/* ---------- form controls ---------- */
label.f{display:block;margin:0 0 12px}
label.f > span.lab{display:block;font-size:13px;font-weight:600;color:var(--muted);margin-bottom:5px}
input[type=text],input[type=date],input[type=url],input[type=number],select,textarea{
width:100%; min-height:var(--tap); padding:11px 12px;
font-size:16px; /* 16px stops iOS zoom-on-focus */
color:var(--ink); background:var(--bg);
border:1px solid var(--line); border-radius:11px;
appearance:none; -webkit-appearance:none;
}
textarea{min-height:78px; resize:vertical; line-height:1.4}
select{background-image:linear-gradient(45deg,transparent 50%,var(--muted) 50%),linear-gradient(135deg,var(--muted) 50%,transparent 50%);
background-position:calc(100% - 18px) 21px,calc(100% - 13px) 21px;background-size:5px 5px,5px 5px;background-repeat:no-repeat;padding-right:36px}
input:focus,select:focus,textarea:focus{outline:2px solid var(--accent);outline-offset:-1px;border-color:transparent}
.row2{display:grid;grid-template-columns:1fr 1fr;gap:10px}
@media (max-width:400px){.row2{grid-template-columns:1fr}}
.btn{
display:inline-flex;align-items:center;justify-content:center;gap:8px;
min-height:var(--tap); padding:0 16px; width:100%;
background:var(--bg); border:1px solid var(--line); border-radius:11px;
font-weight:600; cursor:pointer;
}
.btn:active{transform:scale(.985)}
.btn.primary{background:var(--accent);border-color:var(--accent);color:#1b1305}
.btn.ghost{background:transparent}
.btn.danger{color:var(--danger);border-color:var(--line)}
.btn.sm{min-height:40px;font-size:14px;width:auto;padding:0 12px}
.btnstack > .btn{margin-bottom:10px}
.btnstack > .btn:last-child{margin-bottom:0}
/* ---------- day-type radios ---------- */
.typewrap{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-bottom:14px}
.typewrap input{position:absolute;opacity:0;pointer-events:none}
.typewrap label{
display:flex;align-items:center;justify-content:center;
min-height:var(--tap); border:1px solid var(--line); border-radius:11px;
font-weight:600; font-size:15px; background:var(--bg); cursor:pointer;
}
.typewrap input:checked + label[data-t=Business]{background:var(--biz-bg);color:var(--biz);border-color:var(--biz)}
.typewrap input:checked + label[data-t=Travel]{background:var(--trv-bg);color:var(--trv);border-color:var(--trv)}
.typewrap input:checked + label[data-t=Personal]{background:var(--per-bg);color:var(--per);border-color:var(--per)}
/* ---------- day card ---------- */
.cardhead{
display:flex;align-items:center;gap:12px;width:100%;
padding:14px; background:transparent;border:0;text-align:left;cursor:pointer;
}
.cardhead .grow{flex:1;min-width:0}
.ddate{font-weight:700;font-size:16px}
.dsub{font-size:13px;color:var(--muted);margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.chip{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;padding:5px 9px;border-radius:999px;white-space:nowrap}
.chip.Business{background:var(--biz-bg);color:var(--biz)}
.chip.Travel{background:var(--trv-bg);color:var(--trv)}
.chip.Personal{background:var(--per-bg);color:var(--per)}
.caret{color:var(--muted);font-size:13px;transition:transform .15s ease}
.card.open .caret{transform:rotate(90deg)}
.cardbody{padding:0 14px 14px;border-top:1px solid var(--line)}
.cardbody .section-title:first-child{margin-top:14px}
/* ---------- repeatable rows ---------- */
.rows{margin-bottom:6px}
.subrow{
border:1px solid var(--line); border-radius:12px; padding:12px; margin-bottom:10px;
background:var(--bg);
}
.subrow .rhead{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}
.rnum{font-size:12px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em}
.xbtn{
min-width:44px;min-height:44px;margin:-4px -8px -4px 0;
border:0;background:transparent;color:var(--muted);
font-size:19px;line-height:1;border-radius:10px;cursor:pointer;
}
.xbtn:active{background:var(--danger-bg);color:var(--danger)}
.empty{font-size:14px;color:var(--muted);padding:2px 2px 10px}
/* ---------- receipts ---------- */
.receipt{display:flex;align-items:center;gap:10px;margin-top:2px}
.receipt img{width:56px;height:56px;object-fit:cover;border-radius:9px;border:1px solid var(--line);background:var(--bg)}
.filelabel{
display:inline-flex;align-items:center;justify-content:center;
min-height:44px;padding:0 14px;border:1px solid var(--line);border-radius:11px;
font-size:14px;font-weight:600;background:var(--card);cursor:pointer;
}
.filelabel input{display:none}
/* ---------- summary ---------- */
.stats{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-bottom:12px}
.stat{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);padding:12px 10px;text-align:center}
.stat .n{font-size:24px;font-weight:700;letter-spacing:-.02em}
.stat .l{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-top:2px}
.stat.Business .n{color:var(--biz)} .stat.Travel .n{color:var(--trv)} .stat.Personal .n{color:var(--per)}
.warn{
background:var(--danger-bg);border:1px solid var(--danger);color:var(--danger);
border-radius:var(--radius);padding:13px 14px;margin-bottom:12px;font-size:14px;font-weight:600;line-height:1.4
}
.ok{background:var(--biz-bg);border:1px solid transparent;color:var(--biz);border-radius:var(--radius);padding:11px 14px;margin-bottom:12px;font-size:14px}
table.t{width:100%;border-collapse:collapse;font-size:15px}
table.t td,table.t th{padding:10px 0;border-bottom:1px solid var(--line);text-align:left}
table.t th{font-size:12px;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);font-weight:700}
table.t td.n,table.t th.n{text-align:right;font-variant-numeric:tabular-nums}
table.t tr:last-child td{border-bottom:0}
tr.tot td{font-weight:700;border-top:2px solid var(--line)}
.sub{font-size:12px;color:var(--muted);font-weight:400}
.note{font-size:13px;color:var(--muted);margin:8px 4px 0;line-height:1.45}
/* ---------- tab bar ---------- */
#tabbar{
position:fixed;left:0;right:0;bottom:0;z-index:30;
display:grid;grid-template-columns:repeat(3,1fr);
background:var(--card);border-top:1px solid var(--line);
padding-bottom:var(--safe-b);
}
#tabbar button{
border:0;background:transparent;min-height:60px;cursor:pointer;
display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;
font-size:11px;font-weight:700;letter-spacing:.03em;text-transform:uppercase;color:var(--muted);
}
#tabbar button .ic{font-size:19px;line-height:1}
#tabbar button[aria-selected=true]{color:var(--accent-ink)}
@media (prefers-color-scheme: dark){#tabbar button[aria-selected=true]{color:var(--accent)}}
#storageWarn{
background:var(--danger-bg);color:var(--danger);border-bottom:1px solid var(--danger);
padding:10px 16px;font-size:13px;font-weight:600;line-height:1.35
}
.meter{height:6px;background:var(--bg);border-radius:99px;overflow:hidden;border:1px solid var(--line);margin-top:6px}
.meter i{display:block;height:100%;background:var(--accent)}
/* ---------- print ---------- */
#printRoot{display:none}
@media print{
@page{margin:14mm}
body{background:#fff;color:#000;padding:0;font-size:11pt}
#appbar,#tabbar,main,#savedFlash,#storageWarn{display:none !important}
#printRoot{display:block}
.p-h1{font-size:19pt;margin:0 0 2mm}
.p-meta{font-size:10pt;color:#333;margin-bottom:6mm}
.p-day{break-inside:avoid;page-break-inside:avoid;border-top:1.5pt solid #000;padding:3mm 0 4mm}
.p-day h3{font-size:12.5pt;margin:0 0 1mm}
.p-badge{font-size:9pt;font-weight:700;text-transform:uppercase;letter-spacing:.05em;border:1pt solid #000;padding:0.5mm 2mm;border-radius:2mm;margin-left:3mm}
.p-lab{font-size:8.5pt;text-transform:uppercase;letter-spacing:.06em;color:#444;margin:2.5mm 0 .8mm;font-weight:700}
.p-t{width:100%;border-collapse:collapse;font-size:9.5pt}
.p-t th,.p-t td{border:0.5pt solid #999;padding:1.2mm 2mm;text-align:left;vertical-align:top}
.p-t th{background:#eee;font-size:8.5pt;text-transform:uppercase;letter-spacing:.04em}
.p-t td.n,.p-t th.n{text-align:right}
.p-r{width:22mm;height:22mm;object-fit:contain;border:0.5pt solid #999;background:#fff}
.p-sum{break-before:page;page-break-before:always}
.p-appx{break-before:page;page-break-before:always}
.p-rc{break-inside:avoid;page-break-inside:avoid;margin:0 0 6mm;border:0.5pt solid #999;padding:2mm}
.p-rc img{display:block;width:100%;max-height:110mm;object-fit:contain;background:#fff}
.p-rc .cap{font-size:9pt;margin-bottom:1.5mm;border-bottom:0.5pt solid #ccc;padding-bottom:1mm}
.p-warn{border:1.5pt solid #000;padding:2.5mm;font-weight:700;margin:3mm 0}
.p-foot{font-size:8.5pt;color:#444;margin-top:6mm;border-top:0.5pt solid #999;padding-top:2mm}
}
</style>
</head>
<body>
<div id="savedFlash">Saved</div>
<div id="storageWarn" hidden></div>
<header id="appbar">
<div id="barTitle">Business Trip Day Tracker</div>
<div id="barSub">Set up your trip to begin</div>
</header>
<main>
<section id="view-days" class="view"></section>
<section id="view-summary" class="view" hidden></section>
<section id="view-trip" class="view" hidden></section>
</main>
<nav id="tabbar">
<button data-view="days" aria-selected="true"><span class="ic">&#128197;</span>Days</button>
<button data-view="summary" aria-selected="false"><span class="ic">&#931;</span>Summary</button>
<button data-view="trip" aria-selected="false"><span class="ic">&#9881;</span>Trip</button>
</nav>
<div id="printRoot"></div>
<script>
"use strict";
/* =======================================================================
Constants
======================================================================= */
var LS_KEY = "btdt.v1";
var CATEGORIES = ["Airfare","Lodging","Ground","Meals","Gear","Other"];
var USES = ["Ad","Social","Kickstarter"];
var PAYERS = ["William","Devin","Nick","Other"];
var TYPES = ["Business","Travel","Personal"];
var MEAL_RATE = 0.5; // meals are 50% deductible
var PERSONAL_LIMIT = 25; // % — at or above this, airfare may need proration
/* =======================================================================
State
======================================================================= */
var seq = 0;
function uid(){ seq++; return "i" + Date.now().toString(36) + seq.toString(36); }
function blankDay(date){
return { id:uid(), date:date||"", type:"Business", locations:"", purpose:"",
shots:[], hours:"", people:"", expenses:[], published:[], notes:"" };
}
function defaultState(){
return {
version:1,
trip:{ name:"Ecuador Content Filming Trip", destination:"Ecuador",
start:"2026-09-18", end:"2026-09-28", travelers:"William Swann, Devin Willis" },
days:[]
};
}
var state = defaultState();
var expanded = {}; // day id -> true (UI only, not exported)
var storageOK = true;
var view = "days";
/* =======================================================================
Persistence
======================================================================= */
function probeStorage(){
try { localStorage.setItem("__t","1"); localStorage.removeItem("__t"); return true; }
catch(e){ return false; }
}
function load(){
if(!storageOK) return;
var raw = null;
try { raw = localStorage.getItem(LS_KEY); } catch(e){ return; }
if(!raw) return;
try {
var d = JSON.parse(raw);
if(d && d.trip && Array.isArray(d.days)) state = normalise(d);
} catch(e){ /* corrupt — keep defaults, don't wipe the raw copy */ }
try {
var ex = JSON.parse(localStorage.getItem(LS_KEY+".ui")||"[]");
ex.forEach(function(id){ expanded[id]=true; });
} catch(e){}
}
function normalise(d){
var s = defaultState();
s.trip = Object.assign(s.trip, d.trip||{});
s.days = (d.days||[]).map(function(x){
var day = blankDay(x.date);
day.id = x.id || uid();
day.type = TYPES.indexOf(x.type)>=0 ? x.type : "Business";
day.locations = x.locations||"";
day.purpose = x.purpose||"";
day.hours = x.hours==null?"":String(x.hours);
day.people = x.people||"";
day.notes = x.notes||"";
day.shots = (x.shots||[]).map(function(r){ return {subject:r.subject||"",what:r.what||"",use:r.use||"Social"}; });
day.expenses = (x.expenses||[]).map(function(r){
return { vendor:r.vendor||"", category:CATEGORIES.indexOf(r.category)>=0?r.category:"Other",
amount:r.amount==null?"":String(r.amount),
paidBy:PAYERS.indexOf(r.paidBy)>=0?r.paidBy:"William",
paidByOther:r.paidByOther||"", receipt:r.receipt||"", note:r.note||"" };
});
day.published = (x.published||[]).map(function(r){
return { platform:r.platform||"", title:r.title||"", url:r.url||"", date:r.date||"" };
});
return day;
});
return s;
}
var saveTimer=null, flashTimer=null;
function save(){
if(saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(writeNow, 220);
}
function writeNow(){
if(!storageOK) return;
try {
localStorage.setItem(LS_KEY, JSON.stringify(state));
localStorage.setItem(LS_KEY+".ui", JSON.stringify(Object.keys(expanded)));
flashSaved();
} catch(e){
showStorageWarn("Storage is full. Your last change was NOT saved. Export JSON now, then delete some receipt photos — they are the bulk of it.");
}
}
function flashSaved(){
var f = document.getElementById("savedFlash");
f.classList.add("on");
if(flashTimer) clearTimeout(flashTimer);
flashTimer = setTimeout(function(){ f.classList.remove("on"); }, 700);
}
function showStorageWarn(msg){
var w = document.getElementById("storageWarn");
w.textContent = msg; w.hidden = false;
}
/* =======================================================================
Small helpers
======================================================================= */
function esc(s){
return String(s==null?"":s).replace(/&/g,"&amp;").replace(/</g,"&lt;")
.replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;");
}
function num(v){ var n = parseFloat(String(v==null?"":v).replace(/[^0-9.\-]/g,"")); return isFinite(n)?n:0; }
function money(n){
return "$" + (Math.round(n*100)/100).toLocaleString(undefined,{minimumFractionDigits:2,maximumFractionDigits:2});
}
var WD = ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"];
var MO = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
function ymd(s){ // parse YYYY-MM-DD with no timezone drift
var m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(s||""));
return m ? new Date(+m[1], +m[2]-1, +m[3]) : null;
}
function fmtDate(s){
var d = ymd(s);
if(!d) return "No date set";
return WD[d.getDay()] + " " + MO[d.getMonth()] + " " + d.getDate() + ", " + d.getFullYear();
}
function fmtShort(s){
var d = ymd(s);
return d ? (MO[d.getMonth()] + " " + d.getDate()) : "—";
}
function toYMD(d){
var m = d.getMonth()+1, day = d.getDate();
return d.getFullYear() + "-" + (m<10?"0":"") + m + "-" + (day<10?"0":"") + day;
}
function payerName(e){
return (e.paidBy === "Other") ? (e.paidByOther.trim() || "Other") : e.paidBy;
}
function dayById(id){
for(var i=0;i<state.days.length;i++) if(state.days[i].id===id) return state.days[i];
return null;
}
function sortDays(){
state.days.sort(function(a,b){
if(!a.date) return 1; if(!b.date) return -1;
return a.date < b.date ? -1 : a.date > b.date ? 1 : 0;
});
}
function slug(s){ return String(s||"trip").toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"").slice(0,40) || "trip"; }
/* =======================================================================
Calculations
======================================================================= */
function calc(){
var r = {
total: state.days.length,
counts: {Business:0, Travel:0, Personal:0},
personalPct: 0,
byCat: {}, byPayer: {},
grand: 0, onPersonalDays: 0,
hours: 0, shots: 0, published: 0, receipts: 0, expenseRows: 0
};
CATEGORIES.forEach(function(c){ r.byCat[c]=0; });
state.days.forEach(function(d){
r.counts[d.type] = (r.counts[d.type]||0) + 1;
r.hours += num(d.hours);
r.shots += d.shots.filter(function(s){ return s.subject||s.what; }).length;
r.published += d.published.filter(function(p){ return p.title||p.url||p.platform; }).length;
d.expenses.forEach(function(e){
var amt = num(e.amount);
if(!amt && !e.vendor) return;
r.expenseRows++;
if(e.receipt) r.receipts++;
if(!amt) return;
r.byCat[e.category] = (r.byCat[e.category]||0) + amt;
var p = payerName(e);
r.byPayer[p] = (r.byPayer[p]||0) + amt;
r.grand += amt;
if(d.type === "Personal") r.onPersonalDays += amt;
});
});
r.meals = r.byCat.Meals || 0;
r.mealsHalf = r.meals * MEAL_RATE;
r.afterMeals = r.grand - (r.meals - r.mealsHalf);
r.workDays = r.counts.Business + r.counts.Travel;
r.personalPct = r.total ? (r.counts.Personal / r.total * 100) : 0;
r.overLimit = r.total > 0 && r.personalPct >= PERSONAL_LIMIT;
return r;
}
/* =======================================================================
Render — Days
======================================================================= */
function renderDays(){
var host = document.getElementById("view-days");
var out = "";
if(!state.days.length){
out += '<div class="card pad">' +
'<h2 style="font-size:17px;margin-bottom:6px">No days yet</h2>' +
'<p style="color:var(--muted);font-size:14px;margin:0 0 14px">' +
'Set the trip dates on the <strong>Trip</strong> tab and tap ' +
'&ldquo;Create day cards from date range&rdquo;, or add days one at a time.</p>' +
'<button class="btn primary" data-act="addDay">+ Add a day</button></div>';
} else {
state.days.forEach(function(d, i){ out += dayCard(d, i); });
out += '<div class="btnstack" style="margin:16px 0 8px">' +
'<button class="btn" data-act="addDay">+ Add a day</button></div>';
}
host.innerHTML = out;
}
/* Header only — cheap enough to rebuild on every keystroke. */
function cardHeadInner(d, i){
var tot = d.expenses.reduce(function(s,e){ return s + num(e.amount); }, 0);
var bits = [];
if(d.locations) bits.push(d.locations);
if(num(d.hours)) bits.push(num(d.hours) + "h");
var nShots = d.shots.filter(function(s){ return s.subject||s.what; }).length;
if(nShots) bits.push(nShots + " shot" + (nShots>1?"s":""));
if(tot) bits.push(money(tot));
var sub = bits.length ? bits.join(" · ") : "Tap to fill in";
return '<span class="caret">▶</span>' +
'<span class="grow"><span class="ddate">Day ' + (i+1) + ' — ' + esc(fmtDate(d.date)) + '</span>' +
'<span class="dsub" style="display:block">' + esc(sub) + '</span></span>' +
'<span class="chip ' + d.type + '">' + d.type + '</span>';
}
function dayCard(d, i){
var open = !!expanded[d.id];
var h = '<article class="card' + (open?" open":"") + '" data-card="' + d.id + '">';
h += '<button class="cardhead" data-act="toggle" data-day="' + d.id + '">' +
cardHeadInner(d, i) + '</button>';
if(open){
h += '<div class="cardbody">';
h += '<div class="section-title">Day type</div>';
h += '<div class="typewrap">';
TYPES.forEach(function(t){
var id = "t_"+d.id+"_"+t;
h += '<input type="radio" id="'+id+'" name="type_'+d.id+'" value="'+t+'"' +
(d.type===t?" checked":"") + ' data-day="'+d.id+'" data-field="type">' +
'<label for="'+id+'" data-t="'+t+'">'+t+'</label>';
});
h += '</div>';
h += field(d,"date","Date","date");
h += field(d,"locations","Location(s)","text","Quito → Baños");
h += field(d,"purpose","Business purpose / objective","textarea",
"Why this was a work day — e.g. filmed product B-roll at the waterfall for the Kickstarter video");
h += '<div class="row2">' + field(d,"hours","Hours worked on business","number","0")
+ field(d,"people","People present","text","William, Devin, local guide") + '</div>';
/* shot log */
h += '<div class="section-title">Shot log</div>';
if(!d.shots.length) h += '<div class="empty">Nothing logged yet.</div>';
d.shots.forEach(function(s, idx){
h += '<div class="subrow">' + rhead("Shot " + (idx+1), d.id, "shots", idx);
h += rowField(d.id,"shots",idx,"subject","Subject / scene","text","Cotopaxi crater rim");
h += rowField(d.id,"shots",idx,"what","What was filmed","textarea","Pack on camera, 3 takes, drone orbit");
h += rowSelect(d.id,"shots",idx,"use","Intended use",USES,s.use);
h += '</div>';
});
h += '<button class="btn sm" data-act="addRow" data-day="'+d.id+'" data-list="shots">+ Add shot</button>';
/* expenses */
h += '<div class="section-title">Expenses</div>';
if(!d.expenses.length) h += '<div class="empty">No expenses on this day.</div>';
d.expenses.forEach(function(e, idx){
h += '<div class="subrow">' + rhead("Expense " + (idx+1), d.id, "expenses", idx);
h += rowField(d.id,"expenses",idx,"vendor","Vendor","text","Hotel Casa Gangotena");
h += '<div class="row2">';
h += rowSelect(d.id,"expenses",idx,"category","Category",CATEGORIES,e.category);
h += rowField(d.id,"expenses",idx,"amount","Amount (USD)","number","0.00");
h += '</div>';
h += rowSelect(d.id,"expenses",idx,"paidBy","Paid by",PAYERS,e.paidBy);
if(e.paidBy === "Other")
h += rowField(d.id,"expenses",idx,"paidByOther","Who paid","text","Name");
h += rowField(d.id,"expenses",idx,"note","Note","text","Business purpose of this cost");
h += '<div class="lab" style="font-size:13px;font-weight:600;color:var(--muted);margin-bottom:5px">Receipt photo</div>';
h += '<div class="receipt">';
if(e.receipt){
h += '<img src="'+e.receipt+'" alt="Receipt" data-act="viewReceipt" data-day="'+d.id+'" data-idx="'+idx+'">';
h += '<button class="btn sm danger" data-act="delReceipt" data-day="'+d.id+'" data-idx="'+idx+'">Remove</button>';
} else {
h += '<label class="filelabel">\u{1F4F7} Add receipt' +
'<input type="file" accept="image/*" data-act="receipt" data-day="'+d.id+'" data-idx="'+idx+'"></label>';
}
h += '</div></div>';
});
h += '<button class="btn sm" data-act="addRow" data-day="'+d.id+'" data-list="expenses">+ Add expense</button>';
/* published */
h += '<div class="section-title">Published content <span class="sub">(fill in later)</span></div>';
if(!d.published.length) h += '<div class="empty">Nothing published from this day yet.</div>';
d.published.forEach(function(p, idx){
h += '<div class="subrow">' + rhead("Post " + (idx+1), d.id, "published", idx);
h += '<div class="row2">';
h += rowField(d.id,"published",idx,"platform","Platform","text","Instagram");
h += rowField(d.id,"published",idx,"date","Date posted","date");
h += '</div>';
h += rowField(d.id,"published",idx,"title","Title","text","Ecuador Day 3 — pack test");
h += rowField(d.id,"published",idx,"url","URL","url","https://");
h += '</div>';
});
h += '<button class="btn sm" data-act="addRow" data-day="'+d.id+'" data-list="published">+ Add published item</button>';
h += '<div class="section-title">Notes</div>';
h += '<label class="f"><textarea data-day="'+d.id+'" data-field="notes" placeholder="Anything else worth remembering">'+esc(d.notes)+'</textarea></label>';
h += '<button class="btn danger" data-act="delDay" data-day="'+d.id+'" style="margin-top:6px">Delete this day</button>';
h += '</div>';
}
h += '</article>';
return h;
}
function field(d, key, label, type, ph){
var v = esc(d[key]);
var attrs = 'data-day="'+d.id+'" data-field="'+key+'"';
if(type === "textarea")
return '<label class="f"><span class="lab">'+esc(label)+'</span>' +
'<textarea '+attrs+' placeholder="'+esc(ph||"")+'">'+v+'</textarea></label>';
var extra = (type==="number") ? ' inputmode="decimal" step="0.25" min="0"' : "";
return '<label class="f"><span class="lab">'+esc(label)+'</span>' +
'<input type="'+type+'" '+attrs+extra+' value="'+v+'" placeholder="'+esc(ph||"")+'"></label>';
}
function rhead(title, dayId, list, idx){
return '<div class="rhead"><span class="rnum">'+esc(title)+'</span>' +
'<button class="xbtn" data-act="delRow" data-day="'+dayId+'" data-list="'+list+'" data-idx="'+idx+'" aria-label="Delete">✕</button></div>';
}
function rowField(dayId, list, idx, key, label, type, ph){
var d = dayById(dayId), v = esc(d[list][idx][key]);
var attrs = 'data-day="'+dayId+'" data-list="'+list+'" data-idx="'+idx+'" data-field="'+key+'"';
if(type === "textarea")
return '<label class="f"><span class="lab">'+esc(label)+'</span>' +
'<textarea '+attrs+' placeholder="'+esc(ph||"")+'">'+v+'</textarea></label>';
var extra = (type==="number") ? ' inputmode="decimal" step="0.01" min="0"' : "";
return '<label class="f"><span class="lab">'+esc(label)+'</span>' +
'<input type="'+type+'" '+attrs+extra+' value="'+v+'" placeholder="'+esc(ph||"")+'"></label>';
}
function rowSelect(dayId, list, idx, key, label, opts, cur){
var attrs = 'data-day="'+dayId+'" data-list="'+list+'" data-idx="'+idx+'" data-field="'+key+'"';
var h = '<label class="f"><span class="lab">'+esc(label)+'</span><select '+attrs+'>';
opts.forEach(function(o){ h += '<option value="'+esc(o)+'"'+(o===cur?" selected":"")+'>'+esc(o)+'</option>'; });
return h + '</select></label>';
}
/* =======================================================================
Render — Summary
======================================================================= */
function renderSummary(){
var r = calc();
var host = document.getElementById("view-summary");
var h = "";
h += '<div class="stats">';
TYPES.forEach(function(t){
h += '<div class="stat '+t+'"><div class="n">'+r.counts[t]+'</div><div class="l">'+t+'</div></div>';
});
h += '</div>';
if(r.total){
if(r.overLimit){
h += '<div class="warn">⚠︎ Personal days are ' + r.personalPct.toFixed(0) + '% of the trip (' +
r.counts.Personal + ' of ' + r.total + ').<br>Airfare may need proration — keep personal under ' +
PERSONAL_LIMIT + '%.</div>';
} else {
h += '<div class="ok">Personal days: ' + r.personalPct.toFixed(0) + '% of the trip — under the ' +
PERSONAL_LIMIT + '% threshold. ' + r.workDays + ' of ' + r.total + ' days are Business or Travel.</div>';
}
}
h += '<div class="card pad"><div class="section-title" style="margin-top:0">Expenses by category</div><table class="t">';
h += '<tr><th>Category</th><th class="n">Amount</th></tr>';
CATEGORIES.forEach(function(c){
var v = r.byCat[c] || 0;
if(!v && c !== "Meals") return;
h += '<tr><td>'+c+'</td><td class="n">'+money(v)+'</td></tr>';
if(c === "Meals"){
h += '<tr><td style="padding-left:14px" class="sub">Meals at 50% deductible</td>' +
'<td class="n sub">'+money(r.mealsHalf)+'</td></tr>';
}
});
h += '<tr class="tot"><td>Total spent</td><td class="n">'+money(r.grand)+'</td></tr>';
h += '<tr class="tot"><td>Total with meals at 50%<div class="sub">Informational — before any airfare proration</div></td>' +
'<td class="n">'+money(r.afterMeals)+'</td></tr>';
h += '</table>';
if(r.onPersonalDays > 0){
h += '<p class="note">' + money(r.onPersonalDays) + ' of that was recorded on days marked <strong>Personal</strong>. ' +
'Those generally are not deductible — flag them for your bookkeeper.</p>';
}
h += '</div>';
var payers = Object.keys(r.byPayer).sort();
h += '<div class="card pad"><div class="section-title" style="margin-top:0">Totals per payer</div>';
if(!payers.length){
h += '<div class="empty">No expenses recorded yet.</div>';
} else {
h += '<table class="t"><tr><th>Paid by</th><th class="n">Amount</th></tr>';
payers.forEach(function(p){ h += '<tr><td>'+esc(p)+'</td><td class="n">'+money(r.byPayer[p])+'</td></tr>'; });
h += '<tr class="tot"><td>Total</td><td class="n">'+money(r.grand)+'</td></tr></table>';
}
h += '</div>';
h += '<div class="card pad"><div class="section-title" style="margin-top:0">Documentation</div><table class="t">';
h += '<tr><td>Business hours logged</td><td class="n">'+(Math.round(r.hours*100)/100)+'</td></tr>';
h += '<tr><td>Shots logged</td><td class="n">'+r.shots+'</td></tr>';
h += '<tr><td>Published items</td><td class="n">'+r.published+'</td></tr>';
h += '<tr><td>Expense lines</td><td class="n">'+r.expenseRows+'</td></tr>';
h += '<tr><td>Receipt photos attached</td><td class="n">'+r.receipts+' of '+r.expenseRows+'</td></tr>';
h += '</table></div>';
h += '<div class="btnstack" style="margin-top:16px">' +
'<button class="btn primary" data-act="print">\u{1F5A8} Print / Save PDF report</button>' +
'<button class="btn" data-act="csv">↓ Export CSV (expenses + days)</button>' +
'<button class="btn" data-act="json">↓ Export JSON backup</button></div>';
h += '<p class="note">The PDF is the hand-off document. The CSV is for the bookkeeper’s spreadsheet. ' +
'The JSON is the only thing that can restore this app if the phone is wiped — export it after each day.</p>';
host.innerHTML = h;
}
/* =======================================================================
Render — Trip
======================================================================= */
function renderTrip(){
var t = state.trip;
var host = document.getElementById("view-trip");
var h = '<div class="card pad">';
h += '<div class="section-title" style="margin-top:0">Trip details</div>';
h += tf("name","Trip name","text","Ecuador Content Filming Trip");
h += tf("destination","Destination","text","Quito & Baños, Ecuador");
h += '<div class="row2">' + tf("start","Start date","date") + tf("end","End date","date") + '</div>';
h += tf("travelers","Travelers","textarea","William Swann, Devin Willis");
h += '<button class="btn primary" data-act="genDays">Create day cards from date range</button>';
h += '<p class="note">Adds one card per day between the start and end dates. Days you already have are left alone.</p>';
h += '</div>';
h += '<div class="card pad"><div class="section-title" style="margin-top:0">Backup &amp; export</div><div class="btnstack">' +
'<button class="btn primary" data-act="json">↓ Export JSON backup</button>' +
'<button class="btn" data-act="csv">↓ Export CSV</button>' +
'<button class="btn" data-act="print">\u{1F5A8} Print / Save PDF report</button>' +
'<label class="btn ghost" style="cursor:pointer">↑ Import JSON backup' +
'<input type="file" accept="application/json,.json" data-act="import" style="display:none"></label>' +
'</div>';
h += '<p class="note" id="usageNote"></p><div class="meter"><i id="usageBar" style="width:0%"></i></div>';
h += '</div>';
h += '<div class="card pad"><div class="section-title" style="margin-top:0">Danger zone</div>' +
'<button class="btn danger" data-act="wipe">Erase all data on this device</button></div>';
h += '<p class="note">All data stays on this device in this browser’s local storage. Nothing is sent anywhere, ' +
'and there is no account. Clearing the browser’s site data deletes it — so keep a JSON export.</p>';
host.innerHTML = h;
updateUsage();
}
function tf(key, label, type, ph){
var v = esc(state.trip[key]);
var attrs = 'data-trip="'+key+'"';
if(type === "textarea")
return '<label class="f"><span class="lab">'+esc(label)+'</span>' +
'<textarea '+attrs+' placeholder="'+esc(ph||"")+'" style="min-height:60px">'+v+'</textarea></label>';
return '<label class="f"><span class="lab">'+esc(label)+'</span>' +
'<input type="'+type+'" '+attrs+' value="'+v+'" placeholder="'+esc(ph||"")+'"></label>';
}
function updateUsage(){
var note = document.getElementById("usageNote");
var bar = document.getElementById("usageBar");
if(!note) return;
var bytes = 0;
try { bytes = (localStorage.getItem(LS_KEY)||"").length; } catch(e){}
var kb = bytes/1024, pct = Math.min(100, kb/5120*100); // ~5 MB is the usual cap
note.textContent = "Stored on this device: " + (kb<1024 ? kb.toFixed(0)+" KB" : (kb/1024).toFixed(1)+" MB") +
" of roughly 5 MB. Receipt photos are almost all of it.";
bar.style.width = pct.toFixed(1) + "%";
bar.style.background = pct > 80 ? "var(--danger)" : "var(--accent)";
}
/* =======================================================================
Render — shell
======================================================================= */
function renderBar(){
var t = state.trip;
document.getElementById("barTitle").textContent = t.name || "Business Trip Day Tracker";
var r = calc();
var range = (t.start||t.end) ? (fmtShort(t.start) + " – " + fmtShort(t.end)) : "No dates set";
var bits = [t.destination || "No destination", range];
if(r.total) bits.push(r.total + " day" + (r.total>1?"s":"") + " · " + money(r.grand));
document.getElementById("barSub").textContent = bits.join(" · ");
}
function render(){
renderBar();
if(view === "days") renderDays();
if(view === "summary") renderSummary();
if(view === "trip") renderTrip();
}
function setView(v){
view = v;
["days","summary","trip"].forEach(function(x){
document.getElementById("view-"+x).hidden = (x !== v);
});
Array.prototype.forEach.call(document.querySelectorAll("#tabbar button"), function(b){
b.setAttribute("aria-selected", String(b.dataset.view === v));
});
render();
window.scrollTo(0,0);
}
/* =======================================================================
Input binding (delegated — survives re-render, never steals focus)
======================================================================= */
function applyInput(el){
var t = el.dataset;
if(t.trip){
state.trip[t.trip] = el.value;
renderBar(); save(); return;
}
if(!t.day || !t.field) return;
var d = dayById(t.day);
if(!d) return;
if(t.list){
var row = d[t.list][+t.idx];
if(!row) return;
row[t.field] = el.value;
// "Paid by: Other" reveals a name field — that is a structural change
if(t.field === "paidBy"){ save(); renderDays(); renderBar(); return; }
} else {
d[t.field] = el.value;
if(t.field === "date"){ save(); sortDays(); renderDays(); renderBar(); return; }
if(t.field === "type"){ save(); refreshCardHead(d); renderBar(); return; }
}
save();
refreshCardHead(d);
renderBar();
}
/* Update just the collapsed header line, so typing never re-renders the form. */
function refreshCardHead(d){
var card = document.querySelector('[data-card="'+d.id+'"]');
if(!card) return;
var head = card.querySelector(".cardhead");
if(head) head.innerHTML = cardHeadInner(d, state.days.indexOf(d));
}
/* A date picker, a select and a radio all fire `input` while the user is still
choosing. Re-rendering there tears the control out from under them, so those
three are handled on `change` only. */
function livePartial(el){
return el.tagName === "SELECT" || el.type === "radio" || el.type === "date";
}
document.addEventListener("input", function(e){
var el = e.target;
if(el.tagName === "TEXTAREA"){ el.style.height = "auto"; el.style.height = (el.scrollHeight+2) + "px"; }
if(livePartial(el)) return;
applyInput(el);
});
document.addEventListener("change", function(e){
var el = e.target;
if(el.type === "file") return; // handled below
if(livePartial(el)) applyInput(el);
});
/* =======================================================================
Actions
======================================================================= */
document.addEventListener("click", function(e){
var b = e.target.closest("[data-act]");
if(!b) return;
var a = b.dataset.act;
if(a === "toggle"){
var id = b.dataset.day;
if(expanded[id]) delete expanded[id]; else expanded[id] = true;
writeNow(); renderDays();
var card = document.querySelector('[data-card="'+id+'"]');
if(card && expanded[id]){
var top = card.getBoundingClientRect().top + window.pageYOffset - 84;
window.scrollTo({top:top, behavior:"smooth"});
Array.prototype.forEach.call(card.querySelectorAll("textarea"), function(ta){
ta.style.height="auto"; ta.style.height=(ta.scrollHeight+2)+"px";
});
}
return;
}
if(a === "addDay"){
var last = state.days[state.days.length-1];
var next = "";
if(last && last.date){ var dt = ymd(last.date); dt.setDate(dt.getDate()+1); next = toYMD(dt); }
else if(state.trip.start) next = state.trip.start;
var day = blankDay(next);
state.days.push(day); expanded[day.id] = true;
sortDays(); writeNow(); render();
return;
}
if(a === "delDay"){
var d = dayById(b.dataset.day);
if(!d) return;
if(!confirm("Delete " + fmtDate(d.date) + " and everything on it?")) return;
state.days = state.days.filter(function(x){ return x.id !== d.id; });
delete expanded[d.id];
writeNow(); render();
return;
}
if(a === "addRow"){
var day2 = dayById(b.dataset.day), list = b.dataset.list;
if(!day2) return;
if(list === "shots") day2.shots.push({subject:"",what:"",use:"Social"});
if(list === "expenses") day2.expenses.push({vendor:"",category:"Meals",amount:"",paidBy:"William",paidByOther:"",receipt:"",note:""});
if(list === "published") day2.published.push({platform:"",title:"",url:"",date:""});
writeNow(); renderDays();
return;
}
if(a === "delRow"){
var day3 = dayById(b.dataset.day);
if(!day3) return;
var row = day3[b.dataset.list][+b.dataset.idx];
var hasContent = row && Object.keys(row).some(function(k){
return k !== "use" && k !== "category" && k !== "paidBy" && String(row[k]||"").trim() !== "";
});
if(hasContent && !confirm("Delete this entry? What you typed in it will be lost.")) return;
day3[b.dataset.list].splice(+b.dataset.idx, 1);
writeNow(); renderDays(); renderBar();
return;
}
if(a === "delReceipt"){
var day4 = dayById(b.dataset.day);
if(!day4) return;
day4.expenses[+b.dataset.idx].receipt = "";
writeNow(); renderDays();
return;
}
if(a === "viewReceipt"){
var w = window.open("");
if(w) w.document.write('<title>Receipt</title><body style="margin:0;background:#111"><img src="' +
e.target.getAttribute("src") + '" style="width:100%">');
return;
}
if(a === "genDays"){ genDays(); return; }
if(a === "json"){ exportJSON(); return; }
if(a === "csv"){ exportCSV(); return; }
if(a === "print"){ buildPrint(); setTimeout(function(){ window.print(); }, 60); return; }
if(a === "wipe"){
if(!confirm("Erase the whole trip from this device? Export a JSON backup first if you have not.")) return;
if(!confirm("Really erase? This cannot be undone.")) return;
try { localStorage.removeItem(LS_KEY); localStorage.removeItem(LS_KEY+".ui"); } catch(err){}
state = defaultState(); expanded = {};
writeNow(); setView("trip");
return;
}
});
document.getElementById("tabbar").addEventListener("click", function(e){
var b = e.target.closest("button[data-view]");
if(b) setView(b.dataset.view);
});
/* ---------- generate days from range ---------- */
function genDays(silent){
var s = ymd(state.trip.start), en = ymd(state.trip.end);
if(!s || !en){ if(!silent) alert("Set both a start date and an end date first."); return 0; }
if(en < s){ if(!silent) alert("The end date is before the start date."); return 0; }
var have = {}; state.days.forEach(function(d){ if(d.date) have[d.date]=true; });
var added = 0, cur = new Date(s.getTime());
while(cur <= en && added < 400){
var key = toYMD(cur);
if(!have[key]){ state.days.push(blankDay(key)); added++; }
cur.setDate(cur.getDate()+1);
}
sortDays(); writeNow();
if(!silent){
alert(added ? ("Added " + added + " day card" + (added>1?"s":"") + ".") : "Every day in that range already exists.");
setView("days");
}
return added;
}
/* ---------- receipt capture: downscale, or localStorage fills up fast ---------- */
document.addEventListener("change", function(e){
var el = e.target;
if(el.type !== "file") return;
if(el.dataset.act === "receipt"){
var f = el.files && el.files[0];
if(!f) return;
var day = dayById(el.dataset.day), idx = +el.dataset.idx;
if(!day) return;
shrink(f, function(dataURL){
if(!dataURL){ alert("Could not read that image."); return; }
day.expenses[idx].receipt = dataURL;
writeNow(); renderDays(); updateUsage();
});
return;
}
if(el.dataset.act === "import"){
var file = el.files && el.files[0];
if(!file) return;
var fr = new FileReader();
fr.onload = function(){
try {
var d = JSON.parse(fr.result);
if(!d || !d.trip || !Array.isArray(d.days)) throw new Error("shape");
if(!confirm("Replace everything currently on this device with the backup? " +
"It has " + d.days.length + " day(s).")) return;
state = normalise(d); expanded = {};
writeNow(); setView("days");
alert("Backup restored.");
} catch(err){ alert("That file is not a Trip Day Tracker backup."); }
};
fr.readAsText(file);
el.value = "";
}
});
function shrink(file, cb){
var MAX = 1400, Q = 0.7;
var fr = new FileReader();
fr.onerror = function(){ cb(null); };
fr.onload = function(){
var img = new Image();
img.onerror = function(){ cb(null); };
img.onload = function(){
var w = img.width, h = img.height;
var scale = Math.min(1, MAX / Math.max(w, h));
var cw = Math.max(1, Math.round(w*scale)), ch = Math.max(1, Math.round(h*scale));
var c = document.createElement("canvas");
c.width = cw; c.height = ch;
var ctx = c.getContext("2d");
ctx.fillStyle = "#fff"; ctx.fillRect(0,0,cw,ch);
ctx.drawImage(img, 0, 0, cw, ch);
var out;
try { out = c.toDataURL("image/jpeg", Q); } catch(err){ out = null; }
cb(out || fr.result);
};
img.src = fr.result;
};
fr.readAsDataURL(file);
}
/* =======================================================================
Exports
======================================================================= */
function download(name, mime, text){
var blob = new Blob([text], {type:mime});
var url = URL.createObjectURL(blob);
var a = document.createElement("a");
a.href = url; a.download = name;
document.body.appendChild(a); a.click();
setTimeout(function(){ document.body.removeChild(a); URL.revokeObjectURL(url); }, 1500);
}
function stamp(){ var d = new Date(); return toYMD(d); }
function exportJSON(){
download(slug(state.trip.name) + "-backup-" + stamp() + ".json",
"application/json", JSON.stringify(state, null, 2));
}
function q(v){
var s = String(v==null?"":v);
return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g,'""') + '"' : s;
}
function exportCSV(){
var t = state.trip, r = calc();
/* sheet 1 — one row per expense */
var rows = [["Trip","Destination","Date","Day #","Day type","Location(s)","Business purpose",
"Vendor","Category","Amount USD","Paid by","Receipt attached","Note"]];
state.days.forEach(function(d, i){
d.expenses.forEach(function(e){
rows.push([t.name, t.destination, d.date, i+1, d.type, d.locations, d.purpose,
e.vendor, e.category, (num(e.amount)).toFixed(2), payerName(e),
e.receipt ? "Yes" : "No", e.note]);
});
});
rows.push([]);
rows.push(["TOTALS BY CATEGORY"]);
CATEGORIES.forEach(function(c){ rows.push([c, "", "", "", "", "", "", "", "", (r.byCat[c]||0).toFixed(2)]); });
rows.push(["Meals at 50%","","","","","","","","", r.mealsHalf.toFixed(2)]);
rows.push(["GRAND TOTAL","","","","","","","","", r.grand.toFixed(2)]);
rows.push(["TOTAL with meals at 50%","","","","","","","","", r.afterMeals.toFixed(2)]);
rows.push([]);
rows.push(["TOTALS BY PAYER"]);
Object.keys(r.byPayer).sort().forEach(function(p){ rows.push([p,"","","","","","","","", r.byPayer[p].toFixed(2)]); });
/* sheet 2 — one row per day */
var rows2 = [["Trip","Destination","Travelers","Date","Day #","Day type","Location(s)","Business purpose",
"Hours","People present","Shots logged","Shot details","Expenses on day USD",
"Published items","Notes"]];
state.days.forEach(function(d, i){
var shots = d.shots.filter(function(s){ return s.subject||s.what; })
.map(function(s){ return s.subject + ": " + s.what + " [" + s.use + "]"; }).join(" | ");
var pubs = d.published.filter(function(p){ return p.title||p.url||p.platform; })
.map(function(p){ return p.platform + " – " + p.title + " " + p.url + " (" + p.date + ")"; }).join(" | ");
var tot = d.expenses.reduce(function(s,e){ return s + num(e.amount); }, 0);
rows2.push([t.name, t.destination, t.travelers, d.date, i+1, d.type, d.locations, d.purpose,
num(d.hours), d.people, d.shots.filter(function(s){return s.subject||s.what;}).length,
shots, tot.toFixed(2), pubs, d.notes]);
});
rows2.push([]);
rows2.push(["Business days", r.counts.Business]);
rows2.push(["Travel days", r.counts.Travel]);
rows2.push(["Personal days", r.counts.Personal]);
rows2.push(["Total days", r.total]);
rows2.push(["Personal % of trip", r.personalPct.toFixed(1) + "%"]);
rows2.push(["Personal under " + PERSONAL_LIMIT + "%?", r.overLimit ? "NO - airfare may need proration" : "Yes"]);
var csv1 = rows.map(function(r0){ return r0.map(q).join(","); }).join("\r\n");
var csv2 = rows2.map(function(r0){ return r0.map(q).join(","); }).join("\r\n");
var base = slug(state.trip.name);
download(base + "-expenses-" + stamp() + ".csv", "text/csv", "" + csv1);
setTimeout(function(){ download(base + "-days-" + stamp() + ".csv", "text/csv", "" + csv2); }, 700);
}
/* =======================================================================
Print / PDF report
======================================================================= */
function buildPrint(){
var t = state.trip, r = calc();
var h = "";
h += '<h1 class="p-h1">' + esc(t.name || "Business Trip") + '</h1>';
h += '<div class="p-meta">' +
'<strong>Destination:</strong> ' + esc(t.destination || "—") + '<br>' +
'<strong>Dates:</strong> ' + esc(fmtDate(t.start)) + ' to ' + esc(fmtDate(t.end)) +
' (' + r.total + ' day' + (r.total===1?"":"s") + ')<br>' +
'<strong>Travelers:</strong> ' + esc(t.travelers || "—") + '<br>' +
'<strong>Prepared:</strong> ' + esc(fmtDate(stamp())) + '</div>';
h += '<div class="p-lab">Trip summary</div>';
h += '<table class="p-t"><tr><th>Business days</th><th>Travel days</th><th>Personal days</th><th>Personal %</th></tr>' +
'<tr><td>'+r.counts.Business+'</td><td>'+r.counts.Travel+'</td><td>'+r.counts.Personal+'</td>' +
'<td>'+r.personalPct.toFixed(0)+'%</td></tr></table>';
if(r.overLimit){
h += '<div class="p-warn">Personal days are ' + r.personalPct.toFixed(0) + '% of this trip — at or above the ' +
PERSONAL_LIMIT + '% threshold. Airfare may need proration.</div>';
}
state.days.forEach(function(d, i){
h += '<div class="p-day">';
h += '<h3>Day ' + (i+1) + ' — ' + esc(fmtDate(d.date)) +
'<span class="p-badge">' + d.type + '</span></h3>';
h += '<div style="font-size:10pt"><strong>Location(s):</strong> ' + esc(d.locations || "—") +
' &nbsp;&nbsp; <strong>Hours on business:</strong> ' + (num(d.hours) || 0) +
' &nbsp;&nbsp; <strong>Present:</strong> ' + esc(d.people || "—") + '</div>';
h += '<div class="p-lab">Business purpose</div><div>' + esc(d.purpose || "—") + '</div>';
var shots = d.shots.filter(function(s){ return s.subject||s.what; });
if(shots.length){
h += '<div class="p-lab">Shot log</div><table class="p-t">' +
'<tr><th style="width:28%">Subject / scene</th><th>What was filmed</th><th style="width:20%">Intended use</th></tr>';
shots.forEach(function(s){
h += '<tr><td>'+esc(s.subject)+'</td><td>'+esc(s.what)+'</td><td>'+esc(s.use)+'</td></tr>';
});
h += '</table>';
}
var exps = d.expenses.filter(function(e){ return e.vendor || num(e.amount); });
if(exps.length){
var dayTot = exps.reduce(function(s,e){ return s + num(e.amount); }, 0);
h += '<div class="p-lab">Expenses</div><table class="p-t">' +
'<tr><th style="width:24%">Vendor</th><th style="width:14%">Category</th><th class="n" style="width:14%">Amount</th>' +
'<th style="width:14%">Paid by</th><th>Note</th><th style="width:26mm">Receipt</th></tr>';
exps.forEach(function(e){
h += '<tr><td>'+esc(e.vendor)+'</td><td>'+esc(e.category)+'</td><td class="n">'+money(num(e.amount))+'</td>' +
'<td>'+esc(payerName(e))+'</td><td>'+esc(e.note)+'</td><td>' +
(e.receipt ? '<img class="p-r" src="'+e.receipt+'">' : '<span style="font-size:8.5pt;color:#666">none</span>') +
'</td></tr>';
});
h += '<tr><td colspan="2"><strong>Day total</strong></td><td class="n"><strong>'+money(dayTot)+
'</strong></td><td colspan="3"></td></tr></table>';
}
var pubs = d.published.filter(function(p){ return p.title||p.url||p.platform; });
if(pubs.length){
h += '<div class="p-lab">Published content</div><table class="p-t">' +
'<tr><th style="width:18%">Platform</th><th style="width:32%">Title</th><th>URL</th><th style="width:18%">Posted</th></tr>';
pubs.forEach(function(p){
h += '<tr><td>'+esc(p.platform)+'</td><td>'+esc(p.title)+'</td><td style="word-break:break-all">' +
esc(p.url)+'</td><td>'+esc(p.date?fmtShort(p.date):"")+'</td></tr>';
});
h += '</table>';
}
if(d.notes) h += '<div class="p-lab">Notes</div><div>' + esc(d.notes) + '</div>';
h += '</div>';
});
/* summary page */
h += '<div class="p-sum"><h2 class="p-h1" style="font-size:15pt">Trip totals</h2>';
h += '<div class="p-lab">Day count</div><table class="p-t">' +
'<tr><td>Business days</td><td class="n">'+r.counts.Business+'</td></tr>' +
'<tr><td>Travel days</td><td class="n">'+r.counts.Travel+'</td></tr>' +
'<tr><td>Personal days</td><td class="n">'+r.counts.Personal+'</td></tr>' +
'<tr><td><strong>Total days</strong></td><td class="n"><strong>'+r.total+'</strong></td></tr>' +
'<tr><td>Personal share of trip</td><td class="n">'+r.personalPct.toFixed(1)+'%</td></tr></table>';
h += '<div class="p-lab">Expenses by category</div><table class="p-t"><tr><th>Category</th><th class="n">Amount</th></tr>';
CATEGORIES.forEach(function(c){
h += '<tr><td>'+c+'</td><td class="n">'+money(r.byCat[c]||0)+'</td></tr>';
if(c === "Meals") h += '<tr><td style="padding-left:6mm">Meals at 50% deductible</td><td class="n">'+money(r.mealsHalf)+'</td></tr>';
});
h += '<tr><td><strong>Total spent</strong></td><td class="n"><strong>'+money(r.grand)+'</strong></td></tr>';
h += '<tr><td><strong>Total with meals at 50%</strong></td><td class="n"><strong>'+money(r.afterMeals)+'</strong></td></tr>';
h += '</table>';
if(r.onPersonalDays > 0){
h += '<div style="font-size:9.5pt;margin-top:2mm">' + money(r.onPersonalDays) +
' of the above was recorded on days marked Personal.</div>';
}
h += '<div class="p-lab">Totals per payer</div><table class="p-t"><tr><th>Paid by</th><th class="n">Amount</th></tr>';
Object.keys(r.byPayer).sort().forEach(function(p){
h += '<tr><td>'+esc(p)+'</td><td class="n">'+money(r.byPayer[p])+'</td></tr>';
});
h += '<tr><td><strong>Total</strong></td><td class="n"><strong>'+money(r.grand)+'</strong></td></tr></table>';
h += '<div class="p-lab">Documentation on file</div><table class="p-t">' +
'<tr><td>Business hours logged</td><td class="n">'+(Math.round(r.hours*100)/100)+'</td></tr>' +
'<tr><td>Shots logged</td><td class="n">'+r.shots+'</td></tr>' +
'<tr><td>Published items recorded</td><td class="n">'+r.published+'</td></tr>' +
'<tr><td>Expense lines</td><td class="n">'+r.expenseRows+'</td></tr>' +
'<tr><td>Receipt photos attached</td><td class="n">'+r.receipts+' of '+r.expenseRows+'</td></tr></table>';
h += '<div class="p-foot">Contemporaneous record kept during the trip on a mobile device. ' +
'Meals shown at both 100% and 50%; the 50% figure reflects the standard business-meal limitation. ' +
'This is a record for a tax preparer, not tax advice.</div></div>';
/* receipts appendix — the thumbnails in the day tables are pointers, not evidence */
var shots2 = [];
state.days.forEach(function(d, i){
d.expenses.forEach(function(e){
if(e.receipt) shots2.push({ day:i+1, date:d.date, e:e });
});
});
if(shots2.length){
h += '<div class="p-appx"><h2 class="p-h1" style="font-size:15pt">Receipts</h2>' +
'<div class="p-meta">' + shots2.length + ' receipt' + (shots2.length===1?"":"s") +
' attached, in trip order.</div>';
shots2.forEach(function(x){
h += '<div class="p-rc"><div class="cap"><strong>Day ' + x.day + ' — ' + esc(fmtDate(x.date)) + '</strong> · ' +
esc(x.e.vendor || "(no vendor)") + ' · ' + esc(x.e.category) + ' · ' + money(num(x.e.amount)) +
' · paid by ' + esc(payerName(x.e)) + (x.e.note ? ' · ' + esc(x.e.note) : '') + '</div>' +
'<img src="' + x.e.receipt + '"></div>';
});
h += '</div>';
}
document.getElementById("printRoot").innerHTML = h;
}
/* =======================================================================
Boot
======================================================================= */
storageOK = probeStorage();
if(!storageOK){
showStorageWarn("This browser is blocking local storage for this page, so nothing will be saved when you close the tab. " +
"Open the app from a web address (https://…) rather than as a downloaded file, or export JSON before you close it.");
}
load();
/* First launch with dates already set: build the day cards so the app is usable
immediately, offline, without hunting for a button. */
if(!state.days.length && state.trip.start && state.trip.end) genDays(true);
sortDays();
setView(state.days.length ? "days" : "trip");
window.addEventListener("pagehide", writeNow);
document.addEventListener("visibilitychange", function(){ if(document.hidden) writeNow(); });
/* Offline: register the service worker if one is being served alongside this file.
Opened as a bare file:// document there is no SW and no manifest — the app still
works, it just has to be reloaded from wherever it came from. */
if("serviceWorker" in navigator && location.protocol.indexOf("http") === 0){
window.addEventListener("load", function(){
navigator.serviceWorker.register("sw.js").catch(function(){ /* fine without it */ });
});
}
</script>
</body>
</html>