app
Adom Gantt
Public Made by Adomby adom
Interactive Gantt chart viewer for project timelines, with a table view over the same data. Data-driven, dark by default with a print-friendly light mode, and fully editable in the browser: drag bars to move or resize, multi-select and batch-shift dates, manage dependency arrows inline, reorder rows, filter by phase, and undo. Toggle to the table for every task and milestone as a row with owner, deadline and status, filtered per owner and edited in place. Every edit is recorded in an append-only
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572357335743575357635773578357935803581358235833584358535863587358835893590359135923593359435953596359735983599360036013602360336043605360636073608360936103611361236133614361536163617361836193620362136223623362436253626362736283629363036313632363336343635363636373638363936403641364236433644364536463647364836493650365136523653365436553656365736583659366036613662366336643665366636673668366936703671367236733674367536763677367836793680368136823683368436853686368736883689369036913692369336943695369636973698369937003701370237033704370537063707370837093710371137123713371437153716371737183719372037213722372337243725372637273728372937303731373237333734373537363737373837393740374137423743374437453746374737483749375037513752375337543755375637573758375937603761376237633764376537663767376837693770377137723773377437753776377737783779378037813782378337843785378637873788378937903791379237933794379537963797379837993800380138023803380438053806380738083809381038113812381338143815381638173818381938203821382238233824382538263827382838293830383138323833383438353836383738383839384038413842384338443845384638473848384938503851385238533854385538563857385838593860386138623863386438653866386738683869387038713872387338743875387638773878387938803881388238833884388538863887388838893890389138923893389438953896389738983899390039013902390339043905390639073908390939103911391239133914391539163917391839193920392139223923392439253926392739283929393039313932393339343935393639373938393939403941394239433944394539463947394839493950395139523953395439553956395739583959396039613962396339643965396639673968396939703971397239733974397539763977397839793980398139823983398439853986398739883989399039913992399339943995399639973998399940004001400240034004400540064007400840094010401140124013401440154016401740184019402040214022402340244025402640274028402940304031403240334034403540364037403840394040404140424043404440454046404740484049405040514052405340544055405640574058405940604061406240634064406540664067406840694070407140724073407440754076407740784079408040814082408340844085408640874088408940904091409240934094409540964097409840994100410141024103410441054106410741084109
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>adom-gantt</title>
<link rel="icon" href="favicon.svg">
<!-- Brand faces: Familjen Grotesk for headings, Satoshi for body, JetBrains Mono
for data. These were @font-face rules pointing at https://adom.inc/fonts/,
which serves the .woff2 files with no Access-Control-Allow-Origin header.
A font fetched by @font-face is always a CORS request, so every one of them
was blocked and the app has been rendering in the fallback system stack the
whole time. Both hosts below send ACAO on the font files themselves. The
fallback stacks in the rules further down are unchanged and still carry the
page if these hosts are unreachable. -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="preconnect" href="https://cdn.fontshare.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Familjen+Grotesk:wght@500;600;700&family=JetBrains+Mono:wght@400&display=swap">
<style>
/* Satoshi is not on Google Fonts. Fontshare's CSS endpoint at api.fontshare.com
loads but hands the browser no @font-face rules, so it is skipped and the two
faces we use are declared here against cdn.fontshare.com, which does send
Access-Control-Allow-Origin: *. The URLs are content-addressed and immutable;
if the CDN ever goes away the fallback stack below carries the page, which is
the same thing that happens today. */
@font-face { font-family: 'Satoshi'; font-weight: 400; font-style: normal; font-display: swap;
src: url('https://cdn.fontshare.com/wf/TTX2Z3BF3P6Y5BQT3IV2VNOK6FL22KUT/7QYRJOI3JIMYHGY6CH7SOIFRQLZOLNJ6/KFIAZD4RUMEZIYV6FQ3T3GP5PDBDB6JY.woff2') format('woff2'); }
@font-face { font-family: 'Satoshi'; font-weight: 500; font-style: normal; font-display: swap;
src: url('https://cdn.fontshare.com/wf/P2LQKHE6KA6ZP4AAGN72KDWMHH6ZH3TA/ZC32TK2P7FPS5GFTL46EU6KQJA24ZYDB/7AHDUZ4A7LFLVFUIFSARGIWCRQJHISQP.woff2') format('woff2'); }
/* Adom is a dark system: dark is the base, light is the opt-in override.
Tokens and contrast ratios come from the adom-ui-design skill.
--text3 is 2.3:1 and is NEVER text; it is for borders and disabled chrome. */
:root {
--bg: #0d1117; --surface: #161b22; --elevated: #1c2128; --overlay: #21262d;
--border: #30363d; --border-muted: #21262d;
--text: #e6edf3; --text-body: #c9d1d9; --text2: #8b949e; --text3: #484f58;
--accent: #00b8b1; --accent-hover: #00d4cb; --accent-bright: #00e6dc; --today: #00b8b1;
--ok: #3fb950; --warn: #d29922;
--on-accent: #05221f;
--grid-line: rgba(48,54,61,0.18);
--weekend: rgba(255,255,255,0.022);
--row-hover: rgba(255,255,255,0.04);
/* Phase ramp: teal -> purple -> blue, then a lighter step of each.
Used when a phase declares no explicit colour. */
--ph1: #00b8b1; --ph2: #8c6bf7; --ph3: #64abff;
--bar-opacity: 0.82;
color-scheme: dark;
}
:root[data-theme="light"] {
--bg: #ffffff; --surface: #f6f8fa; --elevated: #ffffff; --overlay: #ffffff;
--border: #d0d7de; --border-muted: #d8dee4;
--text: #1f2328; --text-body: #24292f; --text2: #59636e; --text3: #8c959f;
--accent: #00857f; --accent-hover: #00a49d; --accent-bright: #00a49d; --today: #00857f;
--ok: #1a7f37; --warn: #9a6700;
--on-accent: #ffffff;
--grid-line: rgba(31,35,40,0.10);
--weekend: rgba(31,35,40,0.04);
--row-hover: rgba(31,35,40,0.04);
/* Deepened for paper: each clears 3:1 on white while holding its hue. */
--ph1: #00a49d; --ph2: #8c6bf7; --ph3: #3b95ff;
--bar-opacity: 1;
color-scheme: light;
}
/* Printing is always the light theme, whatever the screen is set to. */
@media print {
:root { --bg:#ffffff; --surface:#ffffff; --elevated:#ffffff; --overlay:#ffffff;
--border:#d0d7de; --border-muted:#d8dee4;
--text:#1f2328; --text-body:#24292f; --text2:#59636e; --text3:#8c959f;
--accent:#00857f; --today:#00857f; --grid-line:rgba(31,35,40,0.10); --weekend:rgba(31,35,40,0.04);
--ph1:#00a49d; --ph2:#8c6bf7; --ph3:#3b95ff;
color-scheme: light; }
html, body { overflow: visible !important; height: auto !important; }
.labels-col, .timeline-col { overflow: visible !important; }
header .actions, .save-indicator { display: none !important; }
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { font: 13px/1.4 'Satoshi', -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; background: var(--bg); color: var(--text); overflow: hidden; height: 100%; }
/* ── Header ── */
header { display: flex; align-items: center; gap: 12px; padding: 0 16px; height: 44px; border-bottom: 1px solid var(--border-muted); background: var(--surface); font-family: 'Familjen Grotesk', sans-serif; flex-shrink: 0; }
header .brand { display: flex; align-items: center; gap: 8px; color: var(--accent); font-size: 13px; font-weight: 600; cursor: default; flex-shrink: 0; }
header .brand svg.logo { width: 16px; height: 16px; flex-shrink: 0; }
header .brand .app-name { color: var(--accent); }
/* Bounded on purpose. The subtitle is author text of any length, and unbounded it
pushed everything after it across the header (and off a narrow one). The full
text is still the header's tooltip, and the project title sits right after it. */
header .brand .app-name { max-width: 20ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
header .brand .sep { color: var(--text3); font-weight: 400; }
header .vsep { width: 1px; height: 18px; background: var(--border); flex-shrink: 0; }
header .subject { display: flex; align-items: baseline; gap: 8px; min-width: 0; flex: 1; cursor: default; overflow: hidden; }
/* The one subtitle. It gives up width before the title does, so on a narrow header
the thing being named survives and its description is what shortens. */
header .subject-sub { color: var(--text2); font-family: 'Satoshi', sans-serif; font-size: 12px; font-weight: 400; min-width: 0; flex-shrink: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* Does not shrink: the subtitle absorbs every bit of squeeze first, so the thing
being named is the last text in the header to give up a character. max-width
keeps it inside the box in the extreme case instead of shoving .actions along. */
header .subject-name { color: var(--text); font-family: 'Familjen Grotesk', sans-serif; font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex-shrink: 0; max-width: 100%; }
header .subject-meta { display: flex; align-items: center; gap: 6px; color: var(--text2); font-family: 'JetBrains Mono', monospace; font-size: 11px; white-space: nowrap; flex-shrink: 0; }
header .subject-meta .sep { color: var(--text3); }
header .start-ctl { display: flex; align-items: center; gap: 8px; flex-shrink: 0; font-family: 'Satoshi', sans-serif; }
header .start-ctl label { color: var(--text2); font-size: 11px; }
header .date-btn { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text-body); font-family: 'JetBrains Mono', monospace; font-size: 11px; padding: 4px 10px; cursor: pointer; transition: border-color 0.15s, color 0.15s; }
header .date-btn:hover { border-color: var(--accent); color: var(--text); }
header .date-btn.shifted { color: var(--accent); border-color: var(--accent); }
header .grain-sel { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text-body); font-family: 'Satoshi', sans-serif; font-size: 11px; padding: 4px 6px; cursor: pointer; outline: none; }
header .grain-sel:hover, header .grain-sel:focus { border-color: var(--accent); color: var(--text); }
header .days-toggle { display: flex; align-items: center; gap: 4px; cursor: pointer; user-select: none; }
header .days-toggle input { appearance: none; width: 13px; height: 13px; border: 1px solid var(--border); border-radius: 3px; background: var(--bg); cursor: pointer; position: relative; flex-shrink: 0; }
header .days-toggle input:checked { background: var(--accent); border-color: var(--accent); }
header .days-toggle input:checked::after { content: ''; position: absolute; left: 3.5px; top: 0.5px; width: 4px; height: 8px; border: solid var(--on-accent); border-width: 0 2px 2px 0; transform: rotate(45deg); }
header .days-toggle span { color: var(--text2); font-size: 11px; }
header .days-toggle input:checked ~ span { color: var(--accent); }
/* Custom date picker: the native <input type=month> popup is browser chrome, so it
cannot be themed or relabelled. This replaces it. */
.datepop { position: fixed; z-index: 99999; background: var(--overlay); border: 1px solid var(--border); border-radius: 10px; box-shadow: 0 12px 40px rgba(0,0,0,0.45); padding: 10px; display: none; font-family: 'Satoshi', sans-serif; min-width: 244px; }
.datepop.show { display: block; }
.datepop .dp-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.datepop .dp-title { font-family: 'Familjen Grotesk', sans-serif; font-weight: 600; font-size: 13px; color: var(--text); }
.datepop .dp-nav { background: none; border: 1px solid var(--border); border-radius: 6px; color: var(--text2); cursor: pointer; width: 24px; height: 24px; line-height: 1; font-size: 13px; }
.datepop .dp-nav:hover { color: var(--accent); border-color: var(--accent); }
.datepop .dp-grid { display: grid; gap: 4px; }
.datepop .dp-grid.months { grid-template-columns: repeat(4, 1fr); }
.datepop .dp-grid.days { grid-template-columns: repeat(7, 1fr); }
.datepop .dp-cell { background: none; border: 1px solid transparent; border-radius: 6px; color: var(--text-body); font-family: 'JetBrains Mono', monospace; font-size: 11px; padding: 6px 0; cursor: pointer; text-align: center; }
.datepop .dp-cell:hover { border-color: var(--accent); color: var(--text); }
.datepop .dp-cell.sel { background: var(--accent); color: var(--on-accent); border-color: var(--accent); font-weight: 600; }
.datepop .dp-cell.muted { color: var(--text3); }
.datepop .dp-dow { text-align: center; font-family: 'JetBrains Mono', monospace; font-size: 9px; color: var(--text2); padding-bottom: 2px; }
.datepop .dp-foot { display: flex; justify-content: space-between; align-items: center; margin-top: 10px; padding-top: 8px; border-top: 1px solid var(--border); }
.datepop .dp-link { background: none; border: none; color: var(--accent); font-family: 'Satoshi', sans-serif; font-size: 11px; cursor: pointer; padding: 2px 4px; }
.datepop .dp-link:hover { text-decoration: underline; }
.datepop .dp-link.muted { color: var(--text2); }
header .actions { display: flex; align-items: center; gap: 4px; margin-left: auto; flex-shrink: 0; }
header .act-btn { background: none; border: 1px solid var(--border); border-radius: 6px; color: var(--text2); font-family: 'Satoshi', sans-serif; font-size: 11px; padding: 4px 10px; cursor: pointer; transition: background 0.15s, color 0.15s; display: flex; align-items: center; gap: 4px; }
header .act-btn:hover { background: rgba(255,255,255,0.04); color: var(--text); }
header .act-btn svg { width: 14px; height: 14px; }
/* Their labels change length ("Lines"/"Diamonds", "Light"/"Dark"). .actions is
right-aligned, so a width change there drags everything left of it, including
the Fit button. Pin them to their widest label. */
/* The single primary action in the chrome, and the only teal fill. Sits second
from the right of the right-aligned group, behind a fixed-width control, so
its position is as stable as the last element's. */
header .new-btn { display: flex; align-items: center; gap: 6px; background: var(--accent); color: var(--on-accent); border: 1px solid var(--accent); border-radius: 6px; font-family: 'Satoshi', sans-serif; font-size: 11px; font-weight: 600; padding: 4px 12px; cursor: pointer; flex-shrink: 0; white-space: nowrap; transition: background 0.15s; }
header .new-btn:hover { background: var(--accent-bright); border-color: var(--accent-bright); }
header .new-btn svg { width: 13px; height: 13px; }
header #btnMsStyle { min-width: 96px; justify-content: center; }
header #btnTheme { min-width: 56px; justify-content: center; }
/* Below this the header is out of room and the project's own name starts to
truncate, which is the one thing in here that must not. Shed chrome instead:
the buttons keep their icons and their tooltips, the unit count goes (the axis
already shows the span), and the "starts" label goes (its button says a date).
With the labels gone the buttons are all one width, so nothing shifts. */
@media (max-width: 1460px) {
header .subject-meta,
header .start-ctl label,
header .act-btn .btn-label,
header #msStyleLabel { display: none; }
/* Icon-only, but still pinned: the milestone glyph swaps between a bar and a
diamond, which are not the same width, and .actions is right-aligned so that
alone dragged the Fit button sideways. Theme keeps its word rather than
becoming a blank button. */
header #btnMsStyle { min-width: 36px; }
}
/* Narrower still: an abbreviated subtitle is worse than no subtitle. Drop it and
keep the title whole. The full text stays on the header's tooltip. */
@media (max-width: 1180px) {
header .subject-sub { display: none; }
}
/* ── Legend ── */
/* One line, always. The legend and the table's filters are the same kind of
thing (what am I looking at), so stacking them into two bands wasted a row and
read as two unrelated toolbars. nowrap plus a shed ladder below. */
.legend { display: flex; gap: 4px; padding: 8px 16px; border-bottom: 1px solid var(--border-muted); background: var(--surface); align-items: center; flex-wrap: nowrap; flex-shrink: 0; overflow: hidden; }
.legend-item { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--text2); font-family: 'Satoshi', sans-serif; padding: 3px 10px; border-radius: 6px; cursor: pointer; transition: opacity 0.15s, background 0.15s; user-select: none; }
.legend-item:hover { background: var(--row-hover); }
.legend-item.off { opacity: 0.3; }
.legend-item.static { cursor: default; }
.legend-item.static:hover { background: none; }
.legend-dot { width: 8px; height: 8px; border-radius: 2px; flex-shrink: 0; }
.legend-diamond { width: 7px; height: 7px; transform: rotate(45deg); background: var(--text); border: 1px solid var(--text2); flex-shrink: 0; }
.legend-line { width: 12px; height: 2px; background: var(--today); border-radius: 1px; flex-shrink: 0; }
/* ── Chart ── */
.chart-wrap { display: flex; flex: 1; overflow: hidden; min-height: 0; }
/* position:relative matters more than it looks. The drop indicator is absolutely
positioned with left:0;right:0, and without a positioned ancestor here it was
laid out against a container further up: it spanned far past the label column
(a stray teal line across the app) and its top was measured in a different
coordinate space than the cursor maths, which is what made it lag the pointer.
Now the rows and the indicator share this element as their offset parent. */
.labels-col { position: relative; width: var(--label-w, clamp(150px, 24vw, 272px)); flex-shrink: 0; border-right: 1px solid var(--border); overflow: hidden; background: var(--bg); }
/* Drag handle sitting on the divider. Wider than the 1px border so it is grabbable,
and it keeps its own cursor so the row underneath still behaves normally. */
.col-resizer { width: 7px; margin-left: -4px; margin-right: -3px; flex-shrink: 0; cursor: col-resize; position: relative; z-index: 30; }
.col-resizer::after { content: ''; position: absolute; inset: 0 3px; background: transparent; transition: background 0.15s; }
.col-resizer:hover::after, .col-resizer.dragging::after { background: var(--accent); }
body.col-resizing { cursor: col-resize; user-select: none; }
.timeline-col { flex: 1; overflow: auto; scrollbar-width: none; }
.timeline-col::-webkit-scrollbar { height: 0; width: 0; }
.tailroom { flex-shrink: 0; pointer-events: none; }
@media print { .tailroom { display: none; } }
.q-row, .m-row { display: flex; }
.q-row { height: 22px; } .m-row { height: 24px; }
.q-cell { display: flex; align-items: center; justify-content: center; height: 100%; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text2); border-bottom: 1px solid var(--border-muted); border-right: 1px solid var(--border-muted); }
.m-cell.weekend { color: var(--text3); }
.m-cell { display: flex; align-items: center; justify-content: center; height: 100%; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text2); border-bottom: 1px solid var(--border); border-right: 1px solid var(--border-muted); }
.label-h-q { height: 22px; border-bottom: 1px solid var(--border-muted); }
.label-h-m { height: 24px; border-bottom: 1px solid var(--border); }
/* ── Rows ── */
.label-row { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; height: 34px; display: flex; align-items: center; padding: 0 12px; border-bottom: 1px solid rgba(48,54,61,0.15); font-family: 'Satoshi', sans-serif; font-size: 12px; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: default; transition: background 0.15s, opacity 0.15s; }
.label-row:hover { background: var(--row-hover); }
.label-row.group { font-family: 'Familjen Grotesk', sans-serif; font-size: 12px; font-weight: 600; color: var(--text2); background: var(--surface); height: 28px; letter-spacing: 0.02em; }
.label-row.ms { color: var(--text2); font-size: 11px; font-style: italic; }
.label-row.dim { opacity: 0.15; }
.label-row.multi-sel { background: rgba(0,184,176,0.06); }
.tag { display: inline-block; font-family: 'JetBrains Mono', monospace; font-size: 9px; padding: 1px 5px; border-radius: 4px; margin-left: 6px; vertical-align: middle; }
.tag-not-started { background: rgba(139,148,158,0.10); color: var(--text2); }
.tag-in-progress { background: rgba(0,184,177,0.14); color: var(--accent); }
.tag-blocked { background: rgba(210,153,34,0.15); color: var(--warn); }
.tag-complete { background: rgba(63,185,80,0.14); color: var(--ok); }
/* A status that came in from a CSV and is none of the four renders as itself,
quietly. Better than pretending it is one of ours or dropping it. */
.tag-other { background: rgba(139,148,158,0.10); color: var(--text2); font-style: italic; }
.bar-row { height: 34px; position: relative; border-bottom: 1px solid rgba(48,54,61,0.15); transition: opacity 0.15s; }
.bar-row.group { height: 28px; background: var(--surface); }
.bar-row.dim { opacity: 0.15; }
.bar { position: absolute; top: 7px; height: 20px; border-radius: 6px; opacity: var(--bar-opacity, 0.82); cursor: pointer; transition: opacity 0.15s; }
.bar:hover { opacity: 1; }
.bar.selected { opacity: 1; outline: 2px solid var(--accent); outline-offset: 1px; }
.bar-note { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-family: 'JetBrains Mono', monospace; font-size: 10px; font-weight: 700; color: var(--on-accent); pointer-events: none; overflow: hidden; white-space: nowrap; padding: 0 6px; }
:root[data-theme="dark"] .bar-note { color: rgba(6,20,24,0.82); }
.bar-note-edit { position: absolute; inset: 0; width: 100%; height: 100%; background: var(--bg); border: 1px solid var(--accent); border-radius: 6px; color: var(--text); font-family: 'JetBrains Mono', monospace; font-size: 10px; text-align: center; outline: none; z-index: 20; }
.bar-striped { background-image: repeating-linear-gradient(-45deg, transparent, transparent 4px, rgba(0,0,0,0.12) 4px, rgba(0,0,0,0.12) 8px); }
.bar .drag-handle { position: absolute; top: 0; width: 8px; height: 100%; cursor: ew-resize; z-index: 2; }
.bar .drag-handle.left { left: -2px; border-radius: 6px 0 0 6px; }
.bar .drag-handle.right { right: -2px; border-radius: 0 6px 6px 0; }
.bar .drag-handle:hover { background: var(--row-hover); }
.ms-diamond { position: absolute; top: 11px; width: 10px; height: 10px; transform: rotate(45deg) translateX(-50%); background: var(--text); border: 2px solid var(--bg); z-index: 5; pointer-events: none; transition: box-shadow 0.15s; }
/* Transparent hover strip: carries the tooltip in both milestone styles, so the
full-height line never has to intercept pointer events over the whole chart. */
.ms-hit { position: absolute; top: 0; bottom: 0; width: 16px; transform: translateX(-50%); z-index: 6; cursor: pointer; }
.ms-hit:hover ~ .ms-diamond, .ms-row:hover .ms-diamond { box-shadow: 0 0 10px rgba(230,237,243,0.3); }
/* Line style: a dashed vertical rule spanning the whole chart at the milestone date. */
.ms-line { position: absolute; top: 0; bottom: 0; width: 0; border-left: 1.5px dashed currentColor; opacity: 0.75; z-index: 4; pointer-events: none; }
.ms-line .ms-cap:hover { background: var(--elevated); filter: brightness(1.25); }
.ms-line .ms-cap { position: absolute; top: 2px; left: 0; transform: translateX(-50%); pointer-events: auto; cursor: pointer; font-family: 'JetBrains Mono', monospace; font-size: 9px; line-height: 1.25; color: inherit; background: var(--bg); padding: 2px 4px; border-radius: 3px; border: 1px solid currentColor; white-space: normal; overflow-wrap: anywhere; text-align: center; max-width: 132px; width: max-content; }
:root[data-ms="diamond"] .ms-line { display: none; }
:root[data-ms="line"] .ms-diamond { display: none; }
/* In line style the dashed rule and its tag carry the milestone, so the grid row is
redundant. Both the label and bar row go, together, or the columns fall out of step. */
:root[data-ms="line"] .label-row.ms, :root[data-ms="line"] .bar-row.ms-row { display: none; }
:root[data-ms="line"] .ms-hit { display: none; }
.label-row.ms::before { content: '\25C6\00a0'; }
:root[data-ms="line"] .label-row.ms::before { content: '\00a6\00a0'; }
.today-line { position: absolute; top: 0; bottom: 0; width: 2px; background: var(--today); z-index: 8; pointer-events: none; box-shadow: 0 0 6px rgba(0,184,176,0.3); }
/* The line itself stays pointer-events:none. A full-height hit strip would sit
over every bar it crosses and eat their clicks, which is a worse trade than a
tooltip. The tag is the marker's affordance, so it carries the tooltip, with a
little more padding to make it a comfortable target. */
.today-tag { position: absolute; top: 4px; transform: translateX(-50%); font-family: 'JetBrains Mono', monospace; font-size: 9px; color: var(--today); background: var(--bg); padding: 2px 6px; border-radius: 3px; border: 1px solid rgba(0,184,176,0.25); pointer-events: auto; cursor: default; z-index: 9; white-space: nowrap; }
.today-tag:hover { border-color: var(--today); }
.dep-svg { position: absolute; top: 0; left: 0; pointer-events: none; z-index: 4; overflow: visible; }
.dep-svg path { fill: none; stroke: var(--accent); stroke-width: 1.5; opacity: 0.5; transition: opacity 0.15s; }
.dep-svg polygon { fill: var(--accent); opacity: 0.5; transition: opacity 0.15s; }
.dep-svg.hidden path, .dep-svg.hidden polygon { opacity: 0; }
/* ── Tooltip ── */
.tip { display: none; position: fixed; z-index: 99999; background: rgba(22,27,34,0.92); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); border: 1px solid rgba(0,184,177,0.2); border-radius: 10px; padding: 10px 14px; max-width: 340px; box-shadow: 0 12px 40px rgba(0,0,0,0.55); pointer-events: none; font-family: 'Satoshi', sans-serif; font-size: 12px; line-height: 1.5; color: var(--text); }
.tip.show { display: block; }
.tip .tip-title { font-family: 'Familjen Grotesk', sans-serif; font-weight: 600; font-size: 13px; color: var(--accent); margin-bottom: 4px; }
.tip .tip-dates { font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text2); margin-bottom: 6px; }
.tip .tip-body { color: var(--text2); }
.tip .tip-prov { margin-top: 6px; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text2); }
/* ── Editor panel (single + batch) ── */
.editor { position: fixed; z-index: 99998; background: var(--elevated); border: 1px solid var(--border); border-radius: 8px; padding: 0 14px 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.5); font-family: 'Satoshi', sans-serif; display: none; min-width: 280px; }
.editor .drag-bar { height: 28px; cursor: grab; display: flex; align-items: center; justify-content: center; margin: 0 -14px 8px; padding: 0 14px; border-radius: 8px 8px 0 0; }
.editor .drag-bar:active { cursor: grabbing; }
.editor .drag-bar::after { content: ''; width: 32px; height: 3px; border-radius: 2px; background: var(--border); }
.editor.show { display: block; }
.editor label { display: block; font-size: 11px; color: var(--text2); margin-bottom: 4px; font-family: 'JetBrains Mono', monospace; }
.editor input, .editor textarea, .editor select { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text); font-family: 'JetBrains Mono', monospace; font-size: 12px; padding: 5px 8px; outline: none; }
.editor input[type="date"], .editor input[type="number"] { width: 130px; }
.editor input[type="text"] { width: 100%; }
.editor input:focus, .editor textarea:focus, .editor select:focus { border-color: var(--accent); }
.editor input:focus { border-color: var(--accent); }
.editor .de-row { display: flex; gap: 10px; margin-bottom: 8px; }
.editor .de-note-row { margin-top: 10px; }
.editor .de-note-row input { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text); font-family: 'JetBrains Mono', monospace; font-size: 12px; padding: 5px 8px; width: 100%; outline: none; }
.editor .de-note-row input:focus { border-color: var(--accent); }
.editor .de-hint { color: var(--text2); font-size: 10px; font-family: 'Satoshi', sans-serif; }
.editor .de-actions { display: flex; gap: 8px; margin-top: 10px; }
.editor .de-dep-section { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border-muted); }
.editor select { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text); font-family: 'Satoshi', sans-serif; font-size: 12px; padding: 5px 8px; width: 100%; outline: none; }
.de-btn { border: none; border-radius: 6px; padding: 6px 14px; font-family: 'Satoshi', sans-serif; font-size: 12px; font-weight: 500; cursor: pointer; transition: background 0.15s; }
.de-btn.primary { background: var(--accent); color: var(--bg); }
.de-btn.primary:hover { background: var(--accent-bright); }
.de-btn.secondary { background: transparent; color: var(--text2); border: 1px solid var(--border); }
.de-btn.secondary:hover { background: var(--row-hover); }
.dep-list { list-style: none; margin: 4px 0 0; }
.dep-list li { display: flex; align-items: center; justify-content: space-between; font-size: 11px; color: var(--text2); padding: 3px 0; }
.dep-list .dep-rm { color: var(--text2); cursor: pointer; font-size: 13px; padding: 0 4px; border-radius: 4px; transition: color 0.15s; }
.dep-list .dep-rm:hover { color: #f85149; }
.batch-title { font-family: 'Familjen Grotesk', sans-serif; font-size: 13px; font-weight: 600; color: var(--accent); margin-bottom: 10px; }
.batch-hint { font-size: 11px; color: var(--text2); margin-bottom: 8px; }
.batch-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.batch-row label { margin: 0; min-width: 60px; }
.batch-row input[type="number"] { width: 70px; }
.batch-row span { font-size: 11px; color: var(--text2); }
/* ── Dropdown menu ── */
.dropdown { position: fixed; z-index: 99997; background: var(--elevated); border: 1px solid var(--border); border-radius: 8px; padding: 4px 0; box-shadow: 0 8px 24px rgba(0,0,0,0.5); display: none; min-width: 180px; }
.dropdown.show { display: block; }
.dropdown .dd-item { padding: 6px 14px; font-family: 'Satoshi', sans-serif; font-size: 12px; color: var(--text2); cursor: pointer; transition: background 0.15s; display: flex; align-items: center; gap: 8px; }
.dropdown .dd-item:hover { background: rgba(255,255,255,0.04); color: var(--text); }
.dropdown .dd-sep { height: 1px; background: var(--border-muted); margin: 4px 0; }
.dropdown .dd-label { padding: 4px 14px; font-family: 'JetBrains Mono', monospace; font-size: 9px; color: var(--text2); }
/* ── Toasts ── */
.undo-toast { position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%); background: var(--elevated); border: 1px solid var(--border); border-radius: 8px; padding: 8px 16px; font-size: 12px; color: var(--text2); box-shadow: 0 8px 24px rgba(0,0,0,0.5); z-index: 99998; opacity: 0; transition: opacity 0.15s; pointer-events: none; }
.undo-toast.show { opacity: 1; }
.undo-toast kbd { background: var(--bg); border: 1px solid var(--border); border-radius: 4px; padding: 1px 5px; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text2); }
/* A plan that could not be fully rendered says so, and keeps saying so: a silently
shorter plan is worse than an ugly bar. */
.data-warn { display: none; position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%); max-width: 640px; background: var(--elevated); border: 1px solid var(--border); border-left: 3px solid var(--accent); border-radius: 8px; padding: 10px 14px; font-family: 'Satoshi', sans-serif; font-size: 12px; color: var(--text-body); box-shadow: 0 8px 24px rgba(0,0,0,0.5); z-index: 99999; }
.data-warn.show { display: block; }
.data-warn .dw-title { font-family: 'Familjen Grotesk', sans-serif; font-weight: 600; color: var(--text); margin-bottom: 4px; }
.data-warn .dw-rows { font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text2); margin-top: 6px; word-break: break-word; }
.data-warn .dw-close { position: absolute; top: 6px; right: 10px; background: none; border: none; color: var(--text2); font-size: 14px; cursor: pointer; line-height: 1; }
.data-warn .dw-close:hover { color: var(--text); }
@media print { .data-warn { display: none !important; } }
.save-indicator { position: fixed; bottom: 16px; right: 16px; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text2); z-index: 99998; opacity: 0; transition: opacity 0.3s; }
.save-indicator.show { opacity: 1; }
/* The version is chrome: present for orientation, never competing with the plan. */
header .subject-meta .app-ver { color: var(--text3); }
.sel-count { position: fixed; bottom: 16px; left: 16px; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--accent); z-index: 99998; opacity: 0; transition: opacity 0.15s; }
.sel-count.show { opacity: 1; }
/* ── View toggle: the same plan as a chart or as a table ──
The primary control in the header, so it is bigger and heavier than the action
buttons. Its position is anchored to the brand block on its left, which never
changes width, so nothing downstream appearing or disappearing can move it. */
.view-toggle { display: flex; border: 1px solid var(--border); border-radius: 7px; overflow: hidden; flex-shrink: 0; background: var(--bg); }
.view-toggle .vt-btn { display: flex; align-items: center; gap: 6px; background: none; border: none; color: var(--text2); font-family: 'Satoshi', sans-serif; font-size: 12px; line-height: 1; padding: 6px 13px; cursor: pointer; transition: background 0.15s, color 0.15s; white-space: nowrap; }
.view-toggle .vt-btn svg { width: 14px; height: 14px; flex-shrink: 0; }
.view-toggle .vt-btn:hover { background: rgba(255,255,255,0.05); color: var(--text); }
/* Active carries four differences: fill, text colour, weight and an accent rule.
It is deliberately NOT teal-filled any more: a filled teal control is the
primary action, and there is exactly one of those in the chrome (New). */
.view-toggle .vt-btn.on { background: var(--elevated); color: var(--accent); font-weight: 600; box-shadow: inset 0 -2px 0 var(--accent); }
/* ── Table view ──
A second lens over the same items. The chart answers "when"; the table answers
"whose, and by when". Both read and write the same rows, so an edit in one is
already true in the other. */
.table-view { display: none; flex: 1; min-height: 0; flex-direction: column; background: var(--bg); }
:root[data-view="table"] .table-view { display: flex; }
:root[data-view="table"] .chart-wrap { display: none; }
/* The start control, the grain picker and the milestone style are chart chrome:
nothing in the table changes when they do. */
:root[data-view="table"] header .start-ctl,
:root[data-view="table"] #btnMsStyle,
:root[data-view="table"] .legend-item.gantt-only { display: none; }
/* Now a group inside the legend line, not a band of its own. */
.table-tools { display: flex; align-items: center; gap: 8px; flex-shrink: 0; flex-wrap: nowrap; margin-left: auto; padding-left: 12px; }
:root[data-view="gantt"] .table-tools { display: none; }
.legend-item { flex-shrink: 0; }
/* Below this the phase chips give up their words before any control wraps: the
colour and the group headers already say which lane is which. */
@media (max-width: 1150px) {
.legend-item.phase-chip span.chip-label { display: none; }
.legend-item.phase-chip { padding: 3px 6px; }
.table-tools input[type="search"] { width: 130px; }
}
.table-tools label { font-family: 'Satoshi', sans-serif; font-size: 11px; color: var(--text2); }
.table-tools select, .table-tools input[type="search"] { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text-body); font-family: 'Satoshi', sans-serif; font-size: 11px; padding: 4px 8px; outline: none; }
.table-tools select:hover, .table-tools select:focus,
.table-tools input[type="search"]:hover, .table-tools input[type="search"]:focus { border-color: var(--accent); color: var(--text); }
.table-tools input[type="search"] { width: 190px; }
.table-tools .tt-btn { background: none; border: 1px solid var(--border); border-radius: 6px; color: var(--text2); font-family: 'Satoshi', sans-serif; font-size: 11px; padding: 4px 10px; cursor: pointer; transition: background 0.15s, color 0.15s; }
.table-tools .tt-btn:hover { background: rgba(255,255,255,0.04); color: var(--text); }
.table-count { margin-left: auto; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text2); }
/* The table scrolls inside its own box, so a wide plan never pushes the page sideways. */
.table-scroll { flex: 1; min-height: 0; overflow: auto; scrollbar-gutter: stable; }
/* The gutter is reserved whether or not there is anything to scroll. Without it a
filter that shortens the list takes the vertical scrollbar away, the pane gets
15px wider, and every proportional column grows by a fraction: a width change
with no cause the reader can see, which is the same complaint in a smaller size. */
table.data-table { table-layout: fixed; border-collapse: separate; border-spacing: 0; width: 100%; min-width: 900px; font-family: 'Satoshi', sans-serif; font-size: 12px; color: var(--text-body); }
.data-table thead th { position: sticky; top: 0; z-index: 2; background: var(--surface); border-bottom: 1px solid var(--border); font-family: 'Familjen Grotesk', sans-serif; font-size: 11px; font-weight: 600; color: var(--text2); text-align: left; padding: 8px 10px; white-space: nowrap; user-select: none; }
.data-table thead th.sortable { cursor: pointer; }
.data-table thead th.sortable:hover { color: var(--text); }
.data-table thead th.sorted { color: var(--accent); }
.data-table thead th .sort-caret { font-size: 8px; margin-left: 5px; }
.data-table tbody td { border-bottom: 1px solid rgba(48,54,61,0.15); padding: 5px 10px; vertical-align: middle; }
.data-table tbody tr:hover { background: var(--row-hover); }
.data-table td.c-name { color: var(--text); }
.data-table tr.is-ms td.c-name { color: var(--text2); font-style: italic; }
.data-table td.c-name .ms-mark { color: var(--text2); font-style: normal; margin-right: 4px; }
.data-table td.c-owner, .data-table td.c-date { font-family: 'JetBrains Mono', monospace; font-size: 11px; white-space: nowrap; }
.data-table td.c-phase { color: var(--text-body); white-space: nowrap; }
.data-table td.editable { cursor: text; }
.data-table td.editable:hover { box-shadow: inset 0 0 0 1px var(--border); border-radius: 4px; }
.data-table td.empty { color: var(--text2); }
.data-table .trunc { display: block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.data-table td.c-notes .trunc { color: var(--text-body); }
.data-table td.c-notes.empty .trunc { color: var(--text2); }
.data-table .phase-dot { display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: 7px; vertical-align: middle; }
/* The editor is laid OVER the cell, not put inside it. Replacing the cell's
content made the column resize to whatever was being typed, and a select is as
wide as its widest option, so the table jumped on every click. An absolutely
positioned overlay is out of flow, so the column cannot feel it; min-width:0
stops the control having opinions of its own. */
.data-table td { position: relative; }
.data-table .cell-edit { position: absolute; inset: 1px 3px; z-index: 6; display: flex; align-items: stretch; }
.data-table .cell-input { width: 100%; min-width: 0; max-width: 100%; box-sizing: border-box; background: var(--bg); border: 1px solid var(--accent); border-radius: 4px; color: var(--text); font-family: 'JetBrains Mono', monospace; font-size: 11px; padding: 3px 6px; outline: none; text-overflow: ellipsis; }
.data-table select.cell-input { font-family: 'Satoshi', sans-serif; }
/* ── Drag to reorder ──
Arming is a click-and-hold, so every existing click (open a cell editor, hit
the delete button, sort a header) still lands untouched. Once armed the row
lifts and a drop indicator says where it will land. */
.table-scroll { position: relative; }
.data-table tbody tr.row-lifted { opacity: 0.45; }
.data-table tbody tr.row-lifted td { background: rgba(0,184,177,0.06); }
body.row-dragging { cursor: grabbing; user-select: none; }
body.row-dragging .data-table tbody tr:hover { background: none; }
.row-drop { position: absolute; left: 0; right: 0; height: 2px; background: var(--accent); border-radius: 1px; box-shadow: 0 0 6px rgba(0,184,176,0.5); z-index: 8; display: none; pointer-events: none; }
.row-drop.show { display: block; }
.row-drop::before { content: ''; position: absolute; left: 0; top: -3px; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); }
/* The lane a drop would land in, called out while dragging across it. */
.data-table tbody tr.lane-target td.c-phase { box-shadow: inset 2px 0 0 var(--accent); }
/* The focused row: the row whose editor you touched last, and the row Alt+Up and
Alt+Down move. An inset shadow rather than a border or a pseudo-element with a
width, so the indicator cannot shift a single pixel of the row it marks. */
.data-table tbody tr.row-focus > td:first-child { box-shadow: inset 3px 0 0 var(--accent); }
.data-table tbody tr.row-focus > td { background: rgba(0,184,177,0.045); }
/* Plan order is the only order you can drag in, so say so rather than letting
the row silently refuse. */
.data-table tbody tr.no-drag { cursor: default; }
.sort-lock-note { font-family: 'Satoshi', sans-serif; font-size: 10px; color: var(--text2); margin-left: 10px; white-space: nowrap; }
.sort-lock-note button { background: none; border: none; color: var(--accent); font: inherit; cursor: pointer; padding: 0; text-decoration: underline; }
.data-table .table-empty { padding: 22px 12px; color: var(--text2); font-family: 'Satoshi', sans-serif; font-size: 12px; }
/* Provenance: a short chip, because the useful part is usually one token ("POR",
a channel name) and the full text is on the tooltip. A chip with a link looks
like a link; one without stays quiet chrome. */
.data-table td.c-prov { white-space: nowrap; }
.data-table .prov-chip { display: inline-block; max-width: calc(150px * var(--fs)); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: middle; font-family: 'JetBrains Mono', monospace; font-size: calc(9px * var(--fs)); line-height: 1.5; padding: 1px 7px; border-radius: 999px; border: 1px solid var(--border); color: var(--text2); text-decoration: none; }
a.prov-chip { border-color: rgba(0,184,177,0.35); color: var(--accent); }
a.prov-chip:hover { border-color: var(--accent); background: rgba(0,184,177,0.08); text-decoration: underline; }
.data-table td.c-prov.editable:hover { box-shadow: inset 0 0 0 1px var(--border); border-radius: 4px; }
/* Delete: quiet until you are on the row, destructive-red only on the button
itself, and never wider than the glyph so it cannot push the other columns. */
.data-table th.th-del { width: 30px; }
.data-table td.c-del { width: 30px; padding: 0 6px 0 0; text-align: right; }
.data-table .row-del { background: none; border: none; color: var(--text3); font-size: 15px; line-height: 1; padding: 2px 4px; border-radius: 4px; cursor: pointer; opacity: 0; transition: opacity 0.12s, color 0.12s, background 0.12s; }
.data-table tbody tr:hover .row-del { opacity: 1; }
.data-table .row-del:hover { color: #f85149; background: rgba(248,81,73,0.10); }
.data-table .row-del:focus-visible { opacity: 1; outline: 1px solid var(--accent); }
.undo-toast kbd { white-space: nowrap; }
@media print {
.table-scroll { overflow: visible !important; }
.table-tools { display: none !important; }
table.data-table { min-width: 0; }
}
/* ── Collapse a lane ──
Collapsing hides rows, not time: the axis is a function of dates and does not
move. Both columns hide the same rows or they drift apart as you scroll. */
.label-row.row-collapsed, .bar-row.row-collapsed, .ms-line.row-collapsed { display: none !important; }
.label-row.group { position: relative; padding-right: 4px; }
.label-row.group .lane-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.lane-toggle { display: flex; align-items: center; gap: 5px; background: none; border: none; color: var(--text2); cursor: pointer; padding: 2px 4px; border-radius: 4px; font-family: 'JetBrains Mono', monospace; font-size: calc(9px * var(--fs)); flex-shrink: 0; }
.lane-toggle:hover { background: var(--row-hover); color: var(--text); }
.lane-toggle svg { width: 11px; height: 11px; transition: transform 0.15s; }
.lane-toggle.collapsed svg { transform: rotate(-90deg); }
.lane-toggle .lane-count { color: var(--text3); }
.lane-toggle.collapsed .lane-count { color: var(--accent); }
/* Editing a name straight in the label column. Fixed to the row's own box so the
name column never resizes, same rule the table cells follow. */
.label-edit { position: absolute; inset: 3px 6px; width: auto; background: var(--bg); border: 1px solid var(--accent); border-radius: 4px; color: var(--text); font-family: 'Satoshi', sans-serif; font-size: calc(12px * var(--fs)); padding: 0 6px; outline: none; z-index: 12; box-sizing: border-box; }
.label-row { position: relative; }
/* Appears only under the cursor, absolutely positioned so it never moves the
name text or changes the row's height. */
.row-edit { position: absolute; right: 4px; top: 50%; transform: translateY(-50%); display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; padding: 0; background: var(--surface); border: 1px solid var(--border); border-radius: 4px; color: var(--text2); cursor: pointer; opacity: 0; pointer-events: none; transition: opacity 0.12s, color 0.12s, border-color 0.12s; z-index: 11; }
.row-edit svg { width: 12px; height: 12px; }
.label-row:hover .row-edit { opacity: 1; pointer-events: auto; }
.row-edit:hover { color: var(--accent); border-color: var(--accent); }
.row-edit:focus-visible { opacity: 1; pointer-events: auto; outline: 1px solid var(--accent); }
.label-row .row-name { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding-right: 22px; }
/* ---- resizable table columns ---- */
.data-table th { position: relative; }
.col-grip { position: absolute; top: 0; right: 0; width: 8px; height: 100%; cursor: col-resize; z-index: 3; }
.col-grip::after { content: ''; position: absolute; inset: 4px 3px; background: transparent; transition: background 0.15s; }
.col-grip:hover::after, .col-grip.dragging::after { background: var(--accent); }
body.col-sizing { cursor: col-resize; user-select: none; }
/* The grid is fixed from the first paint, so every cell clips at its column and
nothing in the body can reach a width. .fixed-cols now only means "the user has
set widths of their own", which is what switches the table from the proportional
defaults to pixels. */
.data-table td, .data-table th { overflow: hidden; }
.data-table thead th { text-overflow: ellipsis; }
.drop-indicator { position: absolute; left: 0; right: 0; height: 2px; background: var(--accent); z-index: 20; pointer-events: none; border-radius: 1px; box-shadow: 0 0 6px rgba(0,184,176,0.4); display: none; }
.drop-indicator.show { display: block; }
.label-row.dragging, .bar-row.dragging { opacity: 0.4; }
.label-row[data-id] { cursor: default; }
.labels-col, .timeline-col { -webkit-user-select: none; user-select: none; }
.app { display: flex; flex-direction: column; height: 100vh; }
input[type="file"] { display: none; }
/* ── Text scale ──────────────────────────────────────────────────────────────
One multiplier drives every size that carries text, and the row geometry that
has to grow with it, so a bigger typeface does not overflow its row. Sizes stay
in px and are simply multiplied, which leaves all the existing layout maths
alone: the JS measures offsetHeight/offsetWidth rather than assuming 34.
These rules come last on purpose, so they win over the originals above. */
:root {
--fs: 1;
--row-h: calc(34px * var(--fs));
--group-h: calc(28px * var(--fs));
--bar-h: calc(20px * var(--fs));
--ms-d: calc(10px * var(--fs));
}
.labels-col { width: var(--label-w, calc(clamp(150px, 24vw, 272px) * var(--fs))); }
.label-row { height: var(--row-h); font-size: calc(12px * var(--fs)); }
.label-row.group { height: var(--group-h); font-size: calc(12px * var(--fs)); }
.label-row.ms { font-size: calc(11px * var(--fs)); }
.tag { font-size: calc(9px * var(--fs)); }
.bar-row { height: var(--row-h); }
.bar-row.group { height: var(--group-h); }
.bar { top: calc((var(--row-h) - var(--bar-h)) / 2); height: var(--bar-h); }
.bar-note, .bar-note-edit { font-size: calc(10px * var(--fs)); }
.q-row, .label-h-q { height: calc(22px * var(--fs)); }
.m-row, .label-h-m { height: calc(24px * var(--fs)); }
.q-cell, .m-cell { font-size: calc(10px * var(--fs)); }
.ms-diamond { width: var(--ms-d); height: var(--ms-d); top: calc((var(--row-h) - var(--ms-d)) / 2); }
.ms-line .ms-cap { font-size: calc(9px * var(--fs)); max-width: calc(132px * var(--fs)); }
.today-tag { font-size: calc(9px * var(--fs)); }
.legend-item { font-size: calc(11px * var(--fs)); }
.tip { font-size: calc(12px * var(--fs)); }
.tip .tip-title { font-size: calc(13px * var(--fs)); }
.tip .tip-dates { font-size: calc(10px * var(--fs)); }
.tip .tip-prov { font-size: calc(10px * var(--fs)); }
.tip .tip-body { font-size: calc(12px * var(--fs)); }
table.data-table { font-size: calc(12px * var(--fs)); min-width: calc(900px * var(--fs)); }
.data-table thead th { font-size: calc(11px * var(--fs)); }
.data-table td.c-owner, .data-table td.c-date { font-size: calc(11px * var(--fs)); }
.data-table .cell-input { font-size: calc(11px * var(--fs)); }
/* The scale control itself never scales: it is chrome, and a control that grew
as you pressed it would walk away from the pointer. */
/* Last child of the right-aligned .actions group, which is the only position in
that group that cannot move: its right edge is the header's right padding, and
everything that hides or changes label (milestone style, theme, the button
labels under 1460px) is upstream of it. */
.fs-ctl { display: flex; align-items: stretch; height: 24px; border: 1px solid var(--border); border-radius: 7px; overflow: hidden; flex-shrink: 0; background: var(--bg); }
.fs-ctl button { background: none; border: none; color: var(--text2); cursor: pointer; padding: 0; transition: background 0.15s, color 0.15s; display: flex; align-items: center; justify-content: center; }
.fs-ctl button:hover { background: rgba(255,255,255,0.05); color: var(--text); }
.fs-ctl .fs-btn { width: 26px; }
.fs-ctl .fs-btn svg { width: 15px; height: 15px; }
.fs-ctl .fs-btn.fs-big svg { width: 19px; height: 19px; }
.fs-ctl button:disabled { color: var(--text3); cursor: default; background: none; }
/* Fixed width: the readout goes "85%" to "100%" and a reflow here would nudge
everything downstream of it. */
.fs-ctl .fs-pct { min-width: 46px; font-family: 'JetBrains Mono', monospace; font-size: 10px; border-left: 1px solid var(--border); border-right: 1px solid var(--border); }
.fs-ctl .fs-pct.off-default { color: var(--accent); }
</style>
</head>
<body>
<div class="app">
<header id="hdr"></header>
<div class="legend" id="legend"></div>
<div class="chart-wrap">
<div class="labels-col" id="labels"></div>
<div class="col-resizer" id="colResizer" title="Drag to resize the name column. Double-click to fit the longest name."></div>
<div class="timeline-col" id="timeline"></div>
</div>
<div class="table-view" id="tableView">
<div class="table-tools" id="tableTools">
<label for="tblOwner">owner</label>
<select id="tblOwner" title="Show only one person's work"></select>
<label for="tblStatus">status</label>
<select id="tblStatus">
<option value="">All</option>
<option value="not-started">not started</option>
<option value="in-progress">in progress</option>
<option value="blocked">blocked</option>
<option value="complete">complete</option>
</select>
<input type="search" id="tblSearch" placeholder="Filter by text" aria-label="Filter rows by text">
<button class="tt-btn" id="tblClear" title="Clear every filter">Clear</button>
<span class="sort-lock-note" id="sortLockNote" style="display:none"></span>
<span class="table-count" id="tblCount"></span>
</div>
<div class="table-scroll">
<div class="row-drop" id="rowDrop"></div>
<table class="data-table">
<thead><tr id="tableHead">
<th class="sortable" data-sort="name">Task</th>
<th class="sortable" data-sort="phase">Phase</th>
<th class="sortable" data-sort="owner">Owner</th>
<th class="sortable" data-sort="start">Start</th>
<th class="sortable" data-sort="deadline">Deadline</th>
<th class="sortable" data-sort="status">Status</th>
<th class="sortable" data-sort="notes">Notes</th>
<th class="sortable" data-sort="prov" title="Where this item came from">Provenance</th>
<th class="th-del" aria-label="Delete"></th>
</tr></thead>
<tbody id="tableBody"></tbody>
</table>
</div>
</div>
</div>
<datalist id="ownerList"></datalist>
<div class="tip" id="tip"></div>
<div class="editor" id="editor"></div>
<div class="dropdown" id="exportMenu"></div>
<div class="dropdown" id="importMenu"></div>
<div class="undo-toast" id="undoToast">Undid change · <kbd>Ctrl+Z</kbd></div>
<div class="data-warn" id="dataWarn"></div>
<div class="save-indicator" id="saveInd">saved</div>
<div class="datepop" id="datePop"></div>
<div class="sel-count" id="selCount"></div>
<input type="file" id="importFile" accept=".json,.csv,.xml">
<script>
(async function() {
// Each grain needs its own minimum cell width, sized to the widest label it draws
// ("31" vs "Mar" vs "Q1" vs "2026"). Below it the timeline scrolls instead of squashing.
const GRAINS = {
day: { label:'Days', min:18 },
week: { label:'Weeks', min:26 },
month: { label:'Months', min:34 },
quarter: { label:'Quarters', min:48 },
year: { label:'Years', min:56 },
};
let COL = 66; // month column width, recomputed from the viewport in fitCOL()
const $ = id => document.getElementById(id);
const labelsEl = $('labels'), timelineEl = $('timeline'), tipEl = $('tip');
const editorEl = $('editor'), undoToast = $('undoToast'), saveInd = $('saveInd');
const selCountEl = $('selCount'), exportMenu = $('exportMenu'), importMenu = $('importMenu');
const importFileEl = $('importFile');
// ── Load data ──
const projData = await fetch('data').then(r => r.json());
const { project, phases, items } = projData;
// Stable per-row key, assigned once from the data-file index and never touched
// again. Row order is persisted as a list of these. An id-less row (a group, a
// milestone) used to be keyed by its own label or name, so renaming a milestone
// changed its key, the key no longer resolved on the next load, the row was
// dropped, and the following save wrote the shortened plan back to the sidecar
// permanently. An index cannot go stale when the text changes.
function assignRowKeys(){
items.forEach((r,i)=>{ if(!r.id) r._key='#row'+i; });
}
function rowOrderKey(r){ return r.id || r._key; }
// Milestones use the same stable key as every other row. The server recomputes it
// as `r.id || '#row' + dataFileIndex`, so the two sides must agree exactly or an
// export resolves a sidecar entry onto the wrong milestone.
function msKey(r){ return rowOrderKey(r); }
// Rows the user has deleted, by stable key. Deletion has to be an explicit
// signal rather than an absence: applyOrder is all-or-nothing precisely so a
// stale key can never silently shrink the plan, so "this row is gone on purpose"
// and "this key no longer resolves" must not look the same.
const deletedKeys = new Set();
// Four states. The old vocabulary is aliased on READ, everywhere, so stored data
// (the data file, an example, a sidecar the user is mid-edit in) keeps working
// untouched and only starts writing the new words when it is next saved.
const STATUS_ORDER=['not-started','in-progress','blocked','complete'];
const STATUS_ALIAS={
'live':'in-progress','in progress':'in-progress','in_progress':'in-progress',
'planned':'not-started','not started':'not-started','notstarted':'not-started',
'done':'complete','completed':'complete',
};
function statusKey(v){
const t=String(v==null?'':v).trim().toLowerCase();
if(!t)return '';
if(STATUS_ORDER.includes(t))return t;
return STATUS_ALIAS[t]||t; // unknown values survive as themselves
}
function statusOf(r){ return statusKey(r&&r.status); }
const statusTag={'not-started':'tag-not-started','in-progress':'tag-in-progress',blocked:'tag-blocked',complete:'tag-complete'};
const statusLabel={'not-started':'not started','in-progress':'in progress',blocked:'blocked',complete:'complete'};
function statusTagFor(k){ return statusTag[k]||'tag-other'; }
function statusTextFor(k){ return statusLabel[k]||k; }
// Rows the user created. The app never rewrites the data file, so a new row has
// nowhere to live but the sidecar, and it has to be merged back by both the
// client and the server or an export would not contain it.
const addedItems = [];
// A created row needs a key that cannot collide with a data-file key now OR
// after the data file grows. #row<n> is the data file's namespace, so created
// rows get their own: #new-<base36 time>-<counter>. A created TASK keys on its
// id anyway; this matters for created milestones, which have none.
let newKeyN = 0;
function freshKey(){ return '#new-'+Date.now().toString(36)+'-'+(newKeyN++); }
function slugify(t){ return String(t||'').toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,'').slice(0,40)||'item'; }
function freshId(name){
const base=slugify(name); let id=base, n=2;
while(items.some(r=>r.id===id))id=base+'-'+(n++);
return id;
}
// A row with no usable date used to take the whole chart down with it: parseISO
// split an undefined during the build, the exception escaped, and one bad row
// blanked all 72 good ones with no way back except hand-editing the data file.
// Drop the row, keep the plan, and say so out loud.
function isDateStr(s){ return typeof s==='string' && /^\d{4}-\d{1,2}(-\d{1,2})?$/.test(s); }
function rowDatesOk(r){
if(!r||r.type==='group')return true;
if(r.type==='milestone')return isDateStr(r.date);
return isDateStr(r.start)&&isDateStr(r.end);
}
// Keys are assigned above, over the FULL data-file array, before anything is
// dropped, so the surviving rows keep the keys the server computes for them.
const UNDATED=items.filter(r=>!rowDatesOk(r));
function dropUndatedRows(){
if(UNDATED.length){ for(let i=items.length-1;i>=0;i--){ if(!rowDatesOk(items[i]))items.splice(i,1); } }
document.title = `${project.title} · adom-gantt`;
}
// ── View preferences (theme, milestone style, start-date override) ──
const PREF_KEY = 'adom-gantt:prefs';
// A snapshot opens as its author curated it, so it ignores whatever the viewer happens
// to have in localStorage from another chart. The export flips this to true.
const SNAPSHOT = false;
// Substituted by the server from package.json, so the header cannot drift from
// the released version. Opening this file straight off disk leaves the token in
// place, which is why it is checked before being shown.
const RAW_VERSION = '__APP_VERSION__';
const APP_VERSION = /^[0-9]/.test(RAW_VERSION) ? RAW_VERSION : '';
// project.view is the author's intended default view. It applies everywhere the chart
// is served fresh: the HTML export, the server render, and any browser with no saved
// preference. In the live app a preference the user has actually set still wins.
function viewDefaults(){
const v = (project && project.view) || {};
const d = {};
if (v.grain && ['day','week','month','quarter','year'].includes(v.grain)) d.granularity = v.grain;
if (v.milestones) d.msStyle = /^lines?$/.test(v.milestones) ? 'line' : 'diamond';
if (v.today !== undefined) d.showToday = !!v.today;
if (v.theme === 'light' || v.theme === 'dark') d.theme = v.theme;
if (v.labelWidth) d.labelW = Math.max(120, Math.min(760, +v.labelWidth || 0)) || undefined;
// Which lens the plan opens in. An owner-and-deadline plan can be authored to
// open as the table, the same way a chart can be authored to open in days.
if (v.mode === 'table' || v.mode === 'gantt') d.view = v.mode;
return d;
}
function savedPrefs(){
if (SNAPSHOT) return {};
try { return JSON.parse(localStorage.getItem(PREF_KEY)) || {}; } catch { return {}; }
}
// Seven steps, 85% to 150%. Discrete so the readout is a round number and the
// step buttons have somewhere definite to land.
const FS_STEPS = [0.85, 0.925, 1, 1.1, 1.2, 1.35, 1.5];
const prefs = Object.assign(
{ theme: 'dark', msStyle: 'line', granularity: 'month', showToday: true, view: 'gantt', fs: 1, collapsed: [] },
viewDefaults(),
savedPrefs()
);
// A hand-edited or stale localStorage value must not put the app at 40%.
function clampFontScalePref(){
if (FS_STEPS.indexOf(prefs.fs) < 0) prefs.fs = 1;
}
function savePrefs(){ try { localStorage.setItem(PREF_KEY, JSON.stringify(prefs)); } catch {} }
function applyTheme(){ document.documentElement.setAttribute('data-theme', prefs.theme); }
function applyStartupPrefs(){
applyTheme();
// Both set before the chart is built: the view so a plan that opens as a table
// never measures a hidden timeline, the scale so the first paint is already at
// the size the user chose and nothing visibly resettles. The header buttons do
// not exist yet on either of these first calls.
applyView();
applyFontScale();
}
// ── Collapse a lane ──
//
// View state, kept in prefs beside theme and grain: it is how one person is
// looking at the plan, not a fact about the plan. If it should ever be shared it
// gets promoted to the sidecar, and the UI says so.
const collapsed=new Set((prefs.collapsed||[]).filter(n=>typeof n==='number'));
let transientCollapse=null; // set while a lane is being dragged
function isCollapsed(ph){ return collapsed.has(ph); }
function laneRows(ph){ return items.filter(r=>r.type!=='group'&&r.phase===ph); }
function applyCollapse(){
items.forEach(r=>{
if(r.type==='group'){
const on=isCollapsed(r.phase);
if(r._laneBtn){
r._laneBtn.classList.toggle('collapsed',on);
const n=laneRows(r.phase).length;
r._laneBtn.querySelector('.lane-count').textContent=String(n);
r._laneBtn.title=(on?'Show':'Hide')+' the '+n+' row(s) in this lane. This is your view only, it does not change the plan.';
r._laneBtn.setAttribute('aria-expanded',on?'false':'true');
}
return;
}
const hide=isCollapsed(r.phase);
r._collapsed=hide;
if(r._lDiv)r._lDiv.classList.toggle('row-collapsed',hide);
if(r._bDiv)r._bDiv.classList.toggle('row-collapsed',hide);
// the dashed rule belongs to the milestone, so it goes with it
if(r._msLine)r._msLine.classList.toggle('row-collapsed',hide);
});
}
function setCollapsed(ph,on,persist){
if(on)collapsed.add(ph); else collapsed.delete(ph);
if(persist!==false){ prefs.collapsed=[...collapsed]; savePrefs(); }
applyCollapse();
layoutMsCaps();sizeTailroom();drawDeps();
}
function wireLaneToggle(){
labelsEl.addEventListener('click',e=>{
const btn=e.target.closest('.lane-toggle');
if(!btn)return;
e.stopPropagation();
const g=items.find(r=>r._laneBtn===btn);
if(g)setCollapsed(g.phase,!isCollapsed(g.phase));
});
}
// ── Date range ──
// The timeline is derived from the data every time, so shifting the plan reshapes
// the axis for free. ORIGINAL_START is the untouched data-file start, which is what
// Revert goes back to.
function isoDay(d){ return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; }
function isoMonth(d){ return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}`; }
// Undateable rows are dropped at load, so this should never see junk. It is
// defensive anyway: a throw in here used to blank the entire chart.
function parseISO(ds){ const[y,m,d]=String(ds==null?'':ds).split('-').map(Number); return new Date(y||1970,(m||1)-1,d||1); }
function scanRange(){
let minD='9999-12-31', maxD='0000-01-01';
items.forEach(r=>{ if(r.start&&r.start<minD)minD=r.start; if(r.end&&r.end>maxD)maxD=r.end;
if(r.date&&r.date<minD)minD=r.date; if(r.date&&r.date>maxD)maxD=r.date; });
phases.forEach(p=>{ const ps=p.start+'-01'; const[ey,em]=p.end.split('-').map(Number);
const pe=`${ey}-${String(em).padStart(2,'0')}-28`;
if(ps<minD)minD=ps; if(pe>maxD)maxD=pe; });
return {minD,maxD};
}
// Earliest real task/milestone date, which is what the start control edits.
function planStart(){
let m=null;
items.forEach(r=>{ const d=r.start||r.date; if(d&&(!m||d<m))m=d; });
return m;
}
const ORIGINAL_START = (()=>{ let m=null;
(projData.items||[]).forEach(r=>{ const d=r.start||r.date; if(d&&(!m||d<m))m=d; });
return m; })();
let START=new Date(), UNITS=0; // UNITS = months, or days when granularity is 'day'
function addD(d,n){const x=new Date(d.getFullYear(),d.getMonth(),d.getDate());x.setDate(x.getDate()+n);return x;}
// The axis a given grain would produce, without touching the live one. Fit needs
// to ask "how wide would Days be?" about a grain it has not switched to yet.
function rangeFor(g){
const {minD,maxD}=scanRange();
const a=parseISO(minD), b=parseISO(maxD);
if(g==='day'){ const s=addD(a,-3);
return {start:s, units:Math.max(14, dayDiff(s,b)+8)}; }
if(g==='week'){ const s=new Date(a.getFullYear(),a.getMonth(),a.getDate()); s.setDate(s.getDate()-s.getDay()-7);
return {start:s, units:Math.max(8, Math.ceil(dayDiff(s,b)/7)+2)}; }
if(g==='month'){ const s=new Date(a.getFullYear(),a.getMonth(),1);
return {start:s, units:Math.max(2, mDiff(s,b)+2)}; }
if(g==='quarter'){ const s=new Date(a.getFullYear(),Math.floor(a.getMonth()/3)*3,1);
return {start:s, units:Math.max(2, Math.ceil((mDiff(s,b)+1)/3)+1)}; }
const s=new Date(a.getFullYear(),0,1);
return {start:s, units:Math.max(2, (b.getFullYear()-s.getFullYear())+2)};
}
function computeRange(){ const r=rangeFor(G()); START=r.start; UNITS=r.units; }
// W is the timeline's pixel width and cannot be computed until computeRange() has
// set UNITS, so it is declared here and assigned inside the init step. That
// declare-then-assign shape is the rule for anything order-dependent now: the
// declaration is a binding, the ordering lives in init().
let W = 0;
function initTimeRange(){
computeRange();
W = UNITS*COL;
}
// Fit the timeline to whatever width the pane actually has. Wide window: months
// stretch so the chart fills it. Narrow window or a very long plan: months stop
// at MIN_COL and the timeline scrolls horizontally instead of squashing.
function fitCOL(){
// -1 leaves room for the closing grid line, which sits at x=W and is 1px wide
const avail = (timelineEl.clientWidth || (window.innerWidth - 272)) - 1;
COL = Math.max((GRAINS[G()]||GRAINS.month).min, Math.floor((avail / UNITS) * 100) / 100);
W = UNITS * COL;
}
function mDiff(a,b){return(b.getFullYear()-a.getFullYear())*12+b.getMonth()-a.getMonth();}
function dayDiff(a,b){return Math.round((new Date(b.getFullYear(),b.getMonth(),b.getDate())-new Date(a.getFullYear(),a.getMonth(),a.getDate()))/86400000);}
// Function declarations, not const arrows: computeRange() runs at module level before
// this point in the file, and a const would still be in its temporal dead zone.
function G(){ return GRAINS[prefs.granularity] ? prefs.granularity : 'month'; }
function isDayMode(){ return G()==='day'; }
// Months per unit for the calendar-based grains; day and week are handled separately
// because their length in months is not constant.
const MONTHS_PER = { month:1, quarter:3, year:12 };
function toX(ds){
const d=parseISO(ds), g=G();
if(g==='day') return dayDiff(START,d)*COL;
if(g==='week') return dayDiff(START,d)/7*COL;
const dd=+(ds.split('-')[2]||1);
const mf=mDiff(START,d)+(dd-1)/30;
return mf/MONTHS_PER[g]*COL;
}
function xToDate(x){
const g=G(), u=x/COL;
if(g==='day') return isoDay(addD(START,Math.round(u)));
if(g==='week') return isoDay(addD(START,Math.round(u*7)));
const mf=u*MONTHS_PER[g], mo=Math.floor(mf), df=mf-mo;
const d=new Date(START.getFullYear(),START.getMonth()+mo,1);
const day=Math.max(1,Math.min(28,Math.round(df*30)+1));
return`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(day).padStart(2,'0')}`;
}
// ── Snapping to the visible grid ──
//
// A dragged bar lands on the lines the axis is actually drawing: day boundaries at
// the day grain, week starts at the week grain, month, quarter and year firsts at
// the coarser ones. That is one rule rather than five, because the grid, the axis
// cells and the bar geometry are all the same function of COL: unit i sits at
// exactly i*COL px and at exactly unitDate(i), so a snap is a rounded index and
// nothing else. Rounding is what makes the preview jump as the cursor crosses each
// midpoint rather than at the boundary itself.
function snapIndex(x){ return Math.max(0,Math.min(UNITS,Math.round(x/COL))); }
function snapDateAt(i){ return isoDay(unitDate(i)); }
function snapX(x){ const i=snapIndex(x); return {i, x:i*COL, date:snapDateAt(i)}; }
// The calendar date at the left edge of unit i, and the two header labels for it.
function unitDate(i){
const g=G();
if(g==='day') return addD(START,i);
if(g==='week') return addD(START,i*7);
if(g==='month') return new Date(START.getFullYear(),START.getMonth()+i,1);
if(g==='quarter') return new Date(START.getFullYear(),START.getMonth()+i*3,1);
return new Date(START.getFullYear()+i,0,1);
}
function topLabel(d){
const g=G();
if(g==='day'||g==='week') return `${mNames[d.getMonth()]} ${d.getFullYear()}`;
if(g==='month') return `Q${Math.floor(d.getMonth()/3)+1} ${d.getFullYear()}`;
if(g==='quarter') return `${d.getFullYear()}`;
return `${Math.floor(d.getFullYear()/10)*10}s`;
}
function botLabel(d){
const g=G();
if(g==='day'||g==='week') return String(d.getDate());
if(g==='month') return mNames[d.getMonth()];
if(g==='quarter') return 'Q'+(Math.floor(d.getMonth()/3)+1);
return String(d.getFullYear());
}
// The one date formatter. Every surface a person reads goes through it, so the
// format is decided in one place: 17-Aug-2026, zero-padded day, capitalised
// three-letter month. A year-month value has no day, so it renders Aug 2026.
// Machine formats are deliberately NOT this: the data file, the sidecar, the
// changelog payloads and CSV all stay ISO, because they are interchange.
const MN=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
function fmt(ds){
if(!ds)return '';
const[y,mo,d]=String(ds).split('-').map(Number);
if(!y||!mo)return String(ds);
return d?`${String(d).padStart(2,'0')}-${MN[mo-1]}-${y}`:`${MN[mo-1]} ${y}`;
}
function addDays(ds, n){const[y,mo,d]=ds.split('-').map(Number);const dt=new Date(y,mo-1,d);dt.setDate(dt.getDate()+n);return`${dt.getFullYear()}-${String(dt.getMonth()+1).padStart(2,'0')}-${String(dt.getDate()).padStart(2,'0')}`;}
// ── Header with import/export buttons ──
function buildHeader(){
$('hdr').innerHTML = `
<div class="brand" title="adom-gantt${project.appName?', '+esc(project.appName):''}"><svg class="logo" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="3" y="4" width="18" height="2" rx="1" opacity="0.5"/><rect x="5" y="9" width="12" height="2" rx="1"/><rect x="3" y="14" width="16" height="2" rx="1" opacity="0.7"/><rect x="7" y="19" width="10" height="2" rx="1" opacity="0.5"/></svg>
<span class="app-name">${esc(project.appName||'adom-gantt')}</span></div>
<div class="vsep"></div>
<!-- The lens switch is the primary control, so it sits first in the row, anchored
to the brand block, which never changes width. It used to live inside .actions,
which is right-aligned: every chart-only button that hid in table view made
that group narrower and shoved the toggle sideways between clicks. -->
<div class="view-toggle" id="viewToggle" role="tablist" title="Two lenses over the same plan: the chart, or the owner and deadline table">
<button class="vt-btn" data-view="gantt" role="tab">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="3" y="5" width="13" height="3" rx="1.5"/><rect x="7" y="10.5" width="14" height="3" rx="1.5"/><rect x="5" y="16" width="10" height="3" rx="1.5"/></svg>Gantt</button>
<button class="vt-btn" data-view="table" role="tab">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 9.5h18M3 15h18M10 9.5V20"/></svg>Table</button>
</div>
<div class="vsep"></div>
<!-- One title, one subtitle. The brand used to repeat the project as an
app-name plus a second subtitle, so a single plan was named three times. -->
<div class="subject" title="${esc(project.title)}${project.subtitle?', '+esc(project.subtitle):''}"><span class="subject-name">${esc(project.title)}</span>${project.subtitle?`<span class="subject-sub">${esc(project.subtitle)}</span>`:''}<span class="subject-meta"><span class="sep">·</span><span id="unitCount">${UNITS} months</span>${APP_VERSION?`<span class="sep">·</span><span class="app-ver" title="adom-gantt ${esc(APP_VERSION)}">v${esc(APP_VERSION)}</span>`:''}</span></div>
<div class="start-ctl" title="Project start. Changing it moves the whole plan.">
<label>starts</label>
<button class="date-btn" id="startBtn"></button>
<select id="grainSel" class="grain-sel" title="Timeline cell size">
<option value="day">Days</option><option value="week">Weeks</option>
<option value="month">Months</option><option value="quarter">Quarters</option>
<option value="year">Years</option>
</select>
<button class="act-btn" id="btnFit" title="Fit the whole plan into the pane: the finest cell size that shows every date without scrolling sideways">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 5v14M21 5v14"/><path d="M7 12h10"/><path d="M10 9l-3 3 3 3M14 9l3 3-3 3"/></svg>Fit</button>
</div>
<div class="actions">
<button class="act-btn" id="btnMsStyle" title="Milestone style: diamond or dashed line">
<span id="msStyleIcon"></span><span id="msStyleLabel"></span></button>
<button class="act-btn" id="btnTheme" title="Toggle light and dark"><span id="themeLabel"></span></button>
<button class="act-btn" id="btnImport" title="Import a plan: JSON, CSV, MS Project XML or Jira CSV"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3v12m0 0l-4-4m4 4l4-4M4 17v2a2 2 0 002 2h12a2 2 0 002-2v-2"/></svg><span class="btn-label">Import</span></button>
<button class="act-btn" id="btnExport" title="Export this plan: JSON, CSV, SVG or a self-contained HTML snapshot"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 21V9m0 0l4 4m-4-4l-4 4M4 7V5a2 2 0 012-2h12a2 2 0 012 2v2"/></svg><span class="btn-label">Export</span></button>
<button class="act-btn" id="btnHelp" style="font-family:'Familjen Grotesk',sans-serif;font-size:14px;font-weight:600;padding:4px 9px">?</button>
<button class="new-btn" id="btnNew" title="New task · Shift+N">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>New</button>
<div class="vsep"></div>
<div class="fs-ctl" id="fsCtl">
<button class="fs-btn" id="fsDown" title="Smaller text"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><text x="12" y="17" text-anchor="middle" font-family="Familjen Grotesk,sans-serif" font-size="14" font-weight="600">A</text></svg></button>
<button class="fs-pct" id="fsPct" title="Text size. Click to go back to 100%"></button>
<button class="fs-btn fs-big" id="fsUp" title="Larger text"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><text x="12" y="19" text-anchor="middle" font-family="Familjen Grotesk,sans-serif" font-size="20" font-weight="600">A</text></svg></button>
</div>
</div>`;
applyView(); // again, now that the toggle buttons exist
}
// The brand has three hues, so the ramp cycles them rather than inventing lighter
// or darker fourths. On a dark ground a second teal has to be lighter to be told
// apart, and a pale teal reads as off-brand. Sections are separated by the grey
// banding and the group header instead, so a repeat three rows down is not confusing.
// Returning the CSS var (not a resolved hex) is what lets a theme switch recolour
// every bar without rebuilding the chart.
function phaseColor(i){
const p = phases[i];
return (p && p.color) ? p.color : `var(--ph${(i % 3) + 1})`;
}
// ── State ──
const phaseVis = phases.map(()=>true);
let msVis=true, depsVis=true;
const selected = new Set();
const barEls = {};
let depSvg;
// ── Undo ──
const undoStack = [];
let undoTimer = null;
// The snapshot carries owner, status and description too, so a table edit is one
// Ctrl+Z like every other edit rather than a second class of change.
function snapshotMany(ids){ids.forEach(id=>{const r=items.find(d=>d.id===id);if(r)undoStack.push({type:'edit',id,s:r.start,e:r.end,deps:r.deps?[...r.deps]:[],note:r.note||'',owner:r.owner,status:r.status,desc:r.description,prov:r.provenance,name:r.name});});if(undoStack.length>100)undoStack.splice(0,undoStack.length-100);}
function snapshot(id){snapshotMany([id]);}
// One undo entry covering many rows, for bulk operations like a plan shift.
function snapshotGroup(ids){
const entries=[];
ids.forEach(id=>{const r=items.find(d=>d.id===id);if(r)entries.push({id,s:r.start,e:r.end,deps:r.deps?[...r.deps]:[],note:r.note||''});});
const ms=items.filter(r=>r.type==='milestone');
undoStack.push({type:'group',entries,ms:ms.map(r=>({r,date:r.date})),phases:phases.map(p=>({p,start:p.start,end:p.end}))});
if(undoStack.length>100)undoStack.splice(0,undoStack.length-100);
}
function snapshotOrder(){undoStack.push({type:'order',order:items.map(rowOrderKey)});if(undoStack.length>100)undoStack.splice(0,undoStack.length-100);}
// Legacy label and name keys are still accepted, so an order saved before row
// keys became stable still restores.
function orderKeyMap(){
const m=new Map();
items.forEach(r=>m.set(rowOrderKey(r),r));
items.forEach(r=>{const k=r.label||r.name;if(k&&!m.has(k))m.set(k,r);});
return m;
}
// All or nothing. This used to push whatever resolved and drop the rest, so one
// stale key shrank the plan and the shrunken plan was then saved over the real one.
// Re-append every row in items order. The dashed milestone rules are painted
// above the rows, so they go back on top afterwards or a reorder buries them.
function reflowRows(){
items.forEach((r,i)=>{r._itemIdx=i;labelsEl.appendChild(r._lDiv);bars.appendChild(r._bDiv);if(r.id&&barEls[r.id])barEls[r.id].idx=i;});
items.filter(r=>r.type==='milestone'&&r._msLine).forEach(r=>bars.appendChild(r._msLine));
keepTailLast();
}
function refreshChartGeometry(){
if(prefs.view==='table')return;
layoutMsCaps(); sizeTailroom(); drawDeps();
}
function applyOrder(order){
if(!Array.isArray(order))return false;
const byKey=orderKeyMap();
const next=order.map(k=>byKey.get(k)).filter(Boolean);
if(next.length!==items.length||new Set(next).size!==items.length)return false;
items.length=0;items.push(...next);
reflowRows();
drawDeps();
return true;
}
// Deleting is a data change, not a hide: the row leaves items, its DOM goes, its
// bar entry goes, and every dependency pointing at it goes too, or the arrows,
// the pickers and the exports would all keep naming something that is no longer
// in the plan. Everything needed to put it back is returned.
function removeRow(r,silent){
const idx=items.indexOf(r);
if(idx<0)return null;
const droppedDeps=[];
if(r.id)items.forEach(o=>{
if(o!==r&&Array.isArray(o.deps)&&o.deps.includes(r.id)){
droppedDeps.push({id:o.id,dep:r.id});
o.deps=o.deps.filter(d=>d!==r.id);
}
});
items.splice(idx,1);
if(r._lDiv&&r._lDiv.parentNode)r._lDiv.remove();
if(r._bDiv&&r._bDiv.parentNode)r._bDiv.remove();
if(r._msLine&&r._msLine.parentNode)r._msLine.remove();
if(r.id)delete barEls[r.id];
// A row that only ever existed in the sidecar just goes. Recording it as
// deleted as well would leave an added+deleted pair sitting there for good.
const ai=addedItems.indexOf(r);
if(ai>=0)addedItems.splice(ai,1);
else deletedKeys.add(rowOrderKey(r));
r._wasAdded=ai>=0;
items.forEach((x,i)=>{x._itemIdx=i;if(x.id&&barEls[x.id])barEls[x.id].idx=i;});
if(!silent){keepTailLast();applyFilter();refreshChartGeometry();}
return {idx,droppedDeps};
}
function restoreRow(r,idx,droppedDeps){
if(r._wasAdded){ if(!addedItems.includes(r))addedItems.push(r); }
else deletedKeys.delete(rowOrderKey(r));
items.splice(Math.max(0,Math.min(idx,items.length)),0,r);
(droppedDeps||[]).forEach(d=>{const o=items.find(x=>x.id===d.id);
if(o){o.deps=o.deps||[];if(!o.deps.includes(d.dep))o.deps.push(d.dep);}});
if(r.id&&r._bDiv){const b=r._bDiv.querySelector('.bar[data-id]');if(b)barEls[r.id]={bar:b,idx,row:r._bDiv};}
reflowRows();applyFilter();refreshChartGeometry();
}
// Put a fully-formed item into the plan: give it a key if it has none, build the
// same DOM the data-file rows got, and drop it into its lane.
function insertRow(r,silent){
if(!r._key&&!r.id)r._key=freshKey();
items.push(r);
buildRowDom(r,items.length-1);
if(r.type==='milestone'&&r._msLine)bars.appendChild(r._msLine);
homeRow(r);
if(!silent){applyFilter();refreshChartGeometry();}
return r;
}
// Place a row at the end of its phase's block. Shared by creation and by a phase
// move, so a new row lands exactly where a moved one would.
function homeRow(r){
const gi=items.findIndex(g=>g.type==='group'&&g.phase===r.phase);
if(gi>=0){
let end=gi+1;
while(end<items.length&&items[end].type!=='group')end++;
const cur=items.indexOf(r);
if(cur>=0){items.splice(cur,1);items.splice(cur<end?end-1:end,0,r);}
}
reflowRows();
}
// A group header is a row like any other, so moving a task to another phase has
// to move it under that phase's header. Changing only item.phase would leave it
// sitting visually in the lane it came from, which is worse than not moving it.
function moveRowToPhase(r,np){
r.phase=np;
homeRow(r);
if(r.id&&barEls[r.id])barEls[r.id].bar.style.background=phaseColor(np);
if(r._msLine)r._msLine.style.color=phaseColor(np);
applyFilter();refreshChartGeometry();
}
// ── Moving a row with the keyboard ──
//
// Alt+Up and Alt+Down move one row one slot: the focused row in the table, the
// selected task in the chart. Alt, because the arrows on their own belong to
// whatever the user is typing into, and because nothing else in the app or the
// browser chrome claims that chord here.
// The phase a row's position now implies. The group header above a row IS its
// lane, everywhere else in this app, and a keyboard move is not allowed to be
// the one gesture that breaks that: a row that ends up under another header has
// moved lanes, and its phase, its bar colour and its dashed rule go with it.
function phaseAtPosition(r){
const i=items.indexOf(r);
for(let k=i-1;k>=0;k--) if(items[k].type==='group') return items[k].phase;
return r.phase;
}
function moveItemAdjacent(r,target,after){
const ri=items.indexOf(r); if(ri<0)return false;
items.splice(ri,1);
const ti=items.indexOf(target);
if(ti<0){ items.splice(ri,0,r); return false; } // put it back rather than lose it
items.splice(after?ti+1:ti,0,r);
return true;
}
// Plan order: free movement. Group headers are not slots of their own, so the row
// steps over them, and stepping over one means it has changed lane.
function planNeighbourMove(r,dir){
const idx=items.indexOf(r); if(idx<0)return false;
let j=idx+dir, crossed=false;
while(j>=0&&j<items.length&&items[j].type==='group'){ crossed=true; j+=dir; }
if(j<0||j>=items.length)return false; // the top or the bottom of the plan
// Going down past a header lands at the top of the next lane; going up past one
// lands at the bottom of the previous lane. Otherwise it is a plain swap.
return moveItemAdjacent(r,items[j],(dir>0)!==crossed);
}
// Under a column sort the rows on screen are not the rows in the plan, so a move
// that could go anywhere would either lie about the sort or silently re-sort the
// plan. Movement is bounded to the run of rows the sort considers TIED with this
// one: under an owner sort an Adonis task moves within the Adonis block and
// nowhere else, the visible order stays exactly what the sort says it is, and
// what actually changes is the tie-break, which is plan position, which persists.
function sortedNeighbourMove(r,dir){
const rows=tableRows();
const vi=rows.indexOf(r);
if(vi<0)return 'gone';
const key=sortValueOf(r,sortKey);
let a=vi,b=vi;
while(a>0&&sortValueOf(rows[a-1],sortKey)===key)a--;
while(b<rows.length-1&&sortValueOf(rows[b+1],sortKey)===key)b++;
if(a===b)return 'alone';
const ti=vi+dir;
if(ti<a||ti>b)return 'edge';
// The comparator breaks ties on plan position ascending, in both sort
// directions, so landing after the neighbour is what puts the row after it.
return moveItemAdjacent(r,rows[ti],dir>0)?'ok':'gone';
}
// The chart has one selection model and it is the one this uses. More than one
// row selected is a different gesture (a batch move) and is not this one.
function soleSelectedRow(){
if(selected.size!==1)return null;
const id=[...selected][0];
return items.find(r=>r.id===id)||null;
}
// A held arrow key is one intent, not eight. Undo stays per press, because a
// keystroke is the unit you want back, but the changelog waits for the burst to
// finish and then records the net move as one readable line.
let keyMoveTimer=null;
function scheduleKeyMoveSave(){
clearTimeout(keyMoveTimer);
keyMoveTimer=setTimeout(()=>{keyMoveTimer=null;scheduleSave('key-move');},600);
}
function flushKeyMove(){
if(!keyMoveTimer)return;
clearTimeout(keyMoveTimer);keyMoveTimer=null;
scheduleSave('key-move');
}
function moveFocusedRow(dir){
const table=prefs.view==='table';
const r=table?rowItem(focusedKey):soleSelectedRow();
if(!r){
flash(saveInd, table
? 'click a cell first, then Alt+Up and Alt+Down move that row'
: (selected.size>1?'select one task to move it':'select a task first'));
return;
}
const sorted=table&&!manualOrder();
const fromPhase=r.phase;
const order=items.map(rowOrderKey);
const res=sorted?sortedNeighbourMove(r,dir):(planNeighbourMove(r,dir)?'ok':'edge');
if(res!=='ok'){
if(res==='alone')flash(saveInd,'no other row is tied with this one under this sort');
else if(res==='edge')flash(saveInd,sorted?'the end of the tied group':'the end of the plan');
return;
}
undoStack.push({type:'phase',r,phase:fromPhase,order});
if(undoStack.length>100)undoStack.splice(0,undoStack.length-100);
const np=phaseAtPosition(r);
if(np!==undefined&&np!==r.phase){
r.phase=np;
if(r.id&&barEls[r.id])barEls[r.id].bar.style.background=phaseColor(np);
if(r._msLine)r._msLine.style.color=phaseColor(np);
}
reflowRows();applyFilter();refreshChartGeometry();renderTable();
// A milestone's row key is its position among the milestones, so it can change
// under the row that carries it. Re-read it rather than keep a stale one.
if(table)setFocusedRow(rowKey(r));
moveHint='"'+(r.name||'a row')+'"';
scheduleKeyMoveSave();
}
function undo(){
if(!undoStack.length)return;
const sn=undoStack.pop();
if(sn.type==='order'){
(sn.phases||[]).forEach(pc=>{
pc.r.phase=pc.from;
if(pc.r.id&&barEls[pc.r.id])barEls[pc.r.id].bar.style.background=phaseColor(pc.from);
if(pc.r._msLine)pc.r._msLine.style.color=phaseColor(pc.from);
});
applyOrder(sn.order);
if(sn.phases&&sn.phases.length){applyCollapse();applyFilter();refreshChartGeometry();}
}
else if(sn.type==='delete'){restoreRow(sn.r,sn.idx,sn.droppedDeps);}
else if(sn.type==='create'){removeRow(sn.r);renderTable();}
else if(sn.type==='phase'){
sn.r.phase=sn.phase;
if(sn.r.id&&barEls[sn.r.id])barEls[sn.r.id].bar.style.background=phaseColor(sn.phase);
if(sn.r._msLine)sn.r._msLine.style.color=phaseColor(sn.phase);
applyOrder(sn.order);applyFilter();refreshChartGeometry();
}
else if(sn.type==='ms'){
sn.r.date=sn.date;
if(sn.name!==undefined)sn.r.name=sn.name;
setOpt(sn.r,'owner',sn.owner);setOpt(sn.r,'status',sn.status);setOpt(sn.r,'description',sn.desc);
if('prov' in sn)setProv(sn.r,sn.prov);
refreshMsChrome(sn.r);updateMilestone(sn.r);
}
else if(sn.type==='group'){
// one Ctrl+Z undoes the whole shift, not 26 tasks one at a time
sn.entries.forEach(en=>{const r=items.find(d=>d.id===en.id);if(!r)return;r.start=en.s;r.end=en.e;r.deps=en.deps;r.note=en.note;});
(sn.ms||[]).forEach(m=>{m.r.date=m.date;});
(sn.phases||[]).forEach(x=>{x.p.start=x.start;x.p.end=x.end;});
rebuildTimeline();Object.keys(barEls).forEach(id=>updateBar(id));
}
else{const r=items.find(d=>d.id===sn.id);if(!r)return;r.start=sn.s;r.end=sn.e;r.deps=sn.deps;if(sn.note!==undefined)r.note=sn.note;
if('owner' in sn)setOpt(r,'owner',sn.owner);
if('status' in sn)setOpt(r,'status',sn.status);
if('desc' in sn)setOpt(r,'description',sn.desc);
if('prov' in sn)setProv(r,sn.prov);
if(sn.name!==undefined)r.name=sn.name;
updateBar(sn.id);refreshLabelRow(r);refreshChartGeometry();}
if(prefs.view==='table')renderTable();
clearTimeout(undoTimer);undoToast.classList.add('show');undoTimer=setTimeout(()=>undoToast.classList.remove('show'),1800);scheduleSave('undo');
}
// Is anything at all in progress? A chord that opens a panel must never fire in
// the middle of something else, and "something else" includes any field anywhere,
// not just this app's own editors.
function isTypingTarget(el){
if(!el)return false;
if(el.isContentEditable)return true;
const t=(el.tagName||'').toUpperCase();
return t==='INPUT'||t==='TEXTAREA'||t==='SELECT';
}
function appIsIdle(){
if(tblEditing||labelEditing||rowDrag||laneDrag||dragState||reorderState||gesture)return false;
if(editorEl.classList.contains('show'))return false;
if(datePop&&datePop.classList.contains('show'))return false;
if(isTypingTarget(document.activeElement))return false;
return true;
}
function wireGlobalKeys(){
document.addEventListener('keydown',e=>{
if((e.ctrlKey||e.metaKey)&&e.key==='z'){e.preventDefault();undo();}
// Alt+Up and Alt+Down MOVE a row. Checked first, so the two arrow meanings never
// race: with Alt the plan changes, without it only the selection does.
if(e.altKey&&!e.ctrlKey&&!e.metaKey&&(e.key==='ArrowUp'||e.key==='ArrowDown')){
if(isTypingTarget(document.activeElement))return;
e.preventDefault();
moveFocusedRow(e.key==='ArrowDown'?1:-1);
return;
}
// Plain Up and Down move the SELECTION, the way a spreadsheet does. The
// preventDefault is the point of the feature: the arrows are for choosing a row,
// not for nudging a scrollbar, and the pane only moves when the row you chose is
// off the edge. Only in the table: the chart's selection is a multi-select set
// with its own anchor and modifier rules, and giving it arrows is a different
// feature with different questions, so it keeps the browser's scrolling.
if(!e.altKey&&!e.ctrlKey&&!e.metaKey&&!e.shiftKey&&(e.key==='ArrowUp'||e.key==='ArrowDown')){
if(!tableNavReady())return; // an open editor, a panel or a drag wins
e.preventDefault();
moveRowFocus(e.key==='ArrowDown'?1:-1);
return;
}
// Enter on the focused row edits its name, which is the field you almost always
// want and the one the chart's label click already opens.
if(e.key==='Enter'&&!e.altKey&&!e.ctrlKey&&!e.metaKey&&!e.shiftKey){
if(!tableNavReady())return;
const tr=focusedRowEl();
const td=tr&&tr.querySelector('td.c-name.editable');
if(!td)return;
e.preventDefault();
startCellEdit(td);
return;
}
// Shift+N opens the create panel, and only when the app is genuinely idle: a
// capital N typed into a task name is a capital N and nothing else.
if(e.shiftKey&&!e.ctrlKey&&!e.metaKey&&!e.altKey&&(e.key==='N'||e.key==='n')){
if(!appIsIdle())return;
e.preventDefault();closeMenus();openCreateEditor();
return;
}
if(e.key!=='Escape')return;
// Innermost first. A native select or datalist popup swallows its own Escape
// before this ever runs, which is exactly the first rung of the stack.
if(reorderState&&reorderState.active){
reorderState.moveIds.forEach(mid=>{const r=items.find(d=>d.id===mid);
if(r){r._lDiv.classList.remove('dragging');r._bDiv.classList.remove('dragging');}});
hideDropIndicators();document.body.style.cursor='';reorderState=null;gesture=null;didDrag=true;
return;
}
if(rowDrag||laneDrag){ endRowDrag(false); endLaneDrag(false); return; }
if(tblEditing){ tblEditing.cancel(); return; }
if(editorEl.classList.contains('show')){ closeEditor(); return; }
// The row focus is the outermost thing the table owns, so it goes after the
// field and after any panel, and before the chart's own selection.
if(focusedKey){ setFocusedRow(null); return; }
clearSelection(); closeMenus();
});
}
// ── Persistence ──
let saveTimer=null;
// Milestones carry no id, so an array of tasks alone loses their dates on reload and
// they snap back to the data file while the tasks stay shifted. Persist both.
// An empty value means "no value", not the empty string, so clearing an owner in
// the table leaves the item as clean as it was in the data file.
function setOpt(r,k,v){ if(v===undefined||v===null||v==='')delete r[k]; else r[k]=v; }
// provenance is an object or a string, so it needs its own setter rather than
// setOpt's empty-means-absent rule applied to a truthy object.
function setProv(r,v){ if(v===undefined||v===null||v===''||(typeof v==='object'&&!v.label&&!v.url))delete r.provenance; else r.provenance=v; }
// v3 adds owner, status and description. The fields are additive: a v2 sidecar
// written before the table view existed still loads, and a v2 reader ignores
// what it does not know.
function getState(){
return {
v: 3,
tasks: items.filter(r=>r.id).map(r=>({id:r.id,s:r.start,e:r.end,deps:r.deps||[],note:r.note||'',owner:r.owner||'',status:r.status||'',desc:r.description||'',prov:provValue(r),name:r.name,phase:r.phase})),
milestones: items.filter(r=>r.type==='milestone').map((r,i)=>({i,key:msKey(r),name:r.name,date:r.date,owner:r.owner||'',status:r.status||'',desc:r.description||'',prov:provValue(r),phase:r.phase})),
phases: phases.map((p,i)=>({i,start:p.start,end:p.end})),
order: items.map(rowOrderKey),
deleted: [...deletedKeys],
added: addedItems.map(r=>{const o={};
['type','id','name','start','end','date','phase','owner','status','description','deps','note','striped','provenance'].forEach(k=>{if(r[k]!==undefined)o[k]=r[k];});
o._key=r._key||rowOrderKey(r); return o;}),
};
}
// A milestone entry carries a stable key, an index and a name, and all but the
// key can go stale: a reorder moves the index, a rename moves the name. Resolve
// down a ladder and claim each milestone at most once.
//
// 1. the stable row key (survives both)
// 2. exactly one unclaimed milestone with this name (survives a reorder)
// 3. the milestone at this index, if unclaimed (survives a rename)
// 4. the first unclaimed milestone with this name (duplicate names)
//
// The old code was `find(name) || ms[i]`: find always won and always returned the
// FIRST match, so an edit to the second of two same-named milestones landed on
// the first and was lost, and a rename resolved to nothing at all.
function resolveMs(ms,sv,claimed){
if(sv.key){ const k=ms.findIndex(m=>msKey(m)===sv.key); if(k>=0&&!claimed.has(k))return k; }
const byName=[];
ms.forEach((m,i)=>{ if(!claimed.has(i)&&m.name===sv.name)byName.push(i); });
if(byName.length===1)return byName[0];
if(typeof sv.i==='number'&&ms[sv.i]&&!claimed.has(sv.i))return sv.i;
if(byName.length)return byName[0];
return -1;
}
function applyState(state){
if(!state)return;
const tasks = Array.isArray(state) ? state : state.tasks; // v1 was a bare task array
(tasks||[]).forEach(sv=>{
// An entry with no id matched the first id-less row, which is a group header,
// and wrote task dates and an owner onto it, dragging the whole axis with it.
if(!sv||!sv.id)return;
const r=items.find(d=>d.id===sv.id);if(!r)return;
if(sv.s)r.start=sv.s;if(sv.e)r.end=sv.e;if(sv.deps)r.deps=sv.deps;if(sv.note!==undefined)r.note=sv.note;
if(sv.owner!==undefined)setOpt(r,'owner',sv.owner);
if(sv.status!==undefined)setOpt(r,'status',sv.status);
if(sv.desc!==undefined)setOpt(r,'description',sv.desc);
if(sv.prov!==undefined)setProv(r,sv.prov);
if(sv.name)r.name=sv.name;
if(typeof sv.phase==='number'&&phases[sv.phase])r.phase=sv.phase;
});
if(Array.isArray(state))return;
const ms=items.filter(r=>r.type==='milestone');
const claimed=new Set();
(state.milestones||[]).forEach(sv=>{
if(!sv)return;
const i=resolveMs(ms,sv,claimed);
if(i<0)return;
claimed.add(i);
const r=ms[i];
if(sv.date)r.date=sv.date;
// getState has always written the name; nothing ever read it back, so a
// rename made in the milestone editor did not survive a reload.
if(sv.name)r.name=sv.name;
if(sv.owner!==undefined)setOpt(r,'owner',sv.owner);
if(sv.status!==undefined)setOpt(r,'status',sv.status);
if(sv.desc!==undefined)setOpt(r,'description',sv.desc);
if(sv.prov!==undefined)setProv(r,sv.prov);
if(typeof sv.phase==='number'&&phases[sv.phase])r.phase=sv.phase;
});
(state.phases||[]).forEach(sv=>{ const p=phases[sv.i]; if(p){ if(sv.start)p.start=sv.start; if(sv.end)p.end=sv.end; } });
// Created rows first: the overrides, the deletions and the order all reference
// them, so they have to exist before any of those are applied.
if(Array.isArray(state.added)){
state.added.forEach(a=>{
if(!a||typeof a!=='object')return;
const k=a._key||a.id;
if(k&&items.some(r=>rowOrderKey(r)===k))return;
const r=Object.assign({},a);
if(a._key)r._key=a._key;
addedItems.push(r);
insertRow(r,true);
});
}
// Before the order, always: the saved order was written after these rows were
// removed, so applying it first would compare against the wrong length and be
// rejected by the anti-shrink guard.
if(Array.isArray(state.deleted)){
state.deleted.forEach(k=>{ const r=items.find(x=>rowOrderKey(x)===k); if(r)removeRow(r,true); });
}
if(Array.isArray(state.order)&&state.order.length===items.length)applyOrder(state.order);
}
// ── Changelog ──
//
// Rather than instrument each edit path by hand (which drifts the moment a new
// one is added), we snapshot the semantic state and diff it at every save. One
// hook, and it cannot disagree with the data. `via` just labels how the change
// was made, for readability in the log.
// Group labels often carry a trailing date range, e.g. "Phase 1 - Design (Jan-Apr 2026)".
// The axis already states the dates, and the text goes stale the moment the plan is
// shifted, so drop it. Narrow on purpose: it only strips a parenthetical that starts
// with a month abbreviation and carries a 4-digit year, so "(EVT)" or "(Year 1-2)" stay.
const GROUP_DATE_RE = /\s*\((?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[^)]*\d{4}[^)]*\)\s*$/;
function groupLabel(t){ return (t||'').replace(GROUP_DATE_RE,''); }
// Set by a drag just before it saves, so the reorder event can say what actually
// moved instead of listing keys. Consumed once.
let moveHint=null;
function nameOf(id){const r=items.find(d=>d.id===id);return r?r.name:id;}
function phaseNameAt(i){const p=phases[i];return p?p.name:String(i);}
function planStartOf(snap){let m=null;Object.values(snap.byId).forEach(v=>{if(v.start&&(!m||v.start<m))m=v.start;});return m;}
function snapSemantic(){
const byId={};
items.forEach(r=>{if(r.id)byId[r.id]={start:r.start,end:r.end,deps:(r.deps||[]).slice(),note:r.note||'',owner:r.owner||'',status:statusOf(r),desc:r.description||'',prov:provSig(r),provL:provLabel(r),name:r.name||'',phase:r.phase};});
// Keyed by the stable row key, not by the name. Keying by name made a rename
// look like one milestone vanishing and another appearing, so it logged nothing
// (both sides early-return on an unseen name) and instead surfaced as a phantom
// "reorder". It also made one of any same-named pair invisible to the diff.
const ms={};items.filter(r=>r.type==='milestone').forEach(r=>{ms[msKey(r)]={name:r.name,date:r.date,owner:r.owner||'',status:r.status||'',desc:r.description||'',prov:provSig(r),provL:provLabel(r),phase:r.phase};});
return {byId,ms,order:items.map(rowOrderKey)};
}
// The baseline the changelog diffs against. It has to be taken after the plan is
// loaded and normalised, which is an ordering fact, so it is an init step rather
// than a declaration initialiser.
let lastSnap=null;
function captureBaselineSnapshot(){
lastSnap=snapSemantic();
}
function diffAndLog(via){
const cur=snapSemantic(),evs=[];
Object.keys(cur.byId).forEach(id=>{
const a=lastSnap.byId[id],b=cur.byId[id];if(!a)return;
if(a.start!==b.start||a.end!==b.end){
evs.push({action:'dates',id,name:nameOf(id),
from:{start:a.start,end:a.end},to:{start:b.start,end:b.end},
shift:{start:daysBetween(a.start,b.start),end:daysBetween(a.end,b.end)}});
}
if((a.note||'')!==(b.note||'')) evs.push({action:'note',id,name:nameOf(id),from:{note:a.note||''},to:{note:b.note||''}});
if((a.owner||'')!==(b.owner||'')) evs.push({action:'owner',id,name:nameOf(id),from:{owner:a.owner||''},to:{owner:b.owner||''}});
if((a.status||'')!==(b.status||'')) evs.push({action:'status',id,name:nameOf(id),from:{status:statusTextFor(a.status)},to:{status:statusTextFor(b.status)}});
if((a.desc||'')!==(b.desc||'')) evs.push({action:'desc',id,name:nameOf(id),from:{desc:a.desc||''},to:{desc:b.desc||''}});
if((a.prov||'')!==(b.prov||'')) evs.push({action:'provenance',id,name:nameOf(id),from:{label:a.provL||''},to:{label:b.provL||''}});
if((a.name||'')!==(b.name||'')) evs.push({action:'rename',id,name:b.name||'',from:{name:a.name||''},to:{name:b.name||''}});
if(a.phase!==b.phase) evs.push({action:'phase',id,name:nameOf(id),from:{phase:phaseNameAt(a.phase)},to:{phase:phaseNameAt(b.phase)}});
b.deps.filter(d=>!a.deps.includes(d)).forEach(d=>evs.push({action:'dep.add',id,name:nameOf(id),dep:d,depName:nameOf(d)}));
a.deps.filter(d=>!b.deps.includes(d)).forEach(d=>evs.push({action:'dep.remove',id,name:nameOf(id),dep:d,depName:nameOf(d)}));
});
// A row that was in the last snapshot and is not in this one was deleted, and
// the reverse is an undo putting it back. The field-by-field loop above cannot
// see either, because it only walks rows that exist in both.
Object.keys(lastSnap.byId).forEach(id=>{
if(cur.byId[id])return;
evs.push({action:'delete',id,name:lastSnap.byId[id].name||id});
});
Object.keys(cur.byId).forEach(id=>{
if(lastSnap.byId[id])return;
const r=items.find(x=>x.id===id);
const born=r&&addedItems.includes(r);
evs.push({action:born?'add':'restore',id,name:cur.byId[id].name||id,
kind:born?(r.type==='milestone'?'milestone':'task'):undefined});
});
// A plan shift moves every task by the same delta. Logging 26 identical rows
// buries the one fact a reader wants, so collapse it into a single event.
const dateEvs=evs.filter(e=>e.action==='dates');
if(dateEvs.length>3){
const d0=dateEvs[0].shift;
if(dateEvs.every(e=>e.shift.start===d0.start&&e.shift.end===d0.end)){
const kept=evs.filter(e=>e.action!=='dates');
evs.length=0;
evs.push({action:'plan.shift',count:dateEvs.length,shift:d0,
from:{start:planStartOf(lastSnap)},to:{start:planStart()}},...kept);
}
}
// milestones: keyed by the stable row key, since they carry no id
const curMs=cur.ms,oldMs=lastSnap.ms||{};
Object.keys(oldMs).forEach(k=>{ if(!curMs[k])evs.push({action:'delete',ms:true,key:k,name:oldMs[k].name||k}); });
Object.keys(curMs).forEach(k=>{ if(!oldMs[k]){
const r=items.find(x=>x.type==='milestone'&&msKey(x)===k);
const born=r&&addedItems.includes(r);
evs.push({action:born?'add':'restore',ms:true,key:k,name:curMs[k].name||k,kind:born?'milestone':undefined});
}});
Object.keys(curMs).forEach(k=>{
const a=oldMs[k],b=curMs[k];
if(!a)return;
const nm=b.name;
if(a.name!==b.name)
evs.push({action:'milestone.rename',ms:true,key:k,name:nm,from:{name:a.name},to:{name:b.name}});
if(a.date!==b.date&&a.date&&b.date)
evs.push({action:'milestone',ms:true,key:k,name:nm,from:{date:a.date},to:{date:b.date},
shift:{days:daysBetween(a.date,b.date)}});
if((a.owner||'')!==(b.owner||'')) evs.push({action:'owner',ms:true,key:k,name:nm,from:{owner:a.owner||''},to:{owner:b.owner||''}});
if((a.status||'')!==(b.status||'')) evs.push({action:'status',ms:true,key:k,name:nm,from:{status:a.status||''},to:{status:b.status||''}});
if((a.desc||'')!==(b.desc||'')) evs.push({action:'desc',ms:true,key:k,name:nm,from:{desc:a.desc||''},to:{desc:b.desc||''}});
if((a.prov||'')!==(b.prov||'')) evs.push({action:'provenance',ms:true,key:k,name:nm,from:{label:a.provL||''},to:{label:b.provL||''}});
if(a.phase!==b.phase) evs.push({action:'phase',ms:true,key:k,name:nm,from:{phase:phaseNameAt(a.phase)},to:{phase:phaseNameAt(b.phase)}});
});
const before=lastSnap.order,after=cur.order;
// Only a real reorder. Adding or removing a row changes this list, and so does
// moving one to another lane, and both of those already say so themselves.
const explained=evs.some(e=>e.action==='phase');
if(!explained&&before.length===after.length&&JSON.stringify(before)!==JSON.stringify(after)){
const moved=after.filter((v,i)=>before[i]!==v);
evs.push({action:'reorder',order:after,moved:moved.slice(0,8),hint:moveHint||undefined});
}
lastSnap=cur;
moveHint=null;
if(!evs.length)return;
if(SNAPSHOT)return; // a snapshot has no server to record against
fetch('log',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({actor:'user',events:evs.map(e=>({...e,via}))})})
.catch(()=>{});
}
function scheduleSave(via){diffAndLog(via||'edit');clearTimeout(saveTimer);saveTimer=setTimeout(()=>{saveTimer=null;saveToFile();},800);}
// The changelog entry goes out immediately but the sidecar write waits 800ms, so
// a tab closed inside that window left the log claiming an edit the sidecar never
// received. Anything still pending goes out synchronously when the page is being
// put away. sendBeacon is the only send guaranteed to outlive the document;
// keepalive fetch is the fallback.
function flushSave(){
// A burst of Alt+Arrow presses holds its changelog line back for 600ms. If the
// tab is put away inside that window the line still has to go, or the log and
// the sidecar would disagree about a move that definitely happened.
flushKeyMove();
if(!saveTimer)return;
clearTimeout(saveTimer);saveTimer=null;
if(SNAPSHOT)return;
const body=JSON.stringify(getState());
try{
if(navigator.sendBeacon&&navigator.sendBeacon('save',new Blob([body],{type:'application/json'})))return;
}catch(e){}
try{fetch('save',{method:'POST',headers:{'Content-Type':'application/json'},body,keepalive:true});}catch(e){}
}
function wireSaveFlush(){
window.addEventListener('pagehide',flushSave);
// pagehide does not fire when a mobile browser backgrounds the tab and later
// discards it, so the hidden transition flushes too.
document.addEventListener('visibilitychange',()=>{if(document.visibilityState==='hidden')flushSave();});
}
// A snapshot is a file, not a server. The export used to try to rewrite this
// function with a regex that could never match its own source; the flag it
// already sets is enough.
function saveToFile(){if(SNAPSHOT){flash(saveInd,'snapshot mode');return;}fetch('save',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(getState())}).then(r=>r.json()).then(()=>{flash(saveInd,'saved');}).catch(()=>{flash(saveInd,'save failed',2500);});}
function flash(el,text,ms){el.textContent=text;el.classList.add('show');setTimeout(()=>el.classList.remove('show'),ms||1500);}
// ── Legend ──
const legendEl = $('legend');
function buildLegendPhases(){
phases.forEach((ph,i)=>{const el=document.createElement('div');el.className='legend-item phase-chip';el.title=ph.name;el.innerHTML=`<div class="legend-dot" style="background:${esc(phaseColor(i))}"></div><span class="chip-label">${esc(ph.name)}</span>`;el.addEventListener('click',()=>{phaseVis[i]=!phaseVis[i];el.classList.toggle('off',!phaseVis[i]);applyFilter();});legendEl.appendChild(el);});
}
const msL=document.createElement('div');
function buildLegendMilestone(){
msL.className='legend-item';msL.innerHTML='<div class="legend-diamond"></div>Milestone';msL.addEventListener('click',()=>{msVis=!msVis;msL.classList.toggle('off',!msVis);applyFilter();});legendEl.appendChild(msL);
}
const depL=document.createElement('div');
function buildLegendDeps(){
depL.className='legend-item gantt-only';depL.innerHTML='<svg width="14" height="10" style="flex-shrink:0"><path d="M0,5C4,5 10,5 14,5" stroke="var(--accent)" stroke-width="1.5" fill="none" opacity="0.6"/><polygon points="14,5 10,3 10,7" fill="var(--accent)" opacity="0.6"/></svg>Dependencies';
depL.addEventListener('click',()=>{depsVis=!depsVis;depL.classList.toggle('off',!depsVis);depSvg.classList.toggle('hidden',!depsVis);});legendEl.appendChild(depL);
}
// Today toggles from the legend like Milestone and Dependencies, rather than adding
// separate chrome: the legend is already where this app hides and shows chart elements.
const todayL=document.createElement('div');
function buildLegendToday(){
todayL.className='legend-item gantt-only';
todayL.innerHTML='<div class="legend-line"></div>Today';
refreshTodayTip(todayL);
todayL.addEventListener('mouseover',()=>refreshTodayTip(todayL));
todayL.addEventListener('click',()=>{
prefs.showToday=!prefs.showToday;savePrefs();
todayL.classList.toggle('off',!prefs.showToday);
positionToday();
});
legendEl.appendChild(todayL);
// The table's filters join the legend line rather than owning a second band.
// Done here, after the chips, so they sit to the right of them.
legendEl.appendChild($('tableTools'));
}
// ── Build chart ──
function fitTimelineToPane(){
fitCOL();
}
const gridEls=[], msLineEls=[]; let todayLineEl=null, todayTagEl=null;
function mountQHeaderSpacer(){
labelsEl.appendChild(Object.assign(document.createElement('div'),{className:'label-h-q'}));
}
const qRow=document.createElement('div');
function mountQRow(){
qRow.className='q-row';
labelsEl.appendChild(Object.assign(document.createElement('div'),{className:'label-h-m'}));
}
const mRow=document.createElement('div');
function mountMRow(){
mRow.className='m-row';
}
const mNames=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
let qs=[];
const bars=document.createElement('div');
function sizeBarsPane(){
bars.style.cssText=`position:relative;width:${W}px;`;
}
// The grid lives in its own layer so changing the start date can rebuild it without
// disturbing the rows (and their bound handlers). Phases are carried by the bar colour
// and the group header, so there is no background band.
const gridLayer=document.createElement('div');
function mountGridLayer(){
gridLayer.style.cssText='position:absolute;inset:0;pointer-events:none;';bars.appendChild(gridLayer);
}
// Quarter and month header cells, rebuilt whenever START or UNITS changes.
function buildTimeHeaders(){
qRow.innerHTML='';mRow.innerHTML='';qs=[];
const g=G();
let cq=null;
for(let i=0;i<UNITS;i++){
const l=topLabel(unitDate(i));
if(!cq||cq.l!==l){if(cq)qs.push(cq);cq={l,n:1};}else cq.n++;
}
if(cq)qs.push(cq);
qRow.style.width=W+'px';
qs.forEach(q=>{const c=document.createElement('div');c.className='q-cell';c.style.width=(q.n*COL)+'px';c.textContent=q.l;qRow.appendChild(c);});
mRow.style.width=W+'px';
for(let i=0;i<UNITS;i++){
const d=unitDate(i);
const c=document.createElement('div');c.className='m-cell';c.style.width=COL+'px';
if(g==='day'&&(d.getDay()===0||d.getDay()===6))c.classList.add('weekend');
c.textContent=botLabel(d);
mRow.appendChild(c);
}
}
function buildGridLines(){
gridLayer.innerHTML='';gridEls.length=0;
const g=G();
if(g==='day'){
// a rule per day is noise: month boundaries only, with weekends shaded
for(let i=0;i<=UNITS;i++){
const d=addD(START,i);
if(d.getDate()===1||i===0||i===UNITS){
const l=document.createElement('div');
l.style.cssText=`position:absolute;top:0;bottom:0;left:${i*COL}px;width:1px;background:var(--grid-line);pointer-events:none;`;
gridLayer.appendChild(l);gridEls.push(l);
} else if(d.getDay()===0||d.getDay()===6){
const l=document.createElement('div');
l.style.cssText=`position:absolute;top:0;bottom:0;left:${i*COL}px;width:${COL}px;background:var(--weekend);pointer-events:none;`;
gridLayer.appendChild(l);gridEls.push(l);
}
}
return;
}
for(let i=0;i<=UNITS;i++){
const l=document.createElement('div');
l.style.cssText=`position:absolute;top:0;bottom:0;left:${i*COL}px;width:1px;background:var(--grid-line);pointer-events:none;`;
gridLayer.appendChild(l);gridEls.push(l);
}
}
function buildTimeAxis(){
buildTimeHeaders();buildGridLines();
depSvg=document.createElementNS('http://www.w3.org/2000/svg','svg');depSvg.classList.add('dep-svg');depSvg.setAttribute('width',W);depSvg.setAttribute('height','1');bars.appendChild(depSvg);
}
// The row's own way into the full editor. A single click on the label edits the
// name (which the user wanted kept), so the modal needed a target of its own
// rather than a double-click nobody could reach past the instant editor.
// Group rows keep their chevron and get no Edit button: a lane header's only
// field is its name, and that is the phase, which is a different thing to edit.
function makeEditBtn(r){
const b=document.createElement('button');
b.type='button';b.className='row-edit';b.title='Edit all fields';
b.setAttribute('aria-label','Edit all fields');
b.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 20h4L19 9a2.1 2.1 0 0 0-3-3L5 17v3z"/></svg>';
b.addEventListener('mousedown',e=>e.stopPropagation());
b.addEventListener('dblclick',e=>e.stopPropagation());
b.addEventListener('click',e=>{e.stopPropagation();e.preventDefault();hideTip();openItemModal(r);});
return b;
}
// Extracted from the initial build loop so a row created at runtime is built by
// exactly the same code as a row from the data file, rather than a second
// almost-identical path that drifts.
function buildRowDom(r,idx){
const lDiv=document.createElement('div'),bDiv=document.createElement('div');
if(r.type==='group'){
lDiv.className='label-row group';
const nm=document.createElement('span');nm.className='lane-name';nm.textContent=groupLabel(r.label);
const btn=document.createElement('button');btn.className='lane-toggle';btn.type='button';
btn.innerHTML='<span class="lane-count"></span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 9l6 6 6-6"/></svg>';
lDiv.appendChild(nm);lDiv.appendChild(btn);
r._laneBtn=btn;
bDiv.className='bar-row group';
}
else if(r.type==='milestone'){
lDiv.className='label-row ms';
lDiv.appendChild(Object.assign(document.createElement('span'),{className:'row-name'}));
lDiv.appendChild(makeEditBtn(r));
r._nameSpan=lDiv.querySelector('.row-name');
r._nameSpan.textContent=r.name;
bDiv.className='bar-row ms-row';
const x=toX(r.date);
const hit=document.createElement('div');hit.className='ms-hit';hit.style.left=x+'px';
hit.dataset.msi=String(items.filter(q=>q.type==='milestone').indexOf(r));
hit.dataset.title=r.name;hit.dataset.dates=fmt(r.date);hit.dataset.body=r.description||'';hit.dataset.prov=provLabel(r);
const d=document.createElement('div');d.className='ms-diamond';d.style.left=x+'px';
bDiv.appendChild(hit);bDiv.appendChild(d);
// The dashed rule lives on the chart container, not the row, so it can span
// the full height. Colour follows the milestone's phase.
const ln=document.createElement('div');ln.className='ms-line';ln.style.left=x+'px';
ln.style.color=phaseColor(r.phase);
const cap=document.createElement('div');cap.className='ms-cap';cap.textContent=r.name.replace(/^M(\d+):\s*/,'M$1 ');
cap.dataset.msi=String(items.filter(q=>q.type==='milestone').indexOf(r));
cap.dataset.title=r.name;cap.dataset.dates=fmt(r.date);cap.dataset.body=r.description||'';cap.dataset.prov=provLabel(r);
ln.appendChild(cap);msLineEls.push(ln);
r._msEl=d;r._msHit=hit;r._msLine=ln;
}
else{lDiv.className='label-row';lDiv.dataset.id=r.id;
lDiv.appendChild(Object.assign(document.createElement('span'),{className:'row-name'}));
lDiv.appendChild(makeEditBtn(r));
r._nameSpan=lDiv.querySelector('.row-name');
const sk=statusOf(r);const tg=sk?`<span class="tag ${statusTagFor(sk)}">${esc(statusTextFor(sk))}</span>`:'';
r._nameSpan.innerHTML=esc(r.name)+tg;
bDiv.className='bar-row';const x1=toX(r.start),x2=toX(r.end),b=document.createElement('div');b.className='bar'+(r.striped?' bar-striped':'');b.style.left=x1+'px';b.style.width=Math.max(x2-x1,6)+'px';b.style.background=phaseColor(r.phase);b.dataset.title=r.name;b.dataset.dates=fmt(r.start)+' → '+fmt(r.end);b.dataset.body=r.description||'';b.dataset.prov=provLabel(r);b.dataset.id=r.id;b.dataset.idx=idx;
const nt=document.createElement('div');nt.className='bar-note';nt.textContent=r.note||'';b.appendChild(nt);r._noteEl=nt;
b.appendChild(Object.assign(document.createElement('div'),{className:'drag-handle left'}));b.appendChild(Object.assign(document.createElement('div'),{className:'drag-handle right'}));bDiv.appendChild(b);barEls[r.id]={bar:b,idx,row:bDiv};}
r._lDiv=lDiv;r._bDiv=bDiv;r._itemIdx=idx;labelsEl.appendChild(lDiv);bars.appendChild(bDiv);
}
function buildRows(){
items.forEach((r,idx)=>buildRowDom(r,idx));
// Appended after the rows so the dashed rules sit above the phase bands.
msLineEls.forEach(ln=>bars.appendChild(ln));
applyCollapse();
document.documentElement.setAttribute('data-ms', prefs.msStyle);
if(prefs.showToday===false)todayL.classList.add('off');
}
const today=new Date();
function todayX(){return toX(isoDay(today));}
// Uses the app's own tooltip, which renders through textContent nodes.
function refreshTodayTip(el){ if(!el)return; el.dataset.title='Today'; el.dataset.dates=fmt(isoDay(new Date())); }
function positionToday(){
const tx=todayX(), on=prefs.showToday!==false && tx>0 && tx<W;
if(todayLineEl){todayLineEl.style.left=tx+'px';todayLineEl.style.display=on?'':'none';}
if(todayTagEl){todayTagEl.style.left=tx+'px';todayTagEl.style.display=on?'':'none';}
}
function mountTodayLine(){
{const tl=document.createElement('div');tl.className='today-line';bars.appendChild(tl);todayLineEl=tl;
const tt=document.createElement('div');tt.className='today-tag';tt.textContent='today';bars.appendChild(tt);todayTagEl=tt;
// Recomputed on hover, not at build: a plan left open overnight would otherwise
// still say yesterday. The marker's own position is a separate question and
// still comes from the load-time date, as it always has.
refreshTodayTip(tt);
tt.addEventListener('mouseover',()=>refreshTodayTip(tt));
positionToday();}
timelineEl.appendChild(qRow);timelineEl.appendChild(mRow);timelineEl.appendChild(bars);
}
const barTail=document.createElement('div');
function mountBarTailroom(){
barTail.className='tailroom';timelineEl.appendChild(barTail);
}
const labelTail=document.createElement('div');
function mountLabelTailroom(){
labelTail.className='tailroom';labelsEl.appendChild(labelTail);
timelineEl.addEventListener('scroll',()=>{labelsEl.scrollTop=timelineEl.scrollTop;});
}
// ── Responsive relayout ──
// Everything on the timeline is positioned in px derived from COL, so a resize
// means recomputing COL and repositioning, not rebuilding (handlers stay bound).
function relayout(){
fitCOL();
buildTimeHeaders();
buildGridLines();
bars.style.width=W+'px';
if(depSvg)depSvg.setAttribute('width',W);
items.forEach(r=>{
if(r.type==='milestone'&&r._msEl){
const x=toX(r.date);
r._msEl.style.left=x+'px';
if(r._msHit){r._msHit.style.left=x+'px';r._msHit.dataset.dates=fmt(r.date);}
if(r._msLine){
r._msLine.style.left=x+'px';
const cap=r._msLine.querySelector('.ms-cap');
if(cap){cap.dataset.dates=fmt(r.date);cap.dataset.title=r.name;cap.dataset.body=r.description||'';}
}
}
else if(r.id&&barEls[r.id]){const b=barEls[r.id].bar,x1=toX(r.start),x2=toX(r.end);b.style.left=x1+'px';b.style.width=Math.max(x2-x1,6)+'px';}
});
positionToday();
layoutMsCaps();
sizeTailroom();
drawDeps();
}
// Room to scroll past the last row, so the bottom of the plan can sit centred in
// the pane instead of being pinned to the bottom edge. Spacers rather than padding
// on the chart container, or the today line and the dashed milestone rules would
// stretch down through the empty tail. Both columns get the same height or they
// drift apart as you scroll.
// Re-appending the rows (a reorder, or applyOrder on load) moves them AFTER the
// label column's tail spacer, which then pushes every label down and detaches the
// labels from their bars. Put the spacer back at the end whenever rows move.
// Wrapping narrows a long milestone title but cannot stop two adjacent ones from
// colliding, so caps that still overlap drop to the next row. Measured left-to-right
// in date order, each cap takes the first row it fits on.
function layoutMsCaps(){
if(prefs.msStyle!=='line')return;
const caps=[];
items.filter(r=>r.type==='milestone'&&r._msLine&&!r._collapsed).forEach(r=>{
const cap=r._msLine.querySelector('.ms-cap');
if(cap){cap.style.top='2px';caps.push({cap,x:toX(r.date)});}
});
caps.sort((a,b)=>a.x-b.x);
const GAP=6;
// Occupied spans per row, not just a right edge, so a cap can sit in a gap to the
// LEFT of something already placed. Row 0 starts pre-occupied by the today tag,
// which shares this band and would otherwise be overlapped.
const rows=[[]];
if(todayTagEl&&todayTagEl.style.display!=='none'&&prefs.showToday!==false){
const tw=todayTagEl.offsetWidth||34, tx=todayX();
rows[0].push([tx-tw/2-GAP, tx+tw/2+GAP]);
}
const fits=(row,l,r)=>row.every(([a,b])=>r<a||l>b);
// Uniform row pitch set by the tallest tag: a wrapped (two-line) tag on an upper
// row can never be ridden into by a shorter tag on the row below.
let rowH=14;
caps.forEach(c=>{rowH=Math.max(rowH,c.cap.offsetHeight||14);});
rowH+=3;
caps.forEach(c=>{
const w=c.cap.offsetWidth||80;
const l=c.x-w/2-GAP, r=c.x+w/2+GAP;
let n=0;
while(true){
if(!rows[n])rows[n]=[];
if(fits(rows[n],l,r))break;
n++;
}
rows[n].push([l,r]);
c.cap.style.top=(2+n*rowH)+'px';
});
}
function keepTailLast(){
if(typeof labelTail!=='undefined'&&labelTail&&labelTail.parentNode) labelsEl.appendChild(labelTail);
if(typeof barTail!=='undefined'&&barTail&&barTail.parentNode) timelineEl.appendChild(barTail);
}
function sizeTailroom(){
const pane=timelineEl.clientHeight||0;
const vis=items.filter(r=>r._bDiv&&!r._collapsed);
const lastRow=vis.length?vis[vis.length-1]._bDiv:null;
const rowH=lastRow?lastRow.offsetHeight:34;
const pad=Math.max(0,Math.round(pane/2-rowH));
barTail.style.height=pad+'px';
labelTail.style.height=pad+'px';
}
// The echo guard's baseline. A measurement is order-dependent by definition, so it
// is taken in wireRelayoutObservers() rather than here, where it would now read a
// pane the chart had not been put into yet.
let rlTimer=null, lastPaneW=0, lastPaneH=0;
function scheduleRelayout(){clearTimeout(rlTimer);rlTimer=setTimeout(()=>{
// A hidden chart measures zero, which would collapse every column and the
// milestone cap layout. Switching back to the chart relays out explicitly.
if(prefs.view==='table')return;
const w=timelineEl.clientWidth, h=timelineEl.clientHeight;
if(w===lastPaneW && h===lastPaneH) return; // guard: ignore observer echo from our own writes
lastPaneW=w; lastPaneH=h;
// A width change reflows the whole timeline; a height-only change still has to
// resize the tailroom, or scrolling past the last row breaks after a vertical drag.
if(w!==lastRelayoutW){ lastRelayoutW=w; relayout(); }
else { sizeTailroom(); }
// One more pass next frame: if the pane was still settling when we measured,
// the tail would otherwise stay stale until the next resize.
requestAnimationFrame(()=>{ lastPaneH=timelineEl.clientHeight; sizeTailroom(); });
},80);}
let lastRelayoutW=0;
function wireRelayoutObservers(){
lastPaneW=timelineEl.clientWidth; lastPaneH=timelineEl.clientHeight;
lastRelayoutW=timelineEl.clientWidth;
window.addEventListener('resize',scheduleRelayout);
if(window.ResizeObserver)new ResizeObserver(scheduleRelayout).observe(timelineEl);
// Web-font load changes tag widths and heights after the first measure, so re-run the
// milestone cap collision layout once fonts settle, or tags can overlap on a cold load.
if(document.fonts&&document.fonts.ready)document.fonts.ready.then(()=>{if(prefs.msStyle==='line'&&prefs.view!=='table')layoutMsCaps();});
}
// ── Filter ──
// The legend's phase and milestone toggles are a statement about the plan, not
// about the chart, so the table honours them too and the two lenses agree.
function applyFilter(){items.forEach(r=>{let vis=true;if(r.phase!==undefined&&!phaseVis[r.phase])vis=false;if(r.type==='milestone'&&!msVis)vis=false;r._lDiv.classList.toggle('dim',!vis);r._bDiv.classList.toggle('dim',!vis);});drawDeps();if(prefs.view==='table')renderTable();}
// ── Dependencies ──
function sb0(entry){ return entry.row || entry.bar.closest('.bar-row'); }
function drawDeps(){
depSvg.innerHTML='';let maxY=0;
items.forEach(r=>{if(!r.deps||!r.id)return;const tgt=barEls[r.id];if(!tgt)return;
r.deps.forEach(depId=>{const src=barEls[depId];if(!src)return;
const sItem=items[src.idx];
if(!sItem)return;
if(r._bDiv.classList.contains('dim')||sItem._bDiv.classList.contains('dim'))return;
// a collapsed row has no position on screen, so an arrow to it would be
// drawn from nowhere to nowhere
if(r._collapsed||sItem._collapsed)return;
// Measure the row rather than assuming 17, which was half of a 34px row and
// put every arrow in the wrong place the moment the text scale moved it.
const sr=sb0(src),tr=sb0(tgt);
const sb=src.bar,tb=tgt.bar,x1=sb.offsetLeft+sb.offsetWidth,y1=sr.offsetTop+sr.offsetHeight/2,x2=tb.offsetLeft,y2=tr.offsetTop+tr.offsetHeight/2,mx=x1+(x2-x1)*0.5;
const p=document.createElementNS('http://www.w3.org/2000/svg','path');p.setAttribute('d',`M${x1},${y1} C${mx},${y1} ${mx},${y2} ${x2},${y2}`);depSvg.appendChild(p);
const a=document.createElementNS('http://www.w3.org/2000/svg','polygon');a.setAttribute('points',`${x2},${y2} ${x2-6},${y2-3} ${x2-6},${y2+3}`);depSvg.appendChild(a);
maxY=Math.max(maxY,y1,y2);});});
depSvg.setAttribute('height',Math.max(maxY+40,bars.scrollHeight));
}
function wireDepRedraw(){
setTimeout(drawDeps,100);new ResizeObserver(drawDeps).observe(bars);
}
// ── Selection (Ctrl+click toggle, Shift+click range, click single) ──
let selAnchor = null;
function taskIds(){return items.filter(r=>r.id).map(r=>r.id);}
function clearSelection(){selected.clear();refreshSelVisuals();updateSelCount();}
function refreshSelVisuals(){
document.querySelectorAll('.bar.selected').forEach(b=>b.classList.remove('selected'));
document.querySelectorAll('.label-row.multi-sel').forEach(l=>l.classList.remove('multi-sel'));
selected.forEach(sid=>{if(barEls[sid]){barEls[sid].bar.classList.add('selected');const r=items.find(d=>d.id===sid);if(r)r._lDiv.classList.add('multi-sel');}});
}
function doSelect(id, e){
const ctrl=e.ctrlKey||e.metaKey, shift=e.shiftKey;
if(shift&&selAnchor){
const ids=taskIds(), ai=ids.indexOf(selAnchor), ci=ids.indexOf(id);
if(ai>=0&&ci>=0){
const lo=Math.min(ai,ci), hi=Math.max(ai,ci);
if(!ctrl)selected.clear();
for(let i=lo;i<=hi;i++)selected.add(ids[i]);
}
} else if(ctrl){
if(selected.has(id))selected.delete(id); else selected.add(id);
selAnchor=id;
} else {
const was=selected.has(id)&&selected.size===1;
selected.clear();
if(!was){selected.add(id);selAnchor=id;}else{selAnchor=null;}
}
refreshSelVisuals();updateSelCount();
}
function updateSelCount(){if(selected.size>1){selCountEl.textContent=`${selected.size} selected`;selCountEl.classList.add('show');}else{selCountEl.classList.remove('show');}}
// ── Label click → select bar ──
// A click on the label edits the NAME, right there. The bar still owns the
// schedule, so clicking it opens the date and dependency editor as before: the
// label is the thing's name, the bar is its time.
//
// Multi-select modifiers keep their old meaning and never start an edit.
function wireLabelClicks(){
labelsEl.addEventListener('click',e=>{
if(reorderState||laneDrag)return;
if(e.target.closest('.lane-toggle')||e.target.closest('.row-edit'))return;
const row=e.target.closest('.label-row[data-id], .label-row.ms');
if(!row)return;
const r=items.find(x=>x._lDiv===row);
if(!r)return;
if(e.ctrlKey||e.metaKey||e.shiftKey){
if(!r.id)return;
doSelect(r.id,e);
if(selected.size>1)openBatchEditor(); else closeEditor();
return;
}
editLabelInline(r);
});
// The second click of a double-click upgrades to the full editor, carrying
// whatever has been typed so far, so the fast path is never paid for by the
// slow one: the inline editor is already mounted and instant.
labelsEl.addEventListener('dblclick',e=>{
const row=e.target.closest('.label-row[data-id], .label-row.ms');
if(!row)return;
const r=items.find(x=>x._lDiv===row);
if(!r)return;
e.preventDefault();e.stopPropagation();
const live=row.querySelector('.label-edit');
const carried=live?live.value:undefined;
if(live)live.__cancel&&live.__cancel();
openItemModal(r,carried);
});
}
function editLabelInline(r){
const lDiv=r._lDiv;
if(!lDiv||lDiv.querySelector('.label-edit'))return;
const inp=document.createElement('input');
inp.type='text';inp.className='label-edit';inp.value=r.name||'';
let done=false;
const restore=()=>{
if(r.type==='milestone')refreshMsChrome(r); else refreshLabelRow(r);
applyCollapse();
};
const finish=save=>{
if(done)return;done=true;
const v=inp.value.trim();
try{inp.blur();}catch(e){}
if(save&&v&&v!==r.name){
if(r.type==='milestone')snapshotMs(r); else snapshot(r.id);
r.name=v;
restore();
if(r.type==='milestone')updateMilestone(r); else updateBar(r.id);
refreshChartGeometry();
scheduleSave('label-edit');
} else restore();
};
inp.__cancel=()=>{done=true;restore();};
inp.addEventListener('keydown',e=>{e.stopPropagation();
if(e.key==='Enter'){e.preventDefault();finish(true);}
else if(e.key==='Escape'){e.preventDefault();finish(false);}});
inp.addEventListener('blur',()=>finish(true));
inp.addEventListener('mousedown',e=>e.stopPropagation());
inp.addEventListener('click',e=>e.stopPropagation());
inp.addEventListener('dblclick',e=>e.stopPropagation());
lDiv.appendChild(inp);
inp.focus();inp.select();
labelEditing=inp;
}
let labelEditing=null;
// ── Unified pending gesture (vertical=reorder, horizontal=move) ──
let reorderState=null, gesture=null;
const labelDropInd=document.createElement('div');
function mountLabelDropIndicator(){
labelDropInd.className='drop-indicator';labelsEl.appendChild(labelDropInd);
}
const barDropInd=document.createElement('div');
function mountBarDropIndicator(){
barDropInd.className='drop-indicator';bars.appendChild(barDropInd);
}
function rowYCenter(el){return el.offsetTop+el.offsetHeight/2;}
// One place to put the indicators away, called from every exit: drop, Escape,
// a drag that ends outside any list, and the safety net on mouseup. An indicator
// left showing is a promise about a drop that is not going to happen.
function hideDropIndicators(){
labelDropInd.classList.remove('show');
barDropInd.classList.remove('show');
}
function wireLabelDragArm(){
labelsEl.addEventListener('mousedown',e=>{
const row=e.target.closest('.label-row[data-id]');
if(!row)return;
gesture={id:row.dataset.id,startX:e.clientX,startY:e.clientY,src:'label'};
});
}
// (bar body mousedown is handled below in the drag section)
let didDrag = false;
function wireBarClicks(){
bars.addEventListener('click',e=>{
if(didDrag){didDrag=false;return;}
const msHit=e.target.closest('.ms-hit, .ms-cap');
if(msHit){ clearSelection(); openMilestoneEditor(+msHit.dataset.msi); e.stopPropagation(); return; }
const bar=e.target.closest('.bar[data-id]');
if(bar&&!e.target.classList.contains('drag-handle')){
const id=bar.dataset.id;
doSelect(id, e);
if(selected.size===1)openSingleEditor(id);
else if(selected.size>1)openBatchEditor();
else closeEditor();
e.stopPropagation();
}else if(!e.target.closest('.editor')&&!e.target.closest('.dropdown')){clearSelection();closeEditor();}
});
document.addEventListener('click',e=>{if(!e.target.closest('.editor')&&!e.target.closest('.bar[data-id]')&&!e.target.closest('.dropdown')&&!e.target.closest('.act-btn'))closeEditor();});
}
// ── Milestone editor ──
function openMilestoneEditor(i){
const r=msByIndex(i);if(!r)return;
closeEditor();
editorEl.innerHTML=`<div class="drag-bar"></div>
<div class="de-row"><div style="flex:1"><label>milestone</label>
<input type="text" id="meName" value="${esc(r.name||'')}"></div></div>
<div class="de-row"><div><label>date</label><input type="date" id="meDate" value="${r.date}"></div></div>
<div class="de-note-row"><label>description <span class="de-hint">shown on hover</span></label>
<input type="text" id="meDesc" value="${esc(r.description||'')}"></div>
<div class="de-actions"><button class="de-btn primary" id="meApply">Apply</button>
<button class="de-btn secondary" id="meClose">Cancel</button></div>`;
const x=r._msHit?r._msHit.getBoundingClientRect():{left:window.innerWidth/2,bottom:200};
editorEl.style.left=Math.min(Math.max(8,x.left-150),window.innerWidth-320)+'px';
editorEl.style.top=Math.min(x.bottom+8,window.innerHeight-300)+'px';
editorEl.classList.add('show');
$('meApply').addEventListener('click',()=>{
snapshotMs(r);
const nd=$('meDate').value;
if(nd)r.date=nd;
r.name=$('meName').value||r.name;
r.description=$('meDesc').value;
if(r._lDiv)r._lDiv.textContent=r.name;
if(r._msHit){r._msHit.dataset.title=r.name;r._msHit.dataset.body=r.description||'';}
if(r._msLine){const cap=r._msLine.querySelector('.ms-cap');if(cap)cap.textContent=r.name.replace(/^M(\d+):\s*/,'M$1 ');}
updateMilestone(r);closeEditor();scheduleSave('milestone-editor');
});
$('meClose').addEventListener('click',closeEditor);
}
// ── Single editor ──
function openSingleEditor(id){
const r=items.find(d=>d.id===id);if(!r)return;
const allTasks=items.filter(d=>d.id&&d.id!==id),deps=r.deps||[];
editorEl.innerHTML=`<div class="drag-bar"></div><div class="de-row"><div><label>start</label><input type="date" id="deStart" value="${r.start}"></div><div><label>end</label><input type="date" id="deEnd" value="${r.end}"></div></div><div class="de-note-row"><label>note <span class="de-hint">shown on the bar</span></label><input type="text" id="deNote" maxlength="24" value="${esc(r.note||'')}" placeholder="e.g. 6m"></div><div class="de-dep-section"><label>dependencies</label><ul class="dep-list" id="deDepList"></ul><select id="deAddDep" style="margin-top:6px;"><option value="">+ add dependency...</option>${allTasks.filter(t=>!deps.includes(t.id)).map(t=>`<option value="${esc(t.id)}">${esc(t.name)}</option>`).join('')}</select></div><div class="de-actions"><button class="de-btn primary" id="deApply">Apply</button><button class="de-btn secondary" id="deClose">Cancel</button></div>`;
renderDepList(id);
const rect=barEls[id].bar.getBoundingClientRect();
const edW=300, edH=300;
let edX=rect.right+8, edY=rect.top;
if(edX+edW>window.innerWidth) edX=rect.left-edW-8;
if(edX<0) edX=8;
if(edY+edH>window.innerHeight) edY=window.innerHeight-edH-8;
if(edY<0) edY=8;
editorEl.style.left=edX+'px';editorEl.style.top=edY+'px';editorEl.classList.add('show');
$('deAddDep').addEventListener('change',function(){if(this.value){snapshot(id);if(!r.deps)r.deps=[];r.deps.push(this.value);renderDepList(id);drawDeps();scheduleSave('editor');const o=this.querySelector(`option[value="${r.deps[r.deps.length-1]}"]`);if(o)o.remove();this.value='';}});
$('deApply').addEventListener('click',()=>{snapshot(id);const ns=$('deStart').value,ne=$('deEnd').value;if(ns)r.start=ns;if(ne)r.end=ne;r.note=$('deNote').value;updateBar(id);closeEditor();scheduleSave('editor');});
$('deClose').addEventListener('click',closeEditor);
}
function renderDepList(id){
const r=items.find(d=>d.id===id),list=$('deDepList');if(!list)return;
const incoming=(r.deps||[]).map(depId=>{const dep=items.find(d=>d.id===depId);return{depId,name:dep?dep.name:depId,dir:'in'};});
const outgoing=items.filter(d=>d.deps&&d.deps.includes(id)).map(d=>({depId:d.id,name:d.name,dir:'out'}));
const all=[...incoming,...outgoing];
list.innerHTML=all.map(d=>`<li><span style="font-family:'JetBrains Mono',monospace;font-size:9px;color:${d.dir==='in'?'var(--accent)':'var(--text2)'};margin-right:4px;min-width:48px">${d.dir==='in'?'← needs':'→ blocks'}</span><span style="flex:1;color:${d.dir==='in'?'var(--text)':'var(--text2)'}">${esc(d.name)}</span><span class="dep-rm" data-dep="${esc(d.depId)}" data-dir="${esc(d.dir)}">×</span></li>`).join('')||'<li style="color:var(--text2)">none</li>';
list.querySelectorAll('.dep-rm').forEach(btn=>{btn.addEventListener('click',()=>{
const depId=btn.dataset.dep, dir=btn.dataset.dir;
if(dir==='in'){snapshot(id);r.deps=r.deps.filter(d=>d!==depId);}
else{const blocker=items.find(d=>d.id===depId);if(blocker){snapshot(depId);blocker.deps=blocker.deps.filter(d=>d!==id);}}
renderDepList(id);drawDeps();scheduleSave('editor');
});});
}
// ── Batch editor (relative adjustments) ──
function daysBetween(a,b){const[ay,am,ad]=a.split('-').map(Number),[by,bm,bd]=b.split('-').map(Number);return Math.round((new Date(by,bm-1,bd)-new Date(ay,am-1,ad))/86400000);}
function openBatchEditor(){
const ids=[...selected];
const tasks=ids.map(id=>items.find(d=>d.id===id)).filter(r=>r&&r.start);
if(!tasks.length){closeEditor();return;}
const earliest=tasks.reduce((a,r)=>r.start<a?r.start:a,tasks[0].start);
const latest=tasks.reduce((a,r)=>r.end>a?r.end:a,tasks[0].end);
editorEl.innerHTML=`<div class="drag-bar"></div>
<div class="batch-title">${ids.length} tasks selected</div>
<div class="batch-hint">All changes are relative, each task shifts from its own dates</div>
<div style="margin-bottom:10px">
<div class="de-row">
<div><label>earliest start</label><input type="date" id="bStart" value="${earliest}"></div>
<div><label>latest end</label><input type="date" id="bEnd" value="${latest}"></div>
</div>
<div style="font-size:10px;color:var(--text2);margin-top:2px">Changing a date shifts all selected by the same offset</div>
</div>
<div style="border-top:1px solid var(--border-muted);padding-top:8px;margin-bottom:4px">
<div class="batch-row"><label>shift all</label><input type="number" id="bShiftAll" value="0" step="1"> <span>days</span></div>
<div class="batch-row"><label>shift start</label><input type="number" id="bShiftS" value="0" step="1"> <span>days</span></div>
<div class="batch-row"><label>shift end</label><input type="number" id="bShiftE" value="0" step="1"> <span>days</span></div>
</div>
<div class="de-actions"><button class="de-btn primary" id="bApply">Apply</button><button class="de-btn secondary" id="bClose">Cancel</button></div>`;
editorEl.style.left=(window.innerWidth/2-155)+'px';editorEl.style.top=(window.innerHeight/2-130)+'px';editorEl.classList.add('show');
$('bApply').addEventListener('click',()=>{
const newStart=$('bStart').value, newEnd=$('bEnd').value;
const startDelta=daysBetween(earliest,newStart);
const endDelta=daysBetween(latest,newEnd);
const all=parseInt($('bShiftAll').value)||0, ds=parseInt($('bShiftS').value)||0, de=parseInt($('bShiftE').value)||0;
const totalStartShift=startDelta+all+ds, totalEndShift=endDelta+all+de;
if(totalStartShift===0&&totalEndShift===0){closeEditor();return;}
snapshotMany(ids);
ids.forEach(id=>{const r=items.find(d=>d.id===id);if(!r||!r.start)return;r.start=addDays(r.start,totalStartShift);r.end=addDays(r.end,totalEndShift);updateBar(id);});
scheduleSave('batch-editor');closeEditor();
});
$('bClose').addEventListener('click',closeEditor);
}
function msByIndex(i){return items.filter(r=>r.type==='milestone')[i];}
function updateMilestone(r){
if(!r||!r._msEl)return;
const x=toX(r.date);
r._msEl.style.left=x+'px';
// Diamond style hovers the hit strip and line style hovers the cap, so both
// carry the tooltip and both have to be restated, not just the cap.
if(r._msHit){r._msHit.style.left=x+'px';r._msHit.dataset.dates=fmt(r.date);r._msHit.dataset.title=r.name;r._msHit.dataset.body=r.description||'';}
if(r._msLine){
r._msLine.style.left=x+'px';
const cap=r._msLine.querySelector('.ms-cap');
if(cap){cap.dataset.dates=fmt(r.date);cap.dataset.title=r.name;cap.dataset.body=r.description||'';}
}
drawDeps();
}
function snapshotMs(r){
undoStack.push({type:'ms',r,date:r.date,name:r.name,owner:r.owner,status:r.status,desc:r.description,prov:r.provenance});
if(undoStack.length>100)undoStack.splice(0,undoStack.length-100);
}
// Milestone chrome that is not the date: the label row, the tooltip body and the
// dashed rule's tag all restate the milestone, so they move together.
function refreshMsChrome(r){
if(!r)return;
if(r._nameSpan)r._nameSpan.textContent=r.name;
else if(r._lDiv)r._lDiv.textContent=r.name;
if(r._msHit){r._msHit.dataset.title=r.name;r._msHit.dataset.body=r.description||'';r._msHit.dataset.prov=provLabel(r);}
if(r._msLine){const cap=r._msLine.querySelector('.ms-cap');
if(cap){cap.textContent=(r.name||'').replace(/^M(\d+):\s*/,'M$1 ');cap.dataset.title=r.name;cap.dataset.body=r.description||'';cap.dataset.prov=provLabel(r);}}
}
// The bar's tooltip lives in its data-* attributes, so restating the item means
// restating those too. This used to patch only the geometry and the dates, so a
// description restored from the sidecar (or edited in the table) left the bar
// hovering the version that was in the data file.
function updateBar(id){const r=items.find(d=>d.id===id),be=barEls[id];if(!r||!be)return;if(r._noteEl)r._noteEl.textContent=r.note||'';const x1=toX(r.start),x2=toX(r.end);be.bar.style.left=x1+'px';be.bar.style.width=Math.max(x2-x1,6)+'px';be.bar.dataset.dates=fmt(r.start)+' → '+fmt(r.end);be.bar.dataset.title=r.name;be.bar.dataset.body=r.description||'';be.bar.dataset.prov=provLabel(r);drawDeps();}
function closeEditor(){editorEl.classList.remove('show');editorEl.innerHTML='';}
function closeMenus(){exportMenu.classList.remove('show');importMenu.classList.remove('show');}
// ── Drag (edge-resize, body-move horizontal, reorder vertical) ──
let dragState=null;
function wireBarDrags(){
bars.addEventListener('mousedown',e=>{
const handle=e.target.closest('.drag-handle');
const bar=handle?handle.closest('.bar[data-id]'):e.target.closest('.bar[data-id]');
if(!bar)return;
if(handle){
e.preventDefault();hideTip();
const id=bar.dataset.id, r=items.find(d=>d.id===id);
const side=handle.classList.contains('left')?'left':'right';
snapshot(id);
if(!selected.has(id)){selected.clear();selected.add(id);selAnchor=id;refreshSelVisuals();updateSelCount();}
dragState={id,side,startX:e.clientX,origLeft:parseFloat(bar.style.left),origWidth:parseFloat(bar.style.width),bar,r,moveIds:[id],originals:{[id]:{left:parseFloat(bar.style.left),width:parseFloat(bar.style.width),start:r.start,end:r.end}}};
document.body.style.cursor='ew-resize';
return;
}
gesture={id:bar.dataset.id,startX:e.clientX,startY:e.clientY,src:'bar',bar};
});
// Milestones carry no id, so they sat outside the bar drag path entirely and could
// never be moved or edited. They get their own handler.
bars.addEventListener('mousedown',e=>{
const hit=e.target.closest('.ms-hit, .ms-cap');
if(!hit)return;
e.preventDefault();hideTip();
const r=msByIndex(+hit.dataset.msi);
if(!r)return;
snapshotMs(r);
dragState={kind:'ms',r,startX:e.clientX,origLeft:toX(r.date)};
document.body.style.cursor='ew-resize';
});
document.addEventListener('mousemove',e=>{
// Resolve gesture → horizontal drag or vertical reorder
if(gesture&&!dragState&&!reorderState){
const dx=e.clientX-gesture.startX, dy=e.clientY-gesture.startY;
if(Math.abs(dx)<5&&Math.abs(dy)<5)return;
hideTip();didDrag=true;
const id=gesture.id;
if(Math.abs(dx)>=Math.abs(dy)){
// Horizontal → move bar dates
const bar=gesture.bar||barEls[id]?.bar;
if(!bar){gesture=null;return;}
const r=items.find(d=>d.id===id);
const moveIds=(selected.has(id)&&selected.size>1)?[...selected]:[id];
snapshotMany(moveIds);
const originals={};
// The dates go into the snapshot as well as the pixels: a body move keeps
// its duration, and duration is a number of days, not a number of pixels.
moveIds.forEach(mid=>{const be=barEls[mid],ri=items.find(d=>d.id===mid);
if(be&&ri)originals[mid]={left:parseFloat(be.bar.style.left),width:parseFloat(be.bar.style.width),start:ri.start,end:ri.end};});
dragState={id,side:'move',startX:gesture.startX,origLeft:parseFloat(bar.style.left),origWidth:parseFloat(bar.style.width),bar,r,moveIds,originals};
if(!selected.has(id)){selected.clear();selected.add(id);selAnchor=id;refreshSelVisuals();updateSelCount();}
document.body.style.cursor='grabbing';
} else {
// Vertical → reorder
reorderState={active:true,id};
closeEditor();
const moveIds=(selected.has(id)&&selected.size>1)?[...selected]:[id];
if(!selected.has(id)){selected.clear();selected.add(id);selAnchor=id;refreshSelVisuals();updateSelCount();}
reorderState.moveIds=moveIds;
moveIds.forEach(mid=>{const r=items.find(d=>d.id===mid);if(r){r._lDiv.classList.add('dragging');r._bDiv.classList.add('dragging');}});
document.body.style.cursor='grabbing';
}
gesture=null;
}
// Horizontal drag
if(dragState){
didDrag=true;
const dx=e.clientX-dragState.startX;
if(dragState.kind==='ms'){
// A milestone is a single date, so it lands on a grid line like any edge.
const s=snapX(dragState.origLeft+dx);
if(dragState.r.date!==s.date){ dragState.r.date=s.date; updateMilestone(dragState.r); }
return;
}
const{id,side,origLeft,origWidth,bar,r,moveIds,originals}=dragState;
// Geometry is redrawn FROM the dates rather than from the cursor, so what is
// on screen mid-drag is exactly what will be committed, down to the pixel.
const paint=(be,ri)=>{
const x1=toX(ri.start),x2=toX(ri.end);
be.bar.style.left=x1+'px';be.bar.style.width=Math.max(x2-x1,6)+'px';
be.bar.dataset.dates=fmt(ri.start)+' → '+fmt(ri.end);
};
if(side==='move'){
// The start snaps; the DURATION is preserved, so the end lands at start plus
// the same number of days it always had. Snapping both ends independently
// would quietly stretch or shrink every bar the user only meant to slide.
const base=originals[id];
if(base){
const s=snapX(origLeft+dx);
const shift=dayDiff(parseISO(base.start),parseISO(s.date));
// With more than one bar selected, the bar under the cursor is the one
// that lands on the line and the rest move by the same number of days, so
// the arrangement the user selected survives the move.
moveIds.forEach(mid=>{
const be=barEls[mid],orig=originals[mid],ri=items.find(d=>d.id===mid);
if(!be||!orig||!ri)return;
const ns=addDays(orig.start,shift), ne=addDays(orig.end,shift);
if(ri.start===ns&&ri.end===ne)return;
ri.start=ns;ri.end=ne;paint(be,ri);
});
}
} else if(side==='left'){
// The end is fixed. Step back off any boundary that would put the start on
// or past it: one cell of the current grain is the shortest bar the visible
// grid can express, so that is the minimum. Drop to a finer grain for less.
let i=snapIndex(origLeft+dx);
while(i>0&&snapDateAt(i)>=r.end)i--;
const d=snapDateAt(i);
if(d<r.end&&r.start!==d){ r.start=d; paint(barEls[id]||{bar},r); }
} else {
// The start is fixed, same rule from the other side.
let i=snapIndex(origLeft+origWidth+dx);
while(i<UNITS&&snapDateAt(i)<=r.start)i++;
const d=snapDateAt(i);
if(d>r.start&&r.end!==d){ r.end=d; paint(barEls[id]||{bar},r); }
}
drawDeps();
}
// Vertical reorder
if(reorderState&&reorderState.active){
const labelsRect=labelsEl.getBoundingClientRect();
const mouseY=e.clientY-labelsRect.top+labelsEl.scrollTop;
let targetIdx=-1, insertBefore=true;
for(let i=0;i<items.length;i++){
const r=items[i];if(reorderState.moveIds.includes(r.id))continue;
const cy=rowYCenter(r._lDiv);
if(mouseY<cy){targetIdx=i;insertBefore=true;break;}
targetIdx=i;insertBefore=false;
}
if(targetIdx>=0){
const ref=items[targetIdx];
// Both columns are measured against their own offset parent, and the cursor
// was converted into that same space above, so the line sits on the slot
// boundary the pointer is actually nearest.
labelDropInd.style.top=(insertBefore?ref._lDiv.offsetTop:ref._lDiv.offsetTop+ref._lDiv.offsetHeight)+'px';labelDropInd.classList.add('show');
barDropInd.style.top=(insertBefore?ref._bDiv.offsetTop:ref._bDiv.offsetTop+ref._bDiv.offsetHeight)+'px';barDropInd.classList.add('show');
reorderState.targetIdx=targetIdx;reorderState.insertBefore=insertBefore;
} else {
hideDropIndicators();
reorderState.targetIdx=null;
}
}
});
document.addEventListener('mouseup',()=>{
gesture=null;
if(!reorderState&&!laneDrag)hideDropIndicators();
if(dragState){scheduleSave(dragState.kind==='ms'?'drag-milestone':(dragState.side==='move'?'drag-move':'drag-resize'));dragState=null;document.body.style.cursor='';}
if(reorderState&&reorderState.active){
hideDropIndicators();
reorderState.moveIds.forEach(mid=>{const r=items.find(d=>d.id===mid);if(r){r._lDiv.classList.remove('dragging');r._bDiv.classList.remove('dragging');}});
document.body.style.cursor='';
if(reorderState.targetIdx!=null){
snapshotOrder();
const moveItems=reorderState.moveIds.map(mid=>items.find(d=>d.id===mid)).filter(Boolean);
const remainingItems=items.filter(r=>!reorderState.moveIds.includes(r.id));
const actualInsert=remainingItems.indexOf(items[reorderState.targetIdx]);
const finalInsert=reorderState.insertBefore?actualInsert:actualInsert+1;
remainingItems.splice(finalInsert,0,...moveItems);
items.length=0;items.push(...remainingItems);
// Where a row lands is what lane it is in: the group header above it is the
// lane. Dropping across a header therefore changes the phase, and the bar
// recolours to match, rather than leaving a row sitting under a header it
// does not belong to.
const phaseChanges=[];
moveItems.forEach(mi=>{
const at=items.indexOf(mi);
let g=null;
for(let i=at;i>=0;i--){ if(items[i].type==='group'){g=items[i];break;} }
if(g&&g.phase!==undefined&&g.phase!==mi.phase){
phaseChanges.push({r:mi,from:mi.phase});
mi.phase=g.phase;
if(mi.id&&barEls[mi.id])barEls[mi.id].bar.style.background=phaseColor(g.phase);
if(mi._msLine)mi._msLine.style.color=phaseColor(g.phase);
}
});
if(phaseChanges.length){
// fold the phase restore into the order snapshot already pushed, so the
// whole gesture is still one Ctrl+Z
const top=undoStack[undoStack.length-1];
if(top&&top.type==='order')top.phases=phaseChanges;
moveHint=moveItems.length===1?'"'+(moveItems[0].name||'a row')+'"':moveItems.length+' rows';
}
applyCollapse();reflowRows();applyFilter();refreshChartGeometry();renderTable();
drawDeps();scheduleSave('drag-reorder');didDrag=true;
}
reorderState=null;
}
});
}
// ── Bar notes ──
// Double-click the bar body to type a short label straight onto it, the way the
// NSF chart shows durations. Single-click still opens the full editor.
function editNoteInline(id){
const r=items.find(d=>d.id===id),be=barEls[id];
if(!r||!be)return;
if(be.bar.querySelector('.bar-note-edit'))return;
const inp=document.createElement('input');
inp.className='bar-note-edit';inp.value=r.note||'';inp.maxLength=24;
inp.addEventListener('mousedown',e=>e.stopPropagation());
inp.addEventListener('click',e=>e.stopPropagation());
const commit=(save)=>{
if(!inp.parentNode)return;
if(save&&(r.note||'')!==inp.value){snapshot(id);r.note=inp.value;updateBar(id);scheduleSave('bar-note');}
inp.remove();
};
inp.addEventListener('keydown',e=>{e.stopPropagation();
if(e.key==='Enter'){e.preventDefault();commit(true);}
if(e.key==='Escape'){e.preventDefault();commit(false);}});
inp.addEventListener('blur',()=>commit(true));
be.bar.appendChild(inp);inp.focus();inp.select();
}
function wireBarDblclick(){
document.addEventListener('dblclick',e=>{
const bar=e.target.closest('.bar');
if(!bar||!bar.dataset.id)return;
e.preventDefault();e.stopPropagation();
closeEditor();
editNoteInline(bar.dataset.id);
});
}
// ── Tooltip ──
let tipTimer=null;
// Built as nodes, never as markup. The title and body are user-typed (a task name,
// a note typed into the table), and reading them back out of a data-* attribute
// decodes the escaping that put them there, so an innerHTML here executed whatever
// the last person to edit the plan wrote, for everyone who opened it afterwards.
function tipLine(cls,text){const d=document.createElement('div');d.className=cls;d.textContent=text;return d;}
function showTip(el){if(dragState)return;clearTimeout(tipTimer);tipTimer=setTimeout(()=>{
if(!el.isConnected){hideTip();return;} // a re-render or a delete beat the delay
const t=el.dataset.title,d=el.dataset.dates,b=el.dataset.body;
if(!t)return;
const pv=el.dataset.prov;
tipEl.textContent='';
tipEl.appendChild(tipLine('tip-title',t));
if(d)tipEl.appendChild(tipLine('tip-dates',d));
if(b)tipEl.appendChild(tipLine('tip-body',b));
if(pv)tipEl.appendChild(tipLine('tip-prov','source: '+pv));
tipEl.classList.add('show');posTipFor(el);},500);}
// Anchored to the trigger, positioned ONCE, clamped on both axes. It used to be
// placed from the pointer coordinates and re-placed on every mousemove, so it
// trailed the cursor instead of sitting beside the thing it describes.
function posTipFor(el){
const r=el.getBoundingClientRect();
const tw=tipEl.offsetWidth, th=tipEl.offsetHeight, M=8;
let y=r.bottom+M;
if(y+th>window.innerHeight-M) y=r.top-th-M; // flip above rather than fall off
y=Math.max(M,Math.min(y,window.innerHeight-th-M));
const x=Math.max(M,Math.min(r.left,window.innerWidth-tw-M));
tipEl.style.left=x+'px';tipEl.style.top=y+'px';
}
function hideTip(){clearTimeout(tipTimer);tipEl.classList.remove('show');}
function wireTooltipsAndExport(){
document.addEventListener('mouseover',e=>{const el=e.target.closest('[data-title]');if(el)showTip(el);});
document.addEventListener('mouseout',e=>{if(e.target.closest('[data-title]'))hideTip();});
// No mousemove handler on purpose: a tooltip that follows the pointer is the bug.
// It leaves on anything that could move or invalidate what it points at.
document.addEventListener('mousedown',hideTip,true);
document.addEventListener('click',hideTip,true);
window.addEventListener('scroll',hideTip,true); // capture, so inner scrollers count
window.addEventListener('resize',hideTip);
window.addEventListener('blur',hideTip);
// ── Export menu ──
$('btnExport').addEventListener('click',e=>{
closeMenus();const rect=e.currentTarget.getBoundingClientRect();
exportMenu.innerHTML=`
<div class="dd-label">export as</div>
<div class="dd-item" data-fmt="json">JSON (native)</div>
<div class="dd-item" data-fmt="csv">CSV</div>
<div class="dd-item" data-fmt="svg">SVG</div>
<div class="dd-item" data-fmt="html">HTML (self-contained)</div>`;
exportMenu.style.left=Math.min(rect.left,window.innerWidth-200)+'px';exportMenu.style.top=(rect.bottom+4)+'px';exportMenu.classList.add('show');
exportMenu.querySelectorAll('.dd-item').forEach(item=>{item.addEventListener('click',()=>{doExport(item.dataset.fmt);closeMenus();});});
e.stopPropagation();
});
}
function doExport(fmt){
const url = `export/${fmt}`;
const a = document.createElement('a');
a.href = url;
a.download = `${project.title.replace(/[^a-zA-Z0-9]/g,'-').toLowerCase()}.${fmt==='html'?'html':fmt}`;
document.body.appendChild(a);a.click();a.remove();
}
// ── Import menu ──
let importFormat = null;
function wireImportAndHelp(){
$('btnImport').addEventListener('click',e=>{
closeMenus();const rect=e.currentTarget.getBoundingClientRect();
importMenu.innerHTML=`
<div class="dd-label">import from</div>
<div class="dd-item" data-fmt="json">JSON (adom-gantt)</div>
<div class="dd-item" data-fmt="csv">CSV</div>
<div class="dd-item" data-fmt="msproject-xml">MS Project XML</div>
<div class="dd-item" data-fmt="jira-csv">Jira CSV</div>`;
importMenu.style.left=Math.min(rect.left,window.innerWidth-200)+'px';importMenu.style.top=(rect.bottom+4)+'px';importMenu.classList.add('show');
importMenu.querySelectorAll('.dd-item').forEach(item=>{item.addEventListener('click',()=>{importFormat=item.dataset.fmt;importFileEl.accept=importFormat.includes('xml')?'.xml':importFormat.includes('csv')?'.csv':'.json,.csv,.xml';importFileEl.click();closeMenus();});});
e.stopPropagation();
});
importFileEl.addEventListener('change',async e=>{
const file=e.target.files[0];if(!file)return;
const text=await file.text();
try{
const r=await fetch(`import/${importFormat}`,{method:'POST',headers:{'Content-Type':'text/plain'},body:text});
const res=await r.json();
if(res.ok){flash(saveInd,`imported ${res.items} items`);setTimeout(()=>location.reload(),500);}
else{alert('Import failed: '+(res.error||'unknown error'));}
}catch(err){alert('Import error: '+err.message);}
importFileEl.value='';
});
// ── Help menu ──
$('btnHelp').addEventListener('click',e=>{
closeMenus();const rect=e.currentTarget.getBoundingClientRect();
exportMenu.innerHTML=`
<div class="dd-label">interactions</div>
<div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">click bar</span>Edit dates + deps</div>
<div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Ctrl+click</span>Toggle in selection</div>
<div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Shift+click</span>Range select</div>
<div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">click label</span>Select (same as bar)</div>
<div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">drag edge</span>Resize start or end</div>
<div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">drag horiz.</span>Move bar dates</div>
<div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">drag vert.</span>Reorder rows</div>
<div class="dd-sep"></div>
<div class="dd-label">table view</div>
<div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Gantt / Table</span>Same plan, two lenses</div>
<div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">click cell</span>Edit owner, dates, status, notes</div>
<div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">click header</span>Sort by that column</div>
<div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">owner</span>Filter to one person's work</div>
<div class="dd-sep"></div>
<div class="dd-label">keyboard</div>
<div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Ctrl+Z</span>Undo</div>
<div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Up/Down</span>Move the selected row in the table. The pane only scrolls to keep it in view</div>
<div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Enter</span>Edit the selected row's name. In a cell, commit and step down a row</div>
<div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Shift+N</span>New task or milestone</div>
<div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Alt+Up/Down</span>Move the row one slot. Under a sort it stays inside its tied group</div>
<div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Tab</span>Commit and open the next field. Shift+Tab goes back</div>
<div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Esc</span>Cancel the field, then the row, then the selection</div>
<div class="dd-sep"></div>
<div class="dd-label">legend</div>
<div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">click phase</span>Toggle phase visibility</div>
<div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">click deps</span>Toggle dependency arrows</div>
<div class="dd-sep"></div>
<div class="dd-label">data</div>
<div class="dd-item" style="cursor:default"><span style="color:var(--text-body);font-size:10px">Edits auto-save to sidecar JSON. Ctrl+Z undoes up to 100 steps.</span></div>`;
exportMenu.style.left=Math.min(rect.right-220,window.innerWidth-240)+'px';exportMenu.style.top=(rect.bottom+4)+'px';exportMenu.classList.add('show');
e.stopPropagation();
});
}
// ── View controls: theme, milestone style, granularity, project start ──
function rebuildTimeline(){
computeRange();
fitCOL();
buildTimeHeaders();buildGridLines();
syncStartBtn();
relayout();
}
function syncStartBtn(){
const ps=planStart();
$('startBtn').textContent = isDayMode() ? fmt(ps) : fmt(ps.slice(0,7));
$('startBtn').classList.toggle('shifted', ps!==ORIGINAL_START);
$('unitCount').textContent = `${UNITS} ${(GRAINS[G()]||GRAINS.month).label.toLowerCase()}`;
}
// Moving the project start moves the whole plan: every task and milestone shifts by
// the same number of days, so durations and the gaps between them are preserved.
function shiftPlanTo(newStartISO, via){
const cur=planStart();
if(!cur||!newStartISO||newStartISO===cur) return false;
const delta=daysBetween(cur,newStartISO);
if(!delta) return false;
snapshotGroup(taskIds());
items.forEach(r=>{
if(r.start)r.start=addDays(r.start,delta);
if(r.end)r.end=addDays(r.end,delta);
if(r.date)r.date=addDays(r.date,delta);
});
// Phase bands are month strings, and they define part of the axis range, so they
// have to travel with the tasks or the timeline stretches to cover both.
// Anchor on the 15th, not the 1st. Adding days to the 1st lands mid-month and then
// truncates down, so a +181d shift moved some phases 6 months and others 5.
phases.forEach(p=>{
p.start=addDays(p.start+'-15',delta).slice(0,7);
const[ey,em]=p.end.split('-').map(Number);
p.end=addDays(`${ey}-${String(em).padStart(2,'0')}-15`,delta).slice(0,7);
});
rebuildTimeline();
Object.keys(barEls).forEach(id=>updateBar(id));
scheduleSave(via||'start-date');
return true;
}
function syncThemeBtn(){ $('themeLabel').textContent = prefs.theme==='dark' ? 'Light' : 'Dark'; }
function wireThemeButton(){
$('btnTheme').addEventListener('click',()=>{
prefs.theme = prefs.theme==='dark' ? 'light' : 'dark';
applyTheme();savePrefs();syncThemeBtn();
});
syncThemeBtn();
}
function syncMsBtn(){
const line = prefs.msStyle==='line';
$('msStyleIcon').textContent = line ? '¦' : '◆';
$('msStyleLabel').textContent = line ? 'Lines' : 'Diamonds';
}
function wireMsStyleAndGrain(){
$('btnMsStyle').addEventListener('click',()=>{
prefs.msStyle = prefs.msStyle==='line' ? 'diamond' : 'line';
document.documentElement.setAttribute('data-ms',prefs.msStyle);
savePrefs();syncMsBtn();
});
syncMsBtn();
$('grainSel').value = G();
$('grainSel').addEventListener('change',function(){
prefs.granularity = this.value;
savePrefs();
rebuildTimeline();
});
}
// ── The full item editor ──
//
// Everything about one row in one panel: the same fields the create panel offers,
// on an existing item. Reached by double-clicking a label, which upgrades from
// the inline name editor and carries whatever was already typed.
function openItemModal(r,carriedName){
closeEditor();
const ms=r.type==='milestone';
const p=provOf(r)||{label:'',url:''};
editorEl.innerHTML=`<div class="drag-bar"></div>
<div class="batch-title">Edit ${ms?'milestone':'task'}</div>
<div class="de-note-row"><label>name <span class="de-hint">required</span></label><input type="text" id="imName"></div>
<div class="de-row" style="margin-top:8px">
<div style="flex:1"><label>phase</label><select id="imPhase">${phases.map((ph,i)=>`<option value="${i}">${esc(ph.name)}</option>`).join('')}</select></div>
</div>
<div class="de-row">
<div style="flex:1"><label>owner</label><input type="text" id="imOwner" list="ownerList" placeholder="one name"></div>
<div style="flex:1"><label>status</label><select id="imStatus"><option value="">none</option>${STATUS_ORDER.map(x=>`<option value="${x}">${statusLabel[x]}</option>`).join('')}</select></div>
</div>
<div class="de-row">
${ms?'':'<div><label>start</label><input type="date" id="imStart"></div>'}
<div><label>${ms?'date':'deadline'}</label><input type="date" id="imEnd"></div>
</div>
<div class="de-note-row"><label>notes</label><input type="text" id="imDesc"></div>
<div class="de-note-row"><label>provenance</label><input type="text" id="imProv" placeholder="where this came from"></div>
<div class="de-actions"><button class="de-btn primary" id="imApply">Apply</button>
<button class="de-btn secondary" id="imCancel">Cancel</button></div>
<div id="imErr" style="color:#f85149;font-size:11px;margin-top:6px;display:none"></div>`;
// values assigned, never interpolated, so nothing typed here can become markup
$('imName').value=carriedName!==undefined?carriedName:(r.name||'');
$('imPhase').value=String(r.phase);
if(!ms&&$('imStart'))$('imStart').value=r.start||'';
$('imEnd').value=(ms?r.date:r.end)||'';
$('imOwner').value=r.owner||'';
$('imStatus').value=statusOf(r);
$('imDesc').value=r.description||'';
$('imProv').value=p.label||'';
const rect=(r._lDiv||document.body).getBoundingClientRect();
editorEl.style.left=Math.max(8,Math.min(rect.right+12,window.innerWidth-340))+'px';
editorEl.style.top=Math.max(8,Math.min(rect.top,window.innerHeight-430))+'px';
editorEl.classList.add('show');
$('imName').focus();$('imName').select();
editorEl.addEventListener('keydown',e=>{e.stopPropagation();
if(e.key==='Enter'&&e.target.tagName!=='BUTTON'){e.preventDefault();$('imApply').click();}
else if(e.key==='Escape'){e.preventDefault();closeEditor();}});
$('imCancel').addEventListener('click',closeEditor);
$('imApply').addEventListener('click',()=>{
const name=$('imName').value.trim();
if(!name){const er=$('imErr');er.textContent='A name is required.';er.style.display='';$('imName').focus();return;}
if(ms)snapshotMs(r); else snapshot(r.id);
const np=parseInt($('imPhase').value,10);
r.name=name;
const end=$('imEnd').value;
if(ms){ if(end)r.date=end; }
else{
const st=$('imStart')?$('imStart').value:r.start;
if(st)r.start=st;
if(end)r.end=end;
if(r.start&&r.end&&r.start>r.end)r.start=r.end;
}
setOpt(r,'owner',$('imOwner').value.trim());
setOpt(r,'status',STATUS_ORDER.includes($('imStatus').value)?$('imStatus').value:'');
setOpt(r,'description',$('imDesc').value.trim());
const pl=$('imProv').value.trim();
if(!pl)delete r.provenance;
else r.provenance=p.url?{label:pl,url:p.url}:pl;
if(ms){refreshMsChrome(r);updateMilestone(r);} else {updateBar(r.id);refreshLabelRow(r);}
if(phases[np]&&np!==r.phase)moveRowToPhase(r,np);
applyCollapse();refreshChartGeometry();renderTable();
closeEditor();
scheduleSave('item-editor');
});
}
// ── Create a row ──
//
// The same inline editor panel the milestone editor uses, so creating a row and
// editing one feel like the same gesture. Everything typed here flows into the
// same rendering paths as data-file content, which is where the escaping lives.
// What the last successful Create left behind, so adding six tasks for the same
// person in the same week is six names typed and nothing else. In-memory on
// purpose: a refresh clears it, and it is never written to prefs, localStorage or
// the sidecar, so it is a fact about this tab in this sitting and not about the
// plan or about anyone else reading it. Cancel never touches it; only a create that
// actually produced a row does.
let lastCreated=null;
function openCreateEditor(){
closeEditor();
const today=isoDay(new Date());
const plus=addDays(today,14);
// Name and notes always start empty: they are what makes an item that item, and
// carrying them over would offer to create the same row twice. Status and
// provenance keep their own defaults and phase is untouched.
const memo=lastCreated||{};
const ownerVal=memo.owner||'';
const startVal=memo.start||today;
const endVal=memo.end||plus;
const carried=lastCreated?'<span class="de-hint">from your last new item</span>':'';
editorEl.innerHTML=`<div class="drag-bar"></div>
<div class="batch-title">New item</div>
<div class="de-row">
<div><label>type</label><select id="ncType"><option value="task">Task</option><option value="milestone">Milestone</option></select></div>
<div style="flex:1"><label>phase</label><select id="ncPhase">${phases.map((p,i)=>`<option value="${i}">${esc(p.name)}</option>`).join('')}</select></div>
</div>
<div class="de-note-row"><label>name <span class="de-hint">required</span></label><input type="text" id="ncName" placeholder="What is it?"></div>
<div class="de-row" style="margin-top:8px">
<div style="flex:1"><label>owner ${carried}</label><input type="text" id="ncOwner" list="ownerList" placeholder="one name" value="${esc(ownerVal)}"></div>
<div style="flex:1"><label>status</label><select id="ncStatus">${STATUS_ORDER.map(x=>`<option value="${x}">${statusLabel[x]}</option>`).join('')}</select></div>
</div>
<div class="de-row">
<div id="ncStartWrap"><label>start</label><input type="date" id="ncStart" value="${startVal}"></div>
<div><label id="ncEndLabel">deadline</label><input type="date" id="ncEnd" value="${endVal}"></div>
</div>
<div class="de-note-row"><label>notes</label><input type="text" id="ncDesc" placeholder="Shown on hover and in the table"></div>
<div class="de-note-row"><label>provenance <span class="de-hint">where this came from</span></label><input type="text" id="ncProv" value="Added by hand"></div>
<div class="de-actions"><button class="de-btn primary" id="ncCreate">Create</button>
<button class="de-btn secondary" id="ncCancel">Cancel</button></div>
<div id="ncErr" style="color:#f85149;font-size:11px;margin-top:6px;display:none"></div>`;
const r=$('btnNew').getBoundingClientRect();
editorEl.style.left=Math.max(8,Math.min(r.left-220,window.innerWidth-340))+'px';
editorEl.style.top=(r.bottom+8)+'px';
editorEl.classList.add('show');
$('ncStatus').value='not-started';
const syncType=()=>{
const ms=$('ncType').value==='milestone';
$('ncStartWrap').style.display=ms?'none':'';
$('ncEndLabel').textContent=ms?'date':'deadline';
};
$('ncType').addEventListener('change',syncType); syncType();
$('ncName').focus();
$('ncCancel').addEventListener('click',closeEditor);
editorEl.addEventListener('keydown',e=>{e.stopPropagation();
if(e.key==='Enter'&&e.target.tagName!=='BUTTON'){e.preventDefault();$('ncCreate').click();}
else if(e.key==='Escape'){e.preventDefault();closeEditor();}});
$('ncCreate').addEventListener('click',()=>{
const name=$('ncName').value.trim();
if(!name){const er=$('ncErr');er.textContent='A name is required.';er.style.display='';$('ncName').focus();return;}
const isMs=$('ncType').value==='milestone';
const phase=parseInt($('ncPhase').value,10)||0;
const end=$('ncEnd').value||endVal;
const start=$('ncStart').value||startVal;
const r={type:isMs?'milestone':'task',name,phase};
if(isMs){ r.date=end; }
else { r.id=freshId(name); r.start=start<=end?start:end; r.end=end; }
if(!isMs)r._key=undefined;
const owner=$('ncOwner').value.trim(); if(owner)r.owner=owner;
const st=$('ncStatus').value; if(st)r.status=st;
const desc=$('ncDesc').value.trim(); if(desc)r.description=desc;
const prov=$('ncProv').value.trim(); if(prov)r.provenance=prov;
// Only now, past validation and with a row actually being made. A milestone
// hides the start field but the input keeps its value, so `start` is whatever
// was carried in and the remembered start comes through a milestone unchanged,
// which is what the panel looks like it is doing.
lastCreated={owner:owner||'', start, end};
addedItems.push(r);
insertRow(r);
undoStack.push({type:'create',r});
if(undoStack.length>100)undoStack.splice(0,undoStack.length-100);
closeEditor();
renderTable();
rebuildTimeline(); // a new date can widen the axis
scheduleSave('create');
flash(saveInd,'added '+(isMs?'milestone':'task'));
});
}
function wireNewButton(){
$('btnNew').addEventListener('click',e=>{e.stopPropagation();closeMenus();openCreateEditor();});
}
// ── Text scale ──
//
// Applies to both lenses, because it is a statement about the reader and not
// about the chart. The multiplier is a CSS variable, so nothing has to be
// rebuilt: the browser reflows and the JS re-measures. Everything downstream
// that was measured in pixels (milestone cap collision rows, the tailroom, the
// dependency arrows) has to be re-run after a change, which is what relayout does.
function applyFontScale(){ document.documentElement.style.setProperty('--fs', String(prefs.fs)); }
function syncFsCtl(){
const i = FS_STEPS.indexOf(prefs.fs);
const pct = $('fsPct');
if(!pct) return;
pct.textContent = Math.round(prefs.fs*100)+'%';
pct.classList.toggle('off-default', prefs.fs !== 1);
$('fsDown').disabled = i <= 0;
$('fsUp').disabled = i >= FS_STEPS.length-1;
}
function setFontScale(v){
if(FS_STEPS.indexOf(v)<0 || v===prefs.fs) return;
prefs.fs = v; savePrefs(); applyFontScale(); syncFsCtl();
renderTable();
// Everything measured in pixels has to be measured again: the milestone cap
// collision rows (at 150% the tags are wider and taller and need more rows
// than they did at 100%), the tailroom, the dependency arrows.
//
// Go through the app's own debounced relayout rather than calling relayout()
// directly, but first invalidate its echo guard. That guard skips a relayout
// when the pane reports the same width and height as last time, which is
// exactly what happens here: the text grew, the pane did not. Without this the
// caps kept their old row assignments and overlapped each other at 150%.
lastPaneW = lastPaneH = lastRelayoutW = -1;
scheduleRelayout();
}
function stepFontScale(d){
const i = FS_STEPS.indexOf(prefs.fs);
setFontScale(FS_STEPS[Math.max(0, Math.min(FS_STEPS.length-1, i+d))]);
}
function wireFontScale(){
$('fsDown').addEventListener('click',()=>stepFontScale(-1));
$('fsUp').addEventListener('click',()=>stepFontScale(1));
$('fsPct').addEventListener('click',()=>setFontScale(1));
syncFsCtl();
}
// ── Fit the plan to the pane ──
//
// Zoom is not continuous here: each grain carries a minimum cell width sized to
// the widest label it draws, and below that the timeline scrolls rather than
// squashing into unreadability. So fitting to the pane means choosing a grain,
// not a scale. Finest first, take the first whose whole span clears its own
// minimum in the width we actually have: that is the most detail the pane can
// hold without scrolling sideways. fitCOL() then stretches the cells to fill it
// exactly. If even Years cannot fit, Years is still the closest we can get.
const GRAIN_ORDER = ['day','week','month','quarter','year'];
function paneWidth(){ return (timelineEl.clientWidth || (window.innerWidth - 272)) - 1; }
function bestFitGrain(){
const avail = paneWidth();
for(const g of GRAIN_ORDER){
if(rangeFor(g).units * GRAINS[g].min <= avail) return g;
}
return 'year';
}
function fitToWidth(){
const g = bestFitGrain();
const changed = g !== G();
prefs.granularity = g;
savePrefs();
$('grainSel').value = g;
// Always rebuild, even when the grain is unchanged: the pane may have been
// resized since, and this is the gesture that says "use the width I have now".
rebuildTimeline();
flash(saveInd, changed ? `fit to ${GRAINS[g].label.toLowerCase()}` : 'fit to width');
}
function wireFitButton(){
$('btnFit').addEventListener('click',fitToWidth);
}
// ── Date picker ──
const datePop=$('datePop');
let dpYear=0, dpMonth=0;
function openDatePicker(){
const ps=parseISO(planStart());
dpYear=ps.getFullYear(); dpMonth=ps.getMonth();
renderDatePicker();
const r=$('startBtn').getBoundingClientRect();
datePop.style.left=Math.min(r.left,window.innerWidth-260)+'px';
datePop.style.top=(r.bottom+6)+'px';
datePop.classList.add('show');
}
function closeDatePicker(){ datePop.classList.remove('show'); }
function renderDatePicker(){
const sel=parseISO(planStart());
const mN=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
let head, grid;
if(isDayMode()){
head=`${mN[dpMonth]} ${dpYear}`;
const first=new Date(dpYear,dpMonth,1), lead=first.getDay();
const nDays=new Date(dpYear,dpMonth+1,0).getDate();
const dow=['S','M','T','W','T','F','S'].map(d=>`<div class="dp-dow">${d}</div>`).join('');
let cells='';
for(let i=0;i<lead;i++) cells+='<div></div>';
for(let d=1;d<=nDays;d++){
const isSel=sel.getFullYear()===dpYear&&sel.getMonth()===dpMonth&&sel.getDate()===d;
cells+=`<button class="dp-cell${isSel?' sel':''}" data-day="${d}">${d}</button>`;
}
grid=`<div class="dp-grid days">${dow}${cells}</div>`;
} else {
head=`${dpYear}`;
grid=`<div class="dp-grid months">`+mN.map((m,i)=>{
const isSel=sel.getFullYear()===dpYear&&sel.getMonth()===i;
return `<button class="dp-cell${isSel?' sel':''}" data-month="${i}">${m}</button>`;
}).join('')+`</div>`;
}
const shifted=planStart()!==ORIGINAL_START;
datePop.innerHTML=`
<div class="dp-head">
<button class="dp-nav" data-nav="-1">‹</button>
<span class="dp-title">${head}</span>
<button class="dp-nav" data-nav="1">›</button>
</div>
${grid}
<div class="dp-foot">
<button class="dp-link${shifted?'':' muted'}" data-act="revert" ${shifted?'':'disabled'}
title="Put the plan back to its original start (${fmt(isDayMode()?ORIGINAL_START:ORIGINAL_START.slice(0,7))})">Revert</button>
<button class="dp-link" data-act="today">Today</button>
</div>`;
}
function wireDatePicker(){
datePop.addEventListener('click',e=>{
const nav=e.target.closest('[data-nav]');
if(nav){ const d=+nav.dataset.nav;
if(isDayMode()){ const n=new Date(dpYear,dpMonth+d,1); dpYear=n.getFullYear(); dpMonth=n.getMonth(); }
else dpYear+=d;
renderDatePicker(); return; }
const act=e.target.closest('[data-act]');
if(act){
if(act.dataset.act==='revert') shiftPlanTo(ORIGINAL_START,'start-revert');
else { const t=new Date(); shiftPlanTo(isDayMode()?isoDay(t):isoDay(new Date(t.getFullYear(),t.getMonth(),1)),'start-today'); }
closeDatePicker(); return;
}
const cell=e.target.closest('.dp-cell');
if(!cell) return;
let iso;
if(cell.dataset.day!==undefined) iso=isoDay(new Date(dpYear,dpMonth,+cell.dataset.day));
else iso=isoDay(new Date(dpYear,+cell.dataset.month,parseISO(planStart()).getDate()));
shiftPlanTo(iso,'start-picker');
closeDatePicker();
});
$('startBtn').addEventListener('click',e=>{
e.stopPropagation();
datePop.classList.contains('show') ? closeDatePicker() : openDatePicker();
});
document.addEventListener('click',e=>{ if(!e.target.closest('.datepop')&&!e.target.closest('#startBtn')) closeDatePicker(); });
}
// ── Resizable name column ──
// Long task names were simply cut off with no way to see them. The width persists,
// and a double-click sizes it to the longest name currently rendered.
const LABEL_MIN=120, LABEL_MAX=760;
function applyLabelWidth(w){
if(w) document.documentElement.style.setProperty('--label-w', w+'px');
else document.documentElement.style.removeProperty('--label-w');
scheduleRelayout();
}
function wireLabelColumnResize(){
if(prefs.labelW) applyLabelWidth(prefs.labelW);
(()=>{
const rz=$('colResizer');
let startX=0, startW=0, dragging=false;
rz.addEventListener('mousedown',e=>{
e.preventDefault();
dragging=true; startX=e.clientX; startW=labelsEl.getBoundingClientRect().width;
rz.classList.add('dragging'); document.body.classList.add('col-resizing');
});
document.addEventListener('mousemove',e=>{
if(!dragging)return;
const w=Math.round(Math.max(LABEL_MIN,Math.min(LABEL_MAX,startW+(e.clientX-startX))));
prefs.labelW=w; applyLabelWidth(w);
});
document.addEventListener('mouseup',()=>{
if(!dragging)return;
dragging=false; rz.classList.remove('dragging'); document.body.classList.remove('col-resizing');
savePrefs();
});
// Double-click: measure the widest rendered name and fit to it.
rz.addEventListener('dblclick',()=>{
const probe=document.createElement('span');
probe.style.cssText='position:absolute;visibility:hidden;white-space:nowrap;font-family:Satoshi,sans-serif;font-size:12px;';
document.body.appendChild(probe);
let max=0;
labelsEl.querySelectorAll('.label-row').forEach(el=>{
probe.textContent=el.textContent;
max=Math.max(max,probe.offsetWidth);
});
probe.remove();
const w=Math.round(Math.max(LABEL_MIN,Math.min(LABEL_MAX,max+40)));
prefs.labelW=w; applyLabelWidth(w); savePrefs();
});
})();
// Close menus on outside click
document.addEventListener('click',e=>{if(!e.target.closest('.dropdown')&&!e.target.closest('.act-btn'))closeMenus();});
}
// ── Draggable editor ──
let edDrag=null;
function wireEditorDrag(){
editorEl.addEventListener('mousedown',e=>{
if(!e.target.closest('.drag-bar'))return;
e.preventDefault();
const rect=editorEl.getBoundingClientRect();
edDrag={sx:e.clientX-rect.left,sy:e.clientY-rect.top};
});
document.addEventListener('mousemove',e=>{
if(!edDrag)return;
editorEl.style.left=Math.max(0,e.clientX-edDrag.sx)+'px';
editorEl.style.top=Math.max(0,e.clientY-edDrag.sy)+'px';
});
document.addEventListener('mouseup',()=>{edDrag=null;});
}
// ── Table view ──
//
// The second lens. Every task and milestone is one row, with the owner, the
// dates, the status and the notes editable in place. An edit here is the same
// kind of event as a drag on the chart: it mutates the item, it goes through
// scheduleSave, and it lands in the sidecar and the changelog. Nothing about
// the table is a separate store.
const tableBody=$('tableBody'), tableHead=$('tableHead'), ownerListEl=$('ownerList');
const ownerSel=$('tblOwner'), statusSel=$('tblStatus'), searchInp=$('tblSearch'), countEl=$('tblCount');
const STATUS_OPTS=STATUS_ORDER;
const UNASSIGNED='__unassigned__'; // sentinel, so a real owner can never collide with it
// null means plan order: the order the rows are actually in, which is the only
// order it makes sense to drag rows around in. A column sort is a lens over that.
let sortKey='deadline', sortDir=1, tblSearchTimer=null;
function restoreTableSort(){
if(prefs.tableSort){
sortKey=prefs.tableSort.key||null;
sortDir=prefs.tableSort.dir===-1?-1:1;
}
}
function manualOrder(){ return !sortKey; }
function esc(s){return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
function isGroup(r){return r.type==='group';}
function isMs(r){return r.type==='milestone';}
// A task's deadline is its end; a milestone's is the date it stands on. One
// column, so sorting by deadline sorts the whole plan at once.
function deadlineOf(r){return isMs(r)?(r.date||''):(r.end||'');}
function phaseNameOf(r){const p=phases[r.phase];return p?p.name:'';}
function ownerOf(r){return (r.owner||'').trim();}
// Only http and https ever become an href. Everything else, javascript: above
// all, falls through to inert text: the label still shows, it just is not a
// link. new URL() also kills protocol-relative "//evil.example" and any scheme
// smuggled past a naive regex.
function safeUrl(u){
if(typeof u!=='string')return '';
const t=u.trim();
if(!/^https?:\/\//i.test(t))return '';
try{ const q=new URL(t); return (q.protocol==='http:'||q.protocol==='https:') ? q.href : ''; }
catch(e){ return ''; }
}
// provenance is {label, url} or a bare string label. Normalised once here so
// nothing downstream has to care which shape the data file used.
function provOf(r){
const p=r&&r.provenance;
if(!p)return null;
if(typeof p==='string'){ const l=p.trim(); return l?{label:l,url:''}:null; }
if(typeof p==='object'){
const l=String(p.label==null?'':p.label).trim(), u=safeUrl(p.url);
if(!l&&!u)return null;
return {label:l||u, url:u};
}
return null;
}
function provLabel(r){ const p=provOf(r); return p?p.label:''; }
// What goes in the sidecar and the changelog: the bare string when there is no
// url, so a hand-written provenance stays hand-written.
function provValue(r){ const p=provOf(r); return p ? (p.url?{label:p.label,url:p.url}:p.label) : ''; }
function provSig(r){ const p=provOf(r); return p ? p.label+'\u0001'+p.url : ''; }
function msIndexOf(r){return items.filter(q=>isMs(q)).indexOf(r);}
function rowKey(r){return isMs(r)?('m:'+msIndexOf(r)):('t:'+r.id);}
function rowItem(key){
if(!key)return null;
if(key.charAt(0)==='t')return items.find(r=>r.id===key.slice(2));
return msByIndex(+key.slice(2));
}
// Re-render the chart's own label row, so a status changed in the table is
// already true on the chart when you toggle back.
function refreshLabelRow(r){
if(!r||!r._lDiv||!r.id)return;
const sk=statusOf(r);
const tg=sk?`<span class="tag ${statusTagFor(sk)}">${esc(statusTextFor(sk))}</span>`:'';
// into the name span only: rewriting the row would take the edit button with it
if(r._nameSpan)r._nameSpan.innerHTML=esc(r.name)+tg;
else r._lDiv.innerHTML=esc(r.name)+tg;
}
function distinctOwners(){
const s=new Set();
items.forEach(r=>{if(isGroup(r))return;const o=ownerOf(r);if(o)s.add(o);});
return [...s].sort((a,b)=>a.localeCompare(b));
}
function syncOwnerControls(){
const owners=distinctOwners();
// On the first render there are no options yet, so a value set from prefs would
// not have stuck. Fall back to the saved filter rather than the empty select.
const cur=ownerSel.options.length?ownerSel.value:(prefs.tableOwner||'');
ownerSel.innerHTML='<option value="">All owners</option>'
+owners.map(o=>`<option value="${esc(o)}">${esc(o)}</option>`).join('')
+`<option value="${UNASSIGNED}">Unassigned</option>`;
ownerSel.value = (cur===''||cur===UNASSIGNED||owners.includes(cur)) ? cur : '';
// Typing an owner offers the names already on the plan without forcing them.
ownerListEl.innerHTML=owners.map(o=>`<option value="${esc(o)}"></option>`).join('');
}
// The value a column sorts on. It lives out here rather than inside tableRows
// because a keyboard move has to ask "which rows does the sort consider tied with
// this one", and the only safe answer is the one the sort itself used.
function sortValueOf(r,key){
if(key==='name')return (r.name||'').toLowerCase();
if(key==='phase')return (phaseNameOf(r)||'').toLowerCase();
if(key==='owner')return ownerOf(r).toLowerCase();
if(key==='start')return isMs(r)?'':(r.start||'');
if(key==='status'){const k=statusOf(r);const i=STATUS_ORDER.indexOf(k);return i<0?(k?'z'+k:''):String(i);}
if(key==='notes')return (r.description||'').toLowerCase();
if(key==='prov')return provLabel(r).toLowerCase();
return deadlineOf(r);
}
function tableRows(){
const q=(searchInp.value||'').trim().toLowerCase();
const own=ownerSel.value, st=statusSel.value;
const rows=[];
items.forEach(r=>{
if(isGroup(r))return;
if(r.phase!==undefined&&!phaseVis[r.phase])return;
if(isMs(r)&&!msVis)return;
const o=ownerOf(r);
if(own===UNASSIGNED){ if(o)return; } else if(own&&o!==own) return;
if(st&&statusOf(r)!==st)return;
if(q){
const hay=`${r.name||''} ${o} ${phaseNameOf(r)} ${r.description||''} ${r.status||''} ${deadlineOf(r)} ${fmt(deadlineOf(r))} ${provLabel(r)}`.toLowerCase();
if(hay.indexOf(q)<0)return;
}
rows.push(r);
});
if(manualOrder())return rows; // already in plan order
const val=r=>sortValueOf(r,sortKey);
// A blank sorts last in both directions: an unassigned row should never push
// the thing you are looking for off the top of the list.
rows.sort((a,b)=>{
const va=val(a), vb=val(b);
if(va===vb) return (a._itemIdx||0)-(b._itemIdx||0);
if(va==='') return 1;
if(vb==='') return -1;
return va<vb ? -sortDir : sortDir;
});
return rows;
}
function syncSortHeaders(){
tableHead.querySelectorAll('th[data-sort]').forEach(th=>{
const on=th.dataset.sort===sortKey;
th.classList.toggle('sorted',on);
const old=th.querySelector('.sort-caret'); if(old)old.remove();
if(on){const c=document.createElement('span');c.className='sort-caret';c.textContent=sortDir===1?'▲':'▼';th.appendChild(c);
th.title='Sorted '+(sortDir===1?'ascending':'descending')+'. Click again for plan order, which is the order you can drag rows in.';}
else th.title='Sort by this column';
});
}
// Blank when absent, never the string "undefined". A link only when the url
// survived safeUrl; otherwise the same chip as plain text, so a javascript: url
// shows its label and does nothing at all.
function provCell(r){
const p=provOf(r);
if(!p)return '';
const tip=esc(p.label+(p.url?', '+p.url:''));
if(p.url) return `<a class="prov-chip" href="${esc(p.url)}" target="_blank" rel="noopener noreferrer" title="${tip}">${esc(p.label)}</a>`;
return `<span class="prov-chip" title="${tip}">${esc(p.label)}</span>`;
}
function renderTable(){
if(!tableBody)return;
syncOwnerControls();
syncSortHeaders();
syncSortLock();
applyColWidths();
const rows=tableRows();
const total=items.filter(r=>!isGroup(r)).length;
countEl.textContent = rows.length===total ? `${total} items` : `${rows.length} of ${total} items`;
if(!rows.length){tableBody.innerHTML='<tr><td colspan="9" class="table-empty">No items match these filters.</td></tr>';return;}
tableBody.innerHTML=rows.map(r=>{
const ms=isMs(r), editable=ms||!!r.id;
const o=ownerOf(r), st=statusOf(r), dl=deadlineOf(r), sd=ms?'':(r.start||'');
const dot=r.phase!==undefined?`<span class="phase-dot" style="background:${esc(phaseColor(r.phase))}"></span>`:'';
const dates=ms?(dl?fmt(dl):''):((sd&&dl)?fmt(sd)+' → '+fmt(dl):'');
const ed=editable?' editable':'';
return `<tr data-key="${esc(rowKey(r))}"${ms?' class="is-ms"':''}>`
+`<td class="c-name${ed}" data-field="name"><span class="trunc" data-title="${esc(r.name)}" data-dates="${esc(dates)}" data-body="${esc(r.description||'')}">`
+`${ms?'<span class="ms-mark">◆</span>':''}${esc(r.name)}</span></td>`
+`<td class="c-phase${ed}" data-field="phase">${dot}${esc(phaseNameOf(r))}</td>`
+`<td class="c-owner${ed}${o?'':' empty'}" data-field="owner">${o?esc(o):'unassigned'}</td>`
+(ms?'<td class="c-date empty"></td>'
:`<td class="c-date${ed}${sd?'':' empty'}" data-field="start">${sd?esc(fmt(sd)):'not set'}</td>`)
+`<td class="c-date${ed}${dl?'':' empty'}" data-field="deadline">${dl?esc(fmt(dl)):'not set'}</td>`
+`<td class="c-status${ed}" data-field="status">${st?`<span class="tag ${statusTagFor(st)}">${esc(statusTextFor(st))}</span>`:'<span class="tag tag-not-started">none</span>'}</td>`
+`<td class="c-notes${ed}${r.description?'':' empty'}" data-field="notes"><span class="trunc">${r.description?esc(r.description):'none'}</span></td>`
+`<td class="c-prov${ed}" data-field="prov">${provCell(r)}</td>`
+`<td class="c-del">${editable?`<button class="row-del" type="button" title="Delete this row from the plan">×</button>`:''}</td>`
+'</tr>';
}).join('');
markFocusedRow();
}
// ── Drag a lane ──
//
// Group headers only exist in the chart's label column (the table is a flat list
// of tasks and milestones, with the lane in a column instead), so moving a whole
// lane is a chart gesture. Dragging one takes its entire block of rows with it as
// one move, one undo, one changelog entry.
//
// phases[] itself is NOT reordered: a phase's index is written into every item's
// `phase` field and into the sidecar, so renumbering lanes would silently
// re-home every row in the plan. Only the row order changes, which means the
// legend can list lanes in a different order than the chart draws them.
let laneDrag=null;
function laneBlock(g){
const gi=items.indexOf(g);
if(gi<0)return null;
let end=gi+1;
while(end<items.length&&items[end].type!=='group')end++;
return {gi,end,rows:items.slice(gi,end)};
}
function restoreTransientCollapse(){
if(!transientCollapse)return;
collapsed.clear();
transientCollapse.forEach(n=>collapsed.add(n));
transientCollapse=null;
applyCollapse();layoutMsCaps();sizeTailroom();drawDeps();
}
function endLaneDrag(commit){
if(!laneDrag)return;
clearTimeout(laneDrag.timer);
const d=laneDrag; laneDrag=null;
restoreTransientCollapse();
document.body.classList.remove('row-dragging');
hideDropIndicators();
(d.block?d.block.rows:[]).forEach(r=>{if(r._lDiv)r._lDiv.classList.remove('dragging');if(r._bDiv)r._bDiv.classList.remove('dragging');});
if(!commit||!d.armed||d.targetGroup==null||d.targetGroup===d.g)return;
const blk=laneBlock(d.g); if(!blk)return;
snapshotOrder(); // one undo for the whole lane
const rows=blk.rows;
const rest=items.filter(r=>!rows.includes(r));
const tb=laneBlock(d.targetGroup);
let at=rest.indexOf(d.targetGroup);
if(at<0)at=rest.length;
else if(!d.before){ // after the target lane's whole block
const tRows=tb?tb.rows:[d.targetGroup];
at=rest.indexOf(tRows[tRows.length-1])+1;
}
rest.splice(at,0,...rows);
items.length=0;items.push(...rest);
reflowRows();applyFilter();refreshChartGeometry();renderTable();
moveHint='the '+groupLabel(d.g.label)+' lane';
scheduleSave('drag-lane');
}
function wireLaneDrag(){
labelsEl.addEventListener('mousedown',e=>{
if(e.button!==0)return;
const row=e.target.closest('.label-row.group');
if(!row)return;
const g=items.find(r=>r._lDiv===row);
if(!g)return;
laneDrag={g,row,x:e.clientX,y:e.clientY,armed:false,targetGroup:null};
laneDrag.timer=setTimeout(()=>{
if(!laneDrag)return;
laneDrag.armed=true;
laneDrag.block=laneBlock(g);
// Collapse every lane for the duration. Moving a lane means choosing a place
// among the other lanes, and with 70 rows expanded the place you are aiming
// for is usually off-screen. This is transient: the exact prior state is
// restored on drop or cancel, and it is never written to prefs.
transientCollapse=[...collapsed];
phases.forEach((p,i)=>collapsed.add(i));
applyCollapse();layoutMsCaps();sizeTailroom();drawDeps();
(laneDrag.block?laneDrag.block.rows:[]).forEach(r=>{if(r._lDiv)r._lDiv.classList.add('dragging');if(r._bDiv)r._bDiv.classList.add('dragging');});
document.body.classList.add('row-dragging');
hideTip();
},350);
});
document.addEventListener('mousemove',e=>{
if(!laneDrag)return;
if(!laneDrag.armed){
if(Math.abs(e.clientX-laneDrag.x)>4||Math.abs(e.clientY-laneDrag.y)>4){clearTimeout(laneDrag.timer);laneDrag=null;}
return;
}
e.preventDefault();
const lr=labelsEl.getBoundingClientRect();
const y=e.clientY-lr.top+labelsEl.scrollTop;
const groups=items.filter(r=>r.type==='group'&&r!==laneDrag.g);
let target=null,before=true;
for(const g of groups){
const blk=laneBlock(g); if(!blk)continue;
const top=g._lDiv.offsetTop;
const last=blk.rows[blk.rows.length-1];
const bottom=last._lDiv.offsetTop+last._lDiv.offsetHeight;
if(y<(top+bottom)/2){target=g;before=true;break;}
target=g;before=false;
}
laneDrag.targetGroup=target;laneDrag.before=before;
if(!target){hideDropIndicators();return;}
const blk=laneBlock(target);
const anchor=before?target:blk.rows[blk.rows.length-1];
const top=before?anchor._lDiv.offsetTop:anchor._lDiv.offsetTop+anchor._lDiv.offsetHeight;
labelDropInd.style.top=top+'px';labelDropInd.classList.add('show');
const btop=before?anchor._bDiv.offsetTop:anchor._bDiv.offsetTop+anchor._bDiv.offsetHeight;
barDropInd.style.top=btop+'px';barDropInd.classList.add('show');
});
document.addEventListener('mouseup',()=>endLaneDrag(true));
document.addEventListener('keydown',e=>{if(e.key==='Escape'&&laneDrag)endLaneDrag(false);},true);
}
// ── Resizable table columns ──
//
// Same idiom as the name-column divider: drag to resize, double-click to fit the
// longest content. Widths persist in prefs, because a column width is how one
// person reads the table rather than a fact about the plan.
//
// The first resize snapshots every column's current width and switches the table
// to fixed layout. Auto layout treats a width as a suggestion and re-solves the
// whole row, so one column being dragged would shove its neighbours around;
// fixed layout makes the number you dragged to the number you get.
const COL_MIN=56;
function colKeys(){ return [...tableHead.querySelectorAll('th')].map(th=>th.dataset.sort||'del'); }
function fixedCols(){ return prefs.tableCols&&Object.keys(prefs.tableCols).length>0; }
// The default sizing rule, and it is a rule about COLUMN TYPES rather than about
// content: a name and a note are wide because they are prose, dates and status are
// narrow because their values have a known shape, and the delete column is an icon.
// The same nine numbers for every plan and every data set.
//
// This replaces a table that was in `table-layout: auto` until the user's first
// resize, which is what "columns resize depending on where I click" was. Auto
// layout re-solves every column from the body content, and renderTable() replaces
// the whole tbody on a click, an edit, a sort, a filter or a new row, so the widths
// were being recomputed constantly and visibly. Under `table-layout: fixed` the
// browser sizes columns from the header row alone and nothing in the body can
// reach them, which is the Excel model: the grid is a grid, and only you move it.
//
// Percentages rather than pixels so the grid fills the pane it is given. The first
// drag calls snapshotColWidths(), which freezes every column to its current pixel
// width, and the table is pixel-driven from then on. That is also what Excel does:
// before you touch anything the columns are whatever the sheet says, and the moment
// you drag one they are numbers you own.
const COL_DEFAULTS={name:'23%',phase:'10%',owner:'8%',start:'7.5%',deadline:'7.5%',status:'8%',notes:'22%',prov:'14%',del:'30px'};
function applyColWidths(){
const table=tableHead.closest('table');
if(!table)return;
const on=fixedCols();
// The class now means "the user has set widths", not "use fixed layout": the
// layout is fixed either way.
table.classList.toggle('fixed-cols',on);
const w=prefs.tableCols||{};
tableHead.querySelectorAll('th').forEach(th=>{
const k=th.dataset.sort||'del';
th.style.width=(on&&w[k])?(w[k]+'px'):(COL_DEFAULTS[k]||'');
});
}
function snapshotColWidths(){
if(fixedCols())return;
const w={};
tableHead.querySelectorAll('th').forEach(th=>{
w[th.dataset.sort||'del']=Math.round(th.getBoundingClientRect().width);
});
prefs.tableCols=w;
}
// Measure the widest rendered cell in a column, the way the name column's
// double-click already does, using the column's own font so the number is real.
function fitColumn(key){
const idx=colKeys().indexOf(key);
if(idx<0)return;
const probe=document.createElement('span');
const sample=tableBody.querySelector('tr td:nth-child('+(idx+1)+')');
const cs=sample?getComputedStyle(sample):null;
probe.style.cssText='position:absolute;visibility:hidden;white-space:nowrap;'+
(cs?`font:${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize}/1 ${cs.fontFamily};`:'');
document.body.appendChild(probe);
let max=0;
probe.textContent=tableHead.querySelectorAll('th')[idx].textContent;
max=probe.offsetWidth;
tableBody.querySelectorAll('tr td:nth-child('+(idx+1)+')').forEach(td=>{
probe.textContent=td.textContent;
max=Math.max(max,probe.offsetWidth);
});
probe.remove();
snapshotColWidths();
prefs.tableCols[key]=Math.max(COL_MIN,Math.min(720,Math.round(max)+28));
savePrefs();applyColWidths();
}
function wireTableColumnResize(){
(function(){
let grip=null;
tableHead.querySelectorAll('th').forEach((th,i,all)=>{
if(i===all.length-1)return; // nothing to drag past the last edge
const g=document.createElement('div');
g.className='col-grip';
g.title='Drag to resize this column. Double-click to fit the widest value.';
g.addEventListener('mousedown',e=>{
e.preventDefault();e.stopPropagation();
snapshotColWidths();
grip={key:th.dataset.sort||'del',x:e.clientX,w:Math.round(th.getBoundingClientRect().width),el:g};
g.classList.add('dragging');document.body.classList.add('col-sizing');
});
g.addEventListener('click',e=>e.stopPropagation()); // never sorts
g.addEventListener('dblclick',e=>{e.preventDefault();e.stopPropagation();fitColumn(th.dataset.sort||'del');});
th.appendChild(g);
});
document.addEventListener('mousemove',e=>{
if(!grip)return;
prefs.tableCols[grip.key]=Math.max(COL_MIN,Math.min(720,grip.w+(e.clientX-grip.x)));
applyColWidths();
});
document.addEventListener('mouseup',()=>{
if(!grip)return;
grip.el.classList.remove('dragging');document.body.classList.remove('col-sizing');
grip=null;document.body.classList.remove('col-sizing');savePrefs();
});
})();
}
// ── Drag a row to reorder it ──
//
// Arming is a 350ms click-and-hold. Everything the table already does on click
// (open a cell editor, delete a row, sort a header) is a short click, so none of
// it had to change and none of it can be swallowed by a drag that never armed.
//
// Dragging is only offered in plan order. Under a column sort the rows on screen
// are not the rows in the plan, so a drop indicator between two of them would be
// describing a position that does not exist. Rather than silently reordering the
// underlying plan to match a gesture made against a sorted view, or quietly
// flipping the sort off when the user presses the mouse down, the row says why
// and offers the way back.
const rowDrop=$('rowDrop'), sortLockNote=$('sortLockNote');
let rowDrag=null, autoScroll=null;
function syncSortLock(){
if(!sortLockNote)return;
if(manualOrder()){sortLockNote.style.display='none';sortLockNote.textContent='';return;}
sortLockNote.style.display='';
sortLockNote.innerHTML='Sorted, so rows cannot be dragged. <button type="button" id="toPlanOrder">Back to plan order</button>';
const b=$('toPlanOrder');
if(b)b.addEventListener('click',()=>{sortKey=null;prefs.tableSort={key:null,dir:1};savePrefs();renderTable();});
}
function endRowDrag(commit){
if(!rowDrag)return;
clearTimeout(rowDrag.timer);
clearInterval(autoScroll); autoScroll=null;
const d=rowDrag; rowDrag=null;
document.body.classList.remove('row-dragging');
rowDrop.classList.remove('show');
if(d.tr)d.tr.classList.remove('row-lifted');
document.querySelectorAll('#tableBody tr.lane-target').forEach(t=>t.classList.remove('lane-target'));
if(!commit||!d.armed||d.targetIdx==null)return;
const r=d.r, target=d.targetItem;
if(!target||target===r)return;
const fromPhase=r.phase;
const toPhase=target.phase;
// One undo for the whole gesture, position and lane together.
undoStack.push({type:'phase',r,phase:fromPhase,order:items.map(rowOrderKey)});
if(undoStack.length>100)undoStack.splice(0,undoStack.length-100);
const cur=items.indexOf(r);
if(cur<0)return;
items.splice(cur,1);
let at=items.indexOf(target);
if(at<0)at=items.length; else if(!d.before)at+=1;
items.splice(at,0,r);
if(toPhase!==undefined&&toPhase!==fromPhase){
r.phase=toPhase;
if(r.id&&barEls[r.id])barEls[r.id].bar.style.background=phaseColor(toPhase);
if(r._msLine)r._msLine.style.color=phaseColor(toPhase);
}
reflowRows();applyFilter();refreshChartGeometry();renderTable();
// A cross-lane drop already reports itself as a phase move, which reads better
// than "reordered rows"; a same-lane drop is a reorder and says which row moved.
moveHint='"'+(r.name||'a row')+'"';
scheduleSave('drag-row');
}
function rowDragMove(e){
if(!rowDrag)return;
if(!rowDrag.armed){
if(Math.abs(e.clientX-rowDrag.x)>4||Math.abs(e.clientY-rowDrag.y)>4){clearTimeout(rowDrag.timer);rowDrag=null;}
return;
}
e.preventDefault();
const scroller=document.querySelector('.table-scroll');
const sr=scroller.getBoundingClientRect();
// auto-scroll when the pointer sits near either edge of the pane
const EDGE=44;
let dir=0;
if(e.clientY<sr.top+EDGE)dir=-1; else if(e.clientY>sr.bottom-EDGE)dir=1;
if(dir&&!autoScroll){autoScroll=setInterval(()=>{scroller.scrollTop+=dir*14;},16);}
else if(!dir&&autoScroll){clearInterval(autoScroll);autoScroll=null;}
else if(dir&&autoScroll){clearInterval(autoScroll);autoScroll=setInterval(()=>{scroller.scrollTop+=dir*14;},16);}
const rows=[...tableBody.querySelectorAll('tr[data-key]')].filter(t=>t!==rowDrag.tr);
let target=null, before=true;
for(const t of rows){
const b=t.getBoundingClientRect();
if(e.clientY<b.top+b.height/2){target=t;before=true;break;}
target=t;before=false;
}
document.querySelectorAll('#tableBody tr.lane-target').forEach(t=>t.classList.remove('lane-target'));
if(!target){rowDrop.classList.remove('show');rowDrag.targetIdx=null;return;}
const tb=target.getBoundingClientRect();
rowDrop.style.top=((before?tb.top:tb.bottom)-sr.top+scroller.scrollTop)+'px';
rowDrop.classList.add('show');
rowDrag.targetIdx=1;
rowDrag.targetItem=rowItem(target.dataset.key);
rowDrag.before=before;
if(rowDrag.targetItem&&rowDrag.targetItem.phase!==rowDrag.r.phase)target.classList.add('lane-target');
}
function wireTableRowDrag(){
tableBody.addEventListener('mousedown',e=>{
if(e.button!==0)return;
if(e.target.closest('.row-del')||e.target.closest('.cell-edit'))return;
const tr=e.target.closest('tr[data-key]');if(!tr)return;
if(!manualOrder())return; // sorted: the note explains why
const r=rowItem(tr.dataset.key);if(!r)return;
rowDrag={tr,r,x:e.clientX,y:e.clientY,armed:false,targetIdx:null};
rowDrag.timer=setTimeout(()=>{
if(!rowDrag)return;
rowDrag.armed=true;
tr.classList.add('row-lifted');
document.body.classList.add('row-dragging');
hideTip();
},350);
});
document.addEventListener('mousemove',rowDragMove);
document.addEventListener('mouseup',()=>{const wasArmed=rowDrag&&rowDrag.armed;endRowDrag(true);
if(wasArmed)dragEndedAt=Date.now();});
// Esc abandons the drag and leaves the plan exactly as it was.
document.addEventListener('keydown',e=>{if(e.key==='Escape'&&rowDrag)endRowDrag(false);},true);
}
// A drag must not also count as a click on the cell it finished over. Timestamped
// rather than a sticky flag: a drag that ends outside the table never produces a
// click here, and a flag left armed would then eat the next real one.
let dragEndedAt=0;
function wireRowDragClickGuard(){
tableBody.addEventListener('click',e=>{
if(Date.now()-dragEndedAt<150){e.stopPropagation();e.preventDefault();}
},true);
}
// ── Inline cell editing ──
//
// The focused row is the row whose editor you touched last. It is not a
// second click target competing with the one-click cell editors: nothing new to
// aim at, no extra gesture to learn, and the thing you were already doing tells
// the app which row you mean. Esc gives it back, innermost first: the field, then
// the row.
let focusedKey=null;
function markFocusedRow(){
if(!tableBody)return;
tableBody.querySelectorAll('tr.row-focus').forEach(t=>t.classList.remove('row-focus'));
if(!focusedKey)return;
const tr=[...tableBody.querySelectorAll('tr[data-key]')].find(t=>t.dataset.key===focusedKey);
if(tr)tr.classList.add('row-focus');
}
function setFocusedRow(k){ focusedKey=k||null; markFocusedRow(); }
function visibleRowEls(){ return [...tableBody.querySelectorAll('tr[data-key]')]; }
function focusedRowEl(){ return focusedKey?visibleRowEls().find(t=>t.dataset.key===focusedKey)||null:null; }
// Keep the focused row in view, and move the pane by the least it can get away
// with. The arrows themselves never scroll (preventDefault sees to that, which is
// the whole point of the request); this puts back exactly the amount of scrolling
// that walking off the edge actually needs. Done by hand rather than with
// scrollIntoView because the header is sticky, so the usable top of the pane is
// its bottom edge and not the pane's, and because only this scroller should move.
function scrollRowIntoView(tr){
const sc=document.querySelector('.table-scroll');
if(!sc||!tr)return;
const pane=sc.getBoundingClientRect(), row=tr.getBoundingClientRect();
const top=Math.max(pane.top, tableHead.getBoundingClientRect().bottom);
if(row.top<top) sc.scrollTop-=(top-row.top);
else if(row.bottom>pane.bottom) sc.scrollTop+=(row.bottom-pane.bottom);
}
function focusRowAt(i){
const rows=visibleRowEls();
if(!rows.length)return false;
const tr=rows[Math.max(0,Math.min(rows.length-1,i))];
setFocusedRow(tr.dataset.key);
scrollRowIntoView(tr);
return true;
}
// Up and Down walk the rows the table is SHOWING, so a sort or a filter changes
// what "the next row" means and the walk follows without knowing anything about
// either.
function moveRowFocus(dir){
const rows=visibleRowEls();
if(!rows.length)return;
const i=rows.findIndex(t=>t.dataset.key===focusedKey);
// Nothing focused yet, so the first press lands on the first visible row rather
// than moving from nowhere. If a row was touched earlier it is already the
// focused row and the walk simply carries on from it.
if(i<0){ focusRowAt(0); return; }
focusRowAt(i+dir);
}
// Arrow navigation belongs to the table and only when the table is otherwise idle,
// because a native select and a date input do their own thing with the arrows and
// they have to win.
function tableNavReady(){ return prefs.view==='table' && appIsIdle(); }
// Tab commits the field and opens the next one to the right, ready to type. The
// walk is over the rendered cells, so it follows the visible order under a sort,
// skips the milestone start cell and the delete button because neither carries
// `editable`, and wraps around the ends rather than dead-ending.
function tabToCell(key,field,dir){
const rows=[...tableBody.querySelectorAll('tr[data-key]')];
if(!rows.length)return;
const ri=rows.findIndex(t=>t.dataset.key===key);
if(ri<0)return; // the edit filtered its own row away
const cells=[...rows[ri].querySelectorAll('td.editable[data-field]')];
const ci=cells.findIndex(c=>c.dataset.field===field);
if(ci>=0&&ci+dir>=0&&ci+dir<cells.length){ startCellEdit(cells[ci+dir]); return; }
// Past the end of the row: the next row that has anything editable in it.
for(let n=1;n<=rows.length;n++){
const nr=rows[(ri+dir*n+rows.length*n)%rows.length];
const nc=[...nr.querySelectorAll('td.editable[data-field]')];
if(nc.length){ startCellEdit(dir>0?nc[0]:nc[nc.length-1]); return; }
}
}
let tblEditing=null;
function startCellEdit(td){
if(tblEditing)return;
const tr=td.closest('tr'); if(!tr)return;
setFocusedRow(tr.dataset.key);
const r=rowItem(tr.dataset.key), field=td.dataset.field;
if(!r||!field)return;
let el;
if(field==='status'){
el=document.createElement('select');
el.innerHTML='<option value="">none</option>'+STATUS_OPTS.map(x=>`<option value="${x}">${statusLabel[x]}</option>`).join('');
const cur=statusOf(r);
if(cur&&!STATUS_OPTS.includes(cur))el.innerHTML+=`<option value="${esc(cur)}">${esc(cur)}</option>`;
el.value=cur;
} else if(field==='start'||field==='deadline'){
el=document.createElement('input');el.type='date';
el.value=(field==='start'?r.start:deadlineOf(r))||'';
} else if(field==='name'){
el=document.createElement('input');el.type='text';el.value=r.name||'';
} else if(field==='phase'){
el=document.createElement('select');
el.innerHTML=phases.map((p,i)=>`<option value="${i}">${esc(p.name)}</option>`).join('');
el.value=String(r.phase);
} else if(field==='prov'){
// The label only. A url is a thing you paste from somewhere, not something
// you retype in a grid cell, so it is preserved as-is underneath.
el=document.createElement('input');el.type='text';
el.placeholder='where this came from';el.value=provLabel(r);
} else if(field==='owner'){
el=document.createElement('input');el.type='text';el.setAttribute('list','ownerList');
el.placeholder='one name';el.value=r.owner||'';
} else {
el=document.createElement('input');el.type='text';el.value=r.description||'';
}
el.className='cell-input';
// Overlaid, not swapped in: the cell keeps its own content underneath so its
// intrinsic width never changes, and the overlay is out of flow so the column
// cannot resize around the editor.
const wrap=document.createElement('div');wrap.className='cell-edit';wrap.appendChild(el);
td.appendChild(wrap);
// One click has to leave a usable editor, not just a rendered one. This runs
// synchronously inside the click, which is the only place showPicker() is
// allowed to open anything.
el.focus();
if(el.tagName==='SELECT'||el.hasAttribute('list')){
// A select or a datalist is a list of choices: the click that opens it should
// show the choices. Feature-detected and swallowed, so a browser without
// showPicker just gets a focused control instead of a console error.
if(typeof el.showPicker==='function'){ try{ el.showPicker(); }catch(e){} }
}
if(el.tagName==='INPUT'&&el.type==='text'){
// Short identifying values are usually replaced wholesale, so select all and
// let typing overwrite. Notes are prose you almost always add to, so the
// caret goes to the end instead of destroying what is there on first keypress.
if(field==='notes'){ const n=el.value.length; el.setSelectionRange(n,n); }
else el.select();
}
// Date cells get focus so the keyboard works at once, but the native calendar
// is deliberately NOT forced open: it covers the rows below, steals the
// keyboard, and the field is usually a small nudge that is faster to type. The
// picker icon is right there for anyone who wants it.
let done=false;
const finish=save=>{
if(done)return;done=true;tblEditing=null;
// Blur before the re-render so focus lands on the document rather than being
// orphaned on a node that is about to be thrown away.
try{ el.blur(); }catch(e){}
if(save)commitCell(r,field,el.value);
renderTable();
};
tblEditing={td,el,r,field,cancel:()=>finish(false)};
// Ctrl+Z inside a field is the browser's own text undo, and Esc means "cancel
// this cell", not "clear the chart selection", so the app's keys stop here.
el.addEventListener('keydown',e=>{e.stopPropagation();
if(e.key==='Enter'){
// Commit and step down a row, the spreadsheet convention, so a column of
// values is Enter, type, Enter, type. It does NOT open the next editor:
// Enter again does that, and the pair reads as "done, next".
e.preventDefault();
const fromKey=tr.dataset.key;
finish(true);
const rows=visibleRowEls();
const i=rows.findIndex(t=>t.dataset.key===fromKey);
if(i>=0)focusRowAt(i+1); // clamped, so the last row commits and stays
}
else if(e.key==='Escape'){e.preventDefault();finish(false);}
else if(e.key==='Tab'){
// preventDefault here even inside a native select or a date input, or the
// browser's own focus walk carries on from the control we are about to
// throw away and lands somewhere in the chrome.
e.preventDefault();
const fromKey=tr.dataset.key, fromField=field, back=e.shiftKey;
finish(true);
tabToCell(fromKey,fromField,back?-1:1);
}});
el.addEventListener('blur',()=>finish(true));
if(el.tagName==='SELECT')el.addEventListener('change',()=>finish(true));
}
function commitCell(r,field,v){
const ms=isMs(r);
const snap=()=>{ if(ms)snapshotMs(r); else snapshot(r.id); };
if(field==='owner'){
v=v.trim().replace(/\s+/g,' ');
if((r.owner||'')===v)return;
snap();setOpt(r,'owner',v);
} else if(field==='status'){
if(statusOf(r)===v)return;
snap();setOpt(r,'status',STATUS_OPTS.includes(v)?v:'');
} else if(field==='name'){
v=v.trim();
// A row with no name cannot be identified in either lens, so refuse it.
if(!v||v===r.name)return;
snap();r.name=v;
} else if(field==='phase'){
const np=parseInt(v,10);
if(!phases[np]||np===r.phase)return;
// One undo step covers both halves: the field and the re-homing it causes.
undoStack.push({type:'phase',r,phase:r.phase,order:items.map(rowOrderKey)});
if(undoStack.length>100)undoStack.splice(0,undoStack.length-100);
moveRowToPhase(r,np);
scheduleSave('table-edit');
return;
} else if(field==='prov'){
v=v.trim();
const cur=provOf(r);
if((cur?cur.label:'')===v)return;
snap();
if(!v)delete r.provenance;
else r.provenance = (cur&&cur.url) ? {label:v,url:cur.url} : v;
} else if(field==='notes'){
v=v.trim();
if((r.description||'')===v)return;
snap();setOpt(r,'description',v);
} else if(field==='deadline'){
if(!v)return;
if(ms){ if(r.date===v)return; snap(); r.date=v; }
else { if(r.end===v)return; snap(); r.end=v;
// A deadline pulled in front of the start would draw a backwards bar.
if(r.start&&r.start>v)r.start=v; }
} else if(field==='start'){
if(!v||ms||r.start===v)return;
snap();r.start=v;
if(r.end&&r.end<v)r.end=v;
} else return;
// updateBar and refreshMsChrome both restate the tooltip, so the chart is
// already telling the truth by the time we toggle back to it.
if(ms){refreshMsChrome(r);updateMilestone(r);}
else{updateBar(r.id);refreshLabelRow(r);}
if(field==='name')refreshChartGeometry(); // a longer cap can need another row
scheduleSave('table-edit');
}
// ── Table wiring ──
// Three states per column: ascending, descending, then back to plan order. That
// last click is the way back to a draggable table, so it is the same control the
// user already reached for rather than a separate "clear sort" affordance.
function wireTableHeadAndDelete(){
tableHead.addEventListener('click',e=>{
const th=e.target.closest('th[data-sort]');if(!th)return;
const k=th.dataset.sort;
if(sortKey!==k){sortKey=k;sortDir=1;}
else if(sortDir===1)sortDir=-1;
else sortKey=null;
prefs.tableSort={key:sortKey,dir:sortDir};savePrefs();
renderTable();
});
tableBody.addEventListener('click',e=>{
const del=e.target.closest('.row-del');
if(del){
const tr=del.closest('tr'); const r=rowItem(tr&&tr.dataset.key);
if(r)deleteRow(r);
return;
}
const td=e.target.closest('td.editable');if(td)startCellEdit(td);
});
}
// One click, undoable. A confirm dialog for something Ctrl+Z already covers is
// a speed bump, not a safety net.
function deleteRow(r){
const label=r.name||r.label||'row';
if(focusedKey===rowKey(r))focusedKey=null;
const info=removeRow(r);
if(!info)return;
undoStack.push({type:'delete',r,idx:info.idx,droppedDeps:info.droppedDeps});
if(undoStack.length>100)undoStack.splice(0,undoStack.length-100);
renderTable();
clearTimeout(undoTimer);
undoToast.innerHTML='Deleted '+esc(label)+' · <kbd>Ctrl+Z</kbd>';
undoToast.classList.add('show');
undoTimer=setTimeout(()=>{undoToast.classList.remove('show');undoToast.innerHTML='Undid change · <kbd>Ctrl+Z</kbd>';},3200);
scheduleSave('table-delete');
}
function wireTableFilters(){
ownerSel.addEventListener('change',()=>{prefs.tableOwner=ownerSel.value;savePrefs();renderTable();});
statusSel.addEventListener('change',renderTable);
searchInp.addEventListener('keydown',e=>e.stopPropagation());
searchInp.addEventListener('input',()=>{clearTimeout(tblSearchTimer);tblSearchTimer=setTimeout(renderTable,120);});
$('tblClear').addEventListener('click',()=>{
ownerSel.value='';statusSel.value='';searchInp.value='';
prefs.tableOwner='';savePrefs();renderTable();
});
}
// ── View switching ──
function applyView(){
const v = (prefs.view==='table') ? 'table' : 'gantt';
document.documentElement.setAttribute('data-view',v);
const vt=$('viewToggle');
if(vt)vt.querySelectorAll('.vt-btn').forEach(b=>{
const on=b.dataset.view===v;
b.classList.toggle('on',on);
b.setAttribute('aria-selected',on?'true':'false');
});
}
function setView(v){
if(v!=='table'&&v!=='gantt')return;
if(prefs.view===v)return;
prefs.view=v;savePrefs();applyView();
if(v==='table'){renderTable();return;}
// The chart measured nothing while it was hidden, so lay it out from scratch
// once it is back on screen. Through the app's own debounced path with the echo
// guard invalidated, not a raw rAF: the pane reports the same size it did
// before, and a rAF here proved unreliable, which is how milestone caps
// renamed or deleted in the table came back overlapping.
lastPaneW=lastPaneH=lastRelayoutW=-1;
scheduleRelayout();
}
async function startApp(){
$('viewToggle').addEventListener('click',e=>{const b=e.target.closest('.vt-btn');if(b)setView(b.dataset.view);});
// ── Init ──
try{const r=await fetch('state');if(r.ok){const st=await r.json();if(!st.empty){applyState(st);Object.keys(barEls).forEach(id=>updateBar(id));}}}catch(e){}
// The sidecar can carry a status or a description that the chart's own label rows
// and tooltips were built from the data file with, so restate them.
items.forEach(r=>{ if(r.id)refreshLabelRow(r); else if(r.type==='milestone')refreshMsChrome(r); });
// Re-baseline AFTER restoring the sidecar, so replaying saved edits on load is
// not mistaken for a fresh round of user changes.
lastSnap=snapSemantic();
// The rev used to sit in a floating chip over the chart, where it collided with
// the milestone labels at the bottom of a tall plan. It is machinery for the AI
// handoff loop, not something a reader needs on the canvas, and it stays fully
// available at GET /changelog (and ?format=md). The header carries the app
// version instead, which is the thing a person actually wants to glance at.
// The sidecar may carry shifted dates, so reshape the axis before the first paint.
rebuildTimeline();
sizeTailroom();
drawDeps();
// "What's mine" is a standing question, so the owner filter survives a reload
// (syncOwnerControls picks prefs.tableOwner up on this first render).
renderTable();
// Say it after the first paint, so the message sits over a chart that clearly
// still works rather than over a blank page.
if(UNDATED.length){
const dw=$('dataWarn');
const names=UNDATED.map(r=>r.id||r.name||r.label||'(unnamed)');
dw.innerHTML=`<button class="dw-close" title="Dismiss">×</button>
<div class="dw-title">${UNDATED.length} row${UNDATED.length>1?'s':''} left out of this chart</div>
<div>A task needs a start and an end; a milestone needs a date. The rest of the plan is drawn and editable, and these rows are untouched in the data file.</div>
<div class="dw-rows">${esc(names.slice(0,12).join(', '))}${names.length>12?', and '+(names.length-12)+' more':''}</div>`;
dw.classList.add('show');
dw.querySelector('.dw-close').addEventListener('click',()=>dw.classList.remove('show'));
}
}
// ── Startup ──
//
// Everything above this line declares or defines. Nothing above it runs on its own,
// and this is the only ordered sequence in the module: it is the whole of startup.
//
// The file used to open with a two-hundred-statement imperative prologue interleaved
// with the declarations it depended on, which put three separate shipped-or-caught
// bugs in the same temporal dead zone (STATUS_ORDER in 0.11.0, collapsed and prefs
// in 0.14.0): a statement near the top ran before a const further down existed, the
// module threw part-way through the build, and the symptom was a chart with labels
// and no bars rather than anything that named the real cause. Function declarations
// hoist, so wrapping each block where it already sat takes file position out of the
// question entirely: by the time any of these run, every declaration in the module
// has been initialised. Anything genuinely order-dependent is declared bare and
// assigned inside its step (W, lastSnap, the relayout echo guard's baseline).
async function init(){
assignRowKeys();
dropUndatedRows();
clampFontScalePref();
applyStartupPrefs();
wireLaneToggle();
initTimeRange();
buildHeader();
wireGlobalKeys();
captureBaselineSnapshot();
wireSaveFlush();
buildLegendPhases();
buildLegendMilestone();
buildLegendDeps();
buildLegendToday();
fitTimelineToPane();
mountQHeaderSpacer();
mountQRow();
mountMRow();
sizeBarsPane();
mountGridLayer();
buildTimeAxis();
buildRows();
mountTodayLine();
mountBarTailroom();
mountLabelTailroom();
wireRelayoutObservers();
wireDepRedraw();
wireLabelClicks();
mountLabelDropIndicator();
mountBarDropIndicator();
wireLabelDragArm();
wireBarClicks();
wireBarDrags();
wireBarDblclick();
wireTooltipsAndExport();
wireImportAndHelp();
wireThemeButton();
wireMsStyleAndGrain();
wireNewButton();
wireFontScale();
wireFitButton();
wireDatePicker();
wireLabelColumnResize();
wireEditorDrag();
restoreTableSort();
wireLaneDrag();
wireTableColumnResize();
wireTableRowDrag();
wireRowDragClickGuard();
wireTableHeadAndDelete();
wireTableFilters();
await startApp();
}
await init();
})();
</script>
</body>
</html>