1247 lines
57 KiB
HTML
1247 lines
57 KiB
HTML
<!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">📅</span>Days</button>
|
||
<button data-view="summary" aria-selected="false"><span class="ic">Σ</span>Summary</button>
|
||
<button data-view="trip" aria-selected="false"><span class="ic">⚙</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,"&").replace(/</g,"<")
|
||
.replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'");
|
||
}
|
||
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 ' +
|
||
'“Create day cards from date range”, 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 & 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 || "—") +
|
||
' <strong>Hours on business:</strong> ' + (num(d.hours) || 0) +
|
||
' <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>
|