Theme?All Style Kit previews update live against the active theme.
Preset
Build your own
Reset Style Kit
Restore the clean Version 1 defaults for themes, roles, states, formats, icons, display scale, and glow.
Typography
Direct px · 12px minimum
Body / BaseNormal text
HeadingsTitles
LabelsField/card labels
SecondarySupporting text
MetadataSmallest text
NavigationSidebar/tabs
Buttons / ControlsInputs/actions
Display effects
Alert glow
0%
Status glow
0%
Notification glow
0%
Sidebar icons?Icons are saved for the currently selected theme. Choose another theme above to build a different icon set.
Field formats?Shared field-format contract for future modules.
Saved
Input masks?Formats as you type.
Date & time
Button roles
Theme roles
Primary
Accent
Secondary
Accent 2
Button previews
Basic
Rounded
Outline
Square
Icon buttons
Sizes
Button group
Badges
Statuses
Alert types
To-do statuses
Cards
IMAGE
Card with image
Example supporting text.
Card with button
Example supporting text.
Card with list
First item
Second item
Third item
Card with links
Example supporting text.
Card linkAnother link
ActiveLinkDisabled
Card with tabs
Example supporting text.
Card, text only
Example supporting text with no action.
Grouped multiselect
Live states
Normal
Kiosk
[Member] [Time] [Date] [Resort] [Kiosk]
Tour ParserSalesforce booking list → verified normalized Tours, read by its Header Layout.
Parser v0.7
Click here and press Ctrl+V — Waypoint will show the captured rows and the copied N of N reconciliation immediately.
Paste a booking list to verify and parse it.
CompletenessCompares Salesforce's own item count to the rows captured. A green result means the copy reconciled and no row failed parsing.
Schedule ViewShows Resort · Booking · Account · Date · Time · Mode · Status. Confirmation, Phone, Tour Type, Booked By, raw Method, and raw Virtual remain in the record.
ModeDisplays Virtual or Kiosk from the source Method + Virtual fields. Source contradictions are warnings; the parser never edits Salesforce data.
Worksheet ParserUpgrade Worksheet PDF → normalized record
Parser v0.7
Drop the worksheet PDF here, or click to choose
reads the file locally — nothing is uploaded
Drop a worksheet PDF to parse it.
ParseLabel-anchored: every value is the text between its label and the next known label. The label list is the source of truth.
ValuesCaptured verbatim (typos, formats, the Trade Value pair all preserved). Blank fields shown in italic; nothing on the form is invented.
Dashboard
Member Account
DocuBuild
Design
Routing
Form View
Template Library
Call Off
Days & Waves
Time Clock — Register Punch
Punch Type
Today's Punches
Add Punch
Edit Punch
Time Clock Audit Log
Time Card Archive
Print Time Card
View Roster
Alternate Names
Membership
Import Memberships
Update Log
Build your own theme
Choose icon
Assigning to icon
Search online when connected. Selected icons are kept locally.
Add Section
Tour Terms
Settings
Add Family
Add Resort
Add Infrastructure
Fields
Configure Field
Wires
Options
Add
Personnel
Personnel Fields
Configure Field
Template
New Post
Configure Dashboard
Who can read and post to each card is set on the access sheet (Utility › Permissions & Sections), under Dashboard.
Parse Tour Schedule
Tour Schedule Statuses
Import Member Accounts
Tidy Existing
Past Bookings
Balance Update
Member Account Setup
Raw Data
WorksheetNo worksheet selected.
Member Account
Slack Balances
Update Tour Record
{const parts=orgParts(f);parts.forEach(p=>{const name=String(p.label||p.name||'').trim(),own=String(f.label||'').trim();out.push({key:String(p.key),label:parts.length>1&&name&&orgNameKey(name)!==orgNameKey(own)?own+' '+name:own||name,type:DOC_MASK_TYPES[p.mask]||'input'})})});
orgWires().forEach(w=>out.push({key:'logo_'+w.id,label:'Logo ('+(w.wire_name||'Wire')+')',type:'image'}));return out}
/* Personnel as a Source: the General Profile fields (as set up on the Personnel tab) and the Primary Work Assignment. */
function docPeopleFields(){const out=[];peopleFields().filter(peopleFieldOn).forEach(f=>out.push({key:f.column_name||('pf:'+f.field_key),label:f.label||'Field',type:f.mask==='date'?'date':DOC_MASK_TYPES[f.mask]||'input',group:'General Profile'}));out.push({key:'photo',label:'Photo',type:'image',group:'General Profile'});
[['role','Role'],['department','Department'],['division','Division'],['location','Location'],['family','Family']].forEach(([key,label])=>out.push({key,label,type:'input',group:'Primary Work Assignment'}));return out}
/* The Family checked Parent Company in Organization: a routing Source of its own, whatever Resort the record is at. */
function docParentFamily(){return (orgRows.family||[]).find(r=>r.parent_company===true&&r.active!==false)||null}
/* Ver. Beta 34 Rev. 2: Member Account as a DocuBuild Source. Its fields are the account's fields under the labels and groups
Arrange Fields gives them; the Resort (and its Family) come through the link. */
const DOC_MEMBER_MONEY=['current_loan_payoff','current_dues','trade_value','last_purchase_amount','membership_equity'];
function docMemberType(name,input){if(DOC_MEMBER_MONEY.includes(name))return 'price';if(name==='address')return 'address';if(name==='city')return 'city';if(name==='state')return 'state';if(name==='zip')return 'zip';
return {email:'email',tel:'phone',date:'date',notes:'notes',check:'checkbox'}[input]||'input'}
function docMemberFields(){const out=[],seen=new Set(),label=n=>(L&&L.labels&&L.labels[n])||MEMBER_LAYOUT_LABELS[n]||(MEMBER_FIELD_BY_NAME[n]||{}).label||n;let L=null;try{L=memberLayoutStored()}catch(_){}
((L&&L.groups)||[]).forEach(g=>(g.rows||[]).forEach(r=>(r.fields||[]).forEach(n=>{if(seen.has(n)||!MEMBER_FIELD_BY_NAME[n]||n==='resort_id')return;seen.add(n);out.push({key:n,label:label(n),type:docMemberType(n,MEMBER_FIELD_BY_NAME[n].input),group:g.label||'Member Account'})})));
MEMBER_FIELDS.forEach(([n,l,input])=>{if(seen.has(n)||n==='resort_id')return;seen.add(n);out.push({key:n,label:label(n),type:docMemberType(n,input),group:'Other Fields'})});
[['purchase_history_total','Purchase History','price'],['open_balance','Open Balance','checkbox'],['loan_worksheet_value','Loan Balance Worksheet Value','input'],['equity_worksheet_value','Current Equity Worksheet Value','input']].forEach(([key,l,type])=>out.push({key,label:l,type,group:'Balance Update Card'}));return out}
/* Ver. Beta 34 Rev. 4: the tour a worksheet is for: its date, time, kiosk and the rest of what the Tour Schedule shows */
function docTourFields(){return [{key:'tour_date',label:'Tour Date',type:'date'},{key:'tour_time',label:'Tour Time EST',type:'time'},{key:'kiosk',label:'Kiosk',type:'input'},{key:'mode',label:'Mode',type:'input'},{key:'booking',label:'Booking',type:'input'},{key:'resort',label:'Resort',type:'input'}]}
function docTables(){return [{key:'members',name:'Member Account',fields:docMemberFields()},{key:'tours',name:'Tour',fields:docTourFields()},{key:'personnel',name:'Personnel',fields:docPeopleFields()},{key:'resorts',name:'Resort',fields:docOrgFields('resort')},{key:'families',name:'Family',fields:docOrgFields('family')},{key:'parent_company',name:'Parent Company',fields:docOrgFields('family')}]}
function docLinks(){return [{from:'personnel',to:'resorts'},{from:'resorts',to:'families'}]}
async function docOrgValues(kind,row){const out={};if(!row)return out;const k=ORG_KINDS[kind],values=profileJsonObject(row.field_values);out[k.name]=String(row[k.name]||'');out.secondary_name=String(row.secondary_name||'');out.abbreviations=String(row.abbreviations||'');if(kind==='resort')out.time_zone=String(row.time_zone||'');
orgFields().forEach(f=>orgParts(f).forEach(p=>{out[String(p.key)]=String(values[p.key]??'')}));
for(const w of orgWires()){const logo=(Array.isArray(row.logos)?row.logos:[]).find(l=>l&&l.active!==false&&!l._pending&&(l.wires||[]).map(String).includes(String(w.id)));let file=null;if(logo)try{file=await wnFileGet(PROFILE_FILES,logo.file_id)}catch(e){console.error(e)}out['logo_'+w.id]=file?.dataUrl?{name:logo.name||'Logo',dataUrl:file.dataUrl}:''}
return out}
async function docFonts(){return (await fontsAll()).map(f=>({family:f.family,weight:f.weight,style:f.style,generic:f.generic,src:f.src}))}
/* Answers a DocuBuild that has just come up with the tables and what it should show. */
async function docConnect(target,open){await orgLoad();await peopleLoad().catch(e=>console.error('Personnel & Roles could not load',e));const msg={type:'docubuild-connect',program:'Waypoint Nexus',tables:docTables(),links:docLinks(),formats:clone(state.formats||{}),library:true,fonts:await docFonts().catch(e=>{console.error(e);return []})};if(open)msg.open=open;target.postMessage(msg,'*')}
function docSourceUrl(){if(!docUrl)docUrl=URL.createObjectURL(new Blob([docSource()],{type:'text/html'}));return docUrl}
function docAlive(){return !!(docWin&&!docWin.closed)}
/* Opens the DocuBuild window. It has to run inside the click, before anything is awaited. */
function docWindow(){if(docAlive()&&(docReady||docConnecting)){try{docWin.focus()}catch(_){}return true}docReady=false;docWin=window.open(docSourceUrl(),DOC_WINDOW);if(!docWin){toast('The browser blocked DocuBuild from opening');return false}return true}
/* open is what DocuBuild should show: {view,template,record}. It goes now when DocuBuild is up, or with the connection when it has just been opened. */
function docRequest(open){if(docAlive()&&docReady)docWin.postMessage(Object.assign({type:'docubuild-open'},open),'*');else docPending=open}
function docOpen(view,template){if(!docAllow(view==='form'||view==='preview'?'read':'write'))return;if(!docWindow())return;docRequest(template?{view,template}:{view})}
/* ---- the Template Library (docubuild_templates): DocuBuild asks for every list, open, save and remove ---- */
async function docLoad(){docRows=await wnSyncAll('docubuild_templates',peopleQ)}
function docTemplates(){return docRows.filter(r=>r.active!==false&&r.template&&r.template.id).map(r=>r.template).sort((a,b)=>String(b.updatedAt||'').localeCompare(String(a.updatedAt||'')))}
function docRow(id){return docRows.find(r=>String(r.id)===String(id))||null}
async function docLibrary(msg){if(msg.op==='list'){await docLoad();return docTemplates()}
if(msg.op==='get'){await docLoad();const r=docRow(msg.templateId);return r&&r.active!==false?r.template:null}
if(msg.op==='save'){if(!docCan('write'))throw new Error('Not permitted: Docubuild is read only for your Role.');const t=msg.template;if(!t||!t.id)throw new Error('The template has no ID.');
const row={id:String(t.id),name:String(t.name||''),kind:String(t.kind||''),template:t,active:true,updated_at:new Date().toISOString()};const {error}=await sb.from('docubuild_templates').upsert(row);if(error)throw error;await docLoad();return true}
if(msg.op==='remove'){if(!docCan('write'))throw new Error('Not permitted: Docubuild is read only for your Role.');await psSave('docubuild_templates',{id:String(msg.templateId)},{active:false});await docLoad();return true}
throw new Error('Unknown Template Library request.')}
function docDate(v){const d=v?new Date(v):null;return d&&!isNaN(d)?new Intl.DateTimeFormat('en-US',{month:'2-digit',day:'2-digit',year:'2-digit'}).format(d):''}
function docPageRender(){const list=document.getElementById('docs-list'),ver=document.getElementById('docs-version');if(ver)ver.textContent=docVersion();if(!list)return;const rows=docTemplates(),icon=tablerSvg('');
list.innerHTML=rows.length?rows.map(t=>'').join(''):'
No templates yet.
'}
async function docPageEnter(){docPageRender();try{await docLoad()}catch(e){console.error(e);toast('The Template Library could not be read: '+String(e&&e.message||e))}docPageRender()}
function docPick(id){const t=docRow(id)?.template;if(!t)return;docPickedId=String(t.id);document.getElementById('docs-template-title').textContent=t.name||'Untitled template';const standalone=t.kind==='form';document.querySelectorAll('#docs-template-overlay [data-docs-view="preview"],#docs-template-overlay [data-docs-view="form"]').forEach(b=>b.disabled=standalone);openOverlay('docs-template-overlay')}
async function docRemove(){if(!docAllow('write'))return;const t=docPickedId?docRow(docPickedId)?.template:null;if(!t)return;if(await wnAsk('Remove the template '+(t.name||'Untitled template')+'?')!=='yes')return;try{await docLibrary({op:'remove',templateId:t.id});closeOverlay('docs-template-overlay');docPickedId='';docPageRender();toast('Template removed')}catch(e){wnFail(e)}}
window.addEventListener('message',e=>{const m=e.data;if(!m||typeof m!=='object')return;if(!docWin||e.source!==docWin)return;
if(m.type==='docubuild-ready'){docReady=false;docConnecting=true;const open=docPending;docPending=null;docConnect(e.source,open).then(()=>{docReady=true;if(docPending){const p=docPending;docPending=null;docRequest(p)}}).catch(console.error).finally(()=>{docConnecting=false});return}
if(m.type==='docubuild-save'){try{e.source.postMessage({type:'docubuild-saved',ok:false,message:'A worksheet does not save back to the Member Account. Change the account, then print again.'},'*')}catch(x){console.error(x)}return}
if(m.type==='docubuild-library'){docLibrary(m).then(result=>({ok:true,result})).catch(err=>{console.error(err);return {ok:false,message:String(err.message||err)}}).then(out=>{try{e.source.postMessage(Object.assign({type:'docubuild-library-result',id:m.id},out),'*')}catch(x){console.error(x)}if(m.op==='save'||m.op==='remove')docPageRender()})}});
function docBind(){document.getElementById('page-docubuild')?.addEventListener('click',e=>{const o=e.target.closest('[data-docs-open]');if(o){docOpen(o.dataset.docsOpen,'');return}const r=e.target.closest('[data-docs-template]');if(r){docPick(r.dataset.docsTemplate);return}if(e.target.closest('#docs-refresh'))docPageEnter()});
document.getElementById('docs-template-overlay')?.addEventListener('click',e=>{const v=e.target.closest('[data-docs-view]');if(v&&docPickedId){closeOverlay('docs-template-overlay');docOpen(v.dataset.docsView,docPickedId);return}if(e.target.closest('#docs-template-delete'))docRemove()})}
/* ===== Hub › Dashboard (Ver. Beta 13 Rev. 1, brought in from the v99 backend's Dashboard) =====
The Dashboard sections (Hub › Dashboard and Hub Test › Dashboard) open this page. Cards come from dashboard_cards (Configure Dashboard:
add a Feed or Calendar card, Label, Icon, Color, Visible, order, Remove). A feed card holds posts (+ New, History / Current); a calendar
card holds events (+ Event, or a day; the month's events list under it). Each post or event has an audience: Everyone, or any of people,
Roles, Divisions and Resorts; a person sees what is addressed to them (their Personnel record: Role, Divisions and Resorts of their Work
Assignments). Someone not on file sees everything; Preview As shows what the Role is sent.
Who can read and post to each card is the access sheet (Access by Role): each card is a row under each Dashboard (dashboard_card_access);
Read & Write is read and post; it is capped by the Role's access to that Dashboard. Setup file 20261013050000_dashboard.sql.
Nothing is deleted: a removed card is marked inactive and its posts stay stored. */
const DASH_SECTIONS=['5b287142-0924-43db-9427-38d4f9257217','f9b5c241-2531-4f5c-a48f-f7fc600e600b'];
const DASH_RANK={none:0,read:1,write:2};
const DASH_ACCENTS=[['directive','Amber (Directive)'],['agenda','Red (Agenda)'],['notes','Blue (Notes)'],['calendar','Green (Calendar)'],['','None']];
const DASH_ICONS={'':['No icon',''],megaphone:['Megaphone',''],
clipboard:['Clipboard',''],
note:['Note',''],
calendar:['Calendar',''],
bell:['Bell',''],
star:['Star',''],
info:['Info','']};
const dash={cards:[],posts:[],events:[],history:new Set(),month:0,composeCard:'',bound:false};
async function dashQ(q){const {data,error}=await q;if(error)throw error;return data}
async function dashLoad(){const [c,p,e]=await Promise.all([dashQ(sb.from('dashboard_cards').select('*')),dashQ(sb.from('dashboard_posts').select('*')),dashQ(sb.from('dashboard_events').select('*'))]);dash.cards=c||[];dash.posts=p||[];dash.events=e||[];ps.dashCards=dash.cards}
function dashCards(all){return dash.cards.filter(c=>c.active!==false&&(all||c.visible!==false)).sort((a,b)=>(a.sort_order||0)-(b.sort_order||0)||String(a.label||'').localeCompare(String(b.label||'')))}
function dashCardById(id){return dash.cards.find(c=>String(c.id)===String(id))||null}
/* ---- access: a card is capped by its Dashboard section ---- */
function dashMin(a,b){return DASH_RANK[a]<=DASH_RANK[b]?a:b}
function psDashCards(){return (ps.dashCards||[]).filter(c=>c.active!==false).sort((a,b)=>(a.sort_order||0)-(b.sort_order||0)||String(a.label||'').localeCompare(String(b.label||'')))}
function psCardRow(roleId,cardId,secId){return (ps.cardAccess||[]).find(a=>psLive(a)&&a.role_id===roleId&&String(a.card_id)===String(cardId)&&a.section_id===secId)||null}
function psCardOwn(roleId,cardId,secId,fit){const a=psCardRow(roleId,cardId,secId);if(!a)return 'none';if(fit&&!psOnlyFits(a,fit))return 'none';return a.access}
function psCardOf(roleId,cardId,secId,fit){return dashMin(psAccessOf(roleId,secId,fit),psCardOwn(roleId,cardId,secId,fit))}
/* the Role in force on the Dashboard in view */
function dashCardAccess(card){const r=wnRole();if(!r)return 'write';return psCardOf(r,card.id,wn.sub,wn.previewRole?(wn.previewFit||null):wn.ownFit)}
async function psCardPut(roleId,cardId,secId,patch){let a=(ps.cardAccess||[]).find(x=>x.role_id===roleId&&String(x.card_id)===String(cardId)&&x.section_id===secId);
if(a){Object.assign(a,patch,{active:true});await psSave('dashboard_card_access',{id:a.id},Object.assign({},patch,{active:true}))}
else{const {data,error}=await sb.from('dashboard_card_access').insert(Object.assign({role_id:roleId,card_id:cardId,section_id:secId},patch)).select().single();if(error)throw error;(ps.cardAccess=ps.cardAccess||[]).push(data)}}
/* a pick on a card row: the Dashboard and the menus above it are raised to the pick if they were lower */
async function psCardSet(key,access){const R=psRoleById(ps.role);if(!R)return;const [,cardId,secId]=String(key).split(':');
try{if(access!=='none'){let p=psById(secId);while(p){if(DASH_RANK[psAccessOwn(R.id,p.id)]';
return '
'+escapeHtml(c.label)+'card · Read & Write = can post'+(c.visible===false?' · not visible':'')+''+(capped?'shows as '+escapeHtml(psAccessLabel(eff))+' (limited above)':'')+'
'+picks+'
'+psOnlyCell(key,a)+'
'}
/* ---- who is looking ---- */
function dashViewer(){if(wn.previewRole){const f=wn.previewFit||{};return {id:'',roles:[String(wn.previewRole)],divisions:(f.divisions||[]).slice(),resorts:f.resort?[String(f.resort)]:[]}}const id=wn.ownPerson;if(!id)return null;const rows=peopleAssignments(String(id));
return {id:String(id),roles:rows.map(a=>String(a.role_id||'')).filter(Boolean),divisions:rows.flatMap(a=>(Array.isArray(a.division_ids)?a.division_ids:[]).map(String)),resorts:rows.flatMap(a=>peopleAssignmentResortIds(a).map(String))}}
function dashFor(row,v){if(!v||row.everyone)return true;const has=(list,ids)=>(list||[]).map(String).some(x=>ids.includes(x));return (row.target_personnel_ids||[]).map(String).includes(v.id)||has(row.target_role_ids,v.roles)||has(row.target_division_ids,v.divisions)||has(row.target_resort_ids,v.resorts)}
function dashAudienceText(row){if(row.everyone)return 'Everyone';const n=k=>(row[k]||[]).length,p=n('target_personnel_ids'),r=n('target_role_ids'),d=n('target_division_ids'),s=n('target_resort_ids'),out=[];
if(p)out.push(p===1?'Direct message':p+' people');if(r)out.push(r+' role'+(r===1?'':'s'));if(d)out.push(d+' division'+(d===1?'':'s'));if(s)out.push(s+' resort'+(s===1?'':'s'));return out.join(' • ')||'Selected audience'}
function dashAuthor(row){const p=row.author_personnel_id?peopleById('personnel',row.author_personnel_id):null;return p?(peopleFullName(p)||'Unknown'):(row.author_email||'Unknown')}
/* ---- the cards ---- */
function dashFeedHtml(card){const hist=dash.history.has(String(card.id)),v=dashViewer(),rows=dash.posts.filter(r=>r.active!==false&&String(r.card_id)===String(card.id)&&(hist?!!r.archived:!r.archived)&&dashFor(r,v)).sort((a,b)=>String(b.posted_at||'').localeCompare(String(a.posted_at||''))).slice(0,10);
if(!rows.length)return '
'}
function dashOnScreen(){return !!$('page-dashboard')?.classList.contains('active')}
function dashRender(){const host=$('dash-grid');if(!host)return;const cards=dashCards(false).filter(c=>dashCardAccess(c)!=='none');host.innerHTML=cards.length?cards.map(dashCardHtml).join(''):'
No Dashboard cards to show.
';
const cfg=$('dash-configure');if(cfg)cfg.hidden=wnAccess(wn.sub)!=='write';const t=$('dash-title');if(t)t.textContent=(psById(wn.sub)||{}).name||'Dashboard'}
async function dashPageEnter(){dashRender();try{await Promise.all([orgLoad(),peopleLoad().catch(e=>console.error('Personnel & Roles could not load',e)),dashLoad()])}catch(e){console.error(e);toast('The Dashboard could not be read: '+String(e&&e.message||e))}dashRender()}
/* ---- + New / + Event: the post or event and its audience (a data popout: Publish / Save Event; closing with changes asks Save changes?) ---- */
function dashAudienceHtml(card){const notes=card.card_key==='manager_notes',ck=(kind,id,label,meta)=>'',
grp=(title,rows,cls)=>'
'+title+'
'+(rows||'
None yet.
')+'
';
const people=peopleCache.personnel.filter(p=>p.status!=='inactive').sort((a,b)=>peopleFullName(a).localeCompare(peopleFullName(b))).map(p=>{const pa=peoplePrimaryAssignment(p.id),r=peopleRoleForAssignment(pa),d=peopleCache.division.find(x=>String(x.id)===String(peoplePrimaryDivisionId(pa)));return ck('personnel',p.id,peopleFullName(p),[r&&r.role_name,d&&d.division_name].filter(Boolean).join(' — '))}).join(''),
roles=psRoles().map(r=>ck('role',r.id,r.name)).join(''),
divs=peopleCache.division.filter(d=>d.active!==false).sort(peopleDivisionOrder).map(d=>ck('division',d.id,(d.division_name||'')+(d.abbreviation?' ('+d.abbreviation+')':''),d.parent_division_id?'Secondary Name':'')).join(''),
resorts=orgActive('resort').sort((a,b)=>String(a.resort_name||'').localeCompare(String(b.resort_name||''))).map(r=>ck('resort',r.id,r.resort_name||'')).join('');
const note=notes?'Manager Notes starts with no one picked: only the people (or Roles, Divisions, Resorts) you pick can see it.':'Leave Everyone checked, or uncheck it and pick any mix of people, Roles, Divisions and Resorts.';
return '
'+dashAudienceHtml(card)+'';
openOverlay('dash-compose-overlay');setTimeout(()=>body.querySelector('input[type=text],textarea')?.focus(),20)}
async function dashComposeSave(){const card=dashCardById(dash.composeCard),err=$('dash-compose-error');if(!card)return false;if(dashCardAccess(card)!=='write'){err.textContent='Not permitted';return false}
const pick=k=>[...document.querySelectorAll('#dash-compose-body .dash-aud-target[data-aud="'+k+'"]:checked')].map(x=>x.value),everyone=!!$('dash-aud-everyone')?.checked;
const aud={everyone,target_personnel_ids:everyone?[]:pick('personnel'),target_role_ids:everyone?[]:pick('role'),target_division_ids:everyone?[]:pick('division'),target_resort_ids:everyone?[]:pick('resort')};
if(!everyone&&!aud.target_personnel_ids.length&&!aud.target_role_ids.length&&!aud.target_division_ids.length&&!aud.target_resort_ids.length){err.textContent=card.card_key==='manager_notes'?'Pick at least one person for this Manager Note, or another audience.':'Check Everyone or pick at least one audience.';return false}
const who={author_personnel_id:wn.ownPerson||null,author_email:(wnUser&&wnUser.email)||''},val=id=>String($(id)?.value||'');
try{if(card.card_type==='calendar'){const title=val('dash-ev-title').trim(),date=val('dash-ev-date');if(!title){err.textContent='Enter an event title.';return false}if(!date){err.textContent='Pick the event date.';return false}
dash.events.push(await dashQ(sb.from('dashboard_events').insert(Object.assign({card_id:card.id,title,event_date:date,start_time:val('dash-ev-time'),notes:val('dash-ev-notes'),event_type:val('dash-ev-type')||'event'},aud,who)).select().single()))}
else{const text=val('dash-post-body').trim();if(!text){err.textContent='Enter a message.';return false}dash.posts.push(await dashQ(sb.from('dashboard_posts').insert(Object.assign({card_id:card.id,body:text},aud,who)).select().single()))}}
catch(e){console.error(e);err.textContent=String(e.message||e);return false}
$('dash-compose-overlay').dataset.wnDirty='';closeOverlay('dash-compose-overlay');dashRender();toast(card.card_type==='calendar'?'Calendar event saved':(card.label||'Post')+' published');return true}
/* ---- Configure Dashboard (a settings popout: every change saves as it is made) ---- */
function dashConfigRender(){const host=$('dash-config-list');if(!host)return;const cards=dashCards(true);
host.innerHTML=cards.length?cards.map((c,i)=>'
'}
function dashCanConfigure(){if(wnAccess(wn.sub)==='write')return true;toast('Read only');return false}
async function dashCardSave(id,patch){if(!dashCanConfigure())return;const c=dashCardById(id);if(!c)return;try{Object.assign(c,await dashQ(sb.from('dashboard_cards').update(patch).eq('id',id).select().single()));dashRender();dashConfigRender()}catch(e){wnFail(e)}}
async function dashCardAdd(type){if(!dashCanConfigure())return;const cal=type==='calendar',stamp=Date.now().toString(36);
try{dash.cards.push(await dashQ(sb.from('dashboard_cards').insert({card_key:(cal?'calendar_':'feed_')+stamp,label:cal?'New Calendar':'New Feed',card_type:cal?'calendar':'feed',accent:cal?'calendar':'',sort_order:dash.cards.reduce((m,x)=>Math.max(m,Number(x.sort_order)||0),0)+1}).select().single()));ps.dashCards=dash.cards;dashRender();dashConfigRender()}catch(e){wnFail(e)}}
async function dashCardMove(id,dir){if(!dashCanConfigure())return;const cards=dashCards(true),i=cards.findIndex(c=>String(c.id)===String(id)),j=i+dir;if(i<0||j<0||j>=cards.length)return;[cards[i],cards[j]]=[cards[j],cards[i]];
try{for(const [n,c] of cards.entries())if((Number(c.sort_order)||0)!==n+1)Object.assign(c,await dashQ(sb.from('dashboard_cards').update({sort_order:n+1}).eq('id',c.id).select().single()));dashRender();dashConfigRender()}catch(e){wnFail(e)}}
async function dashCardRemove(id){if(!dashCanConfigure())return;const c=dashCardById(id);if(!c)return;if(await wnAsk('Remove '+(c.label||'this card')+' from the Dashboard? Its posts and events stay stored.')!=='yes')return;await dashCardSave(id,{active:false})}
function dashBind(){if(dash.bound)return;dash.bound=true;
$('page-dashboard')?.addEventListener('click',e=>{const t=e.target;let b;
if((b=t.closest('[data-dash-compose]')))return dashCompose(b.dataset.dashCompose,'');
if((b=t.closest('[data-dash-day]')))return dashCompose(b.dataset.dashCard,b.dataset.dashDay);
if((b=t.closest('[data-dash-history]'))){const id=b.dataset.dashHistory;if(dash.history.has(id))dash.history.delete(id);else dash.history.add(id);return dashRender()}
if((b=t.closest('[data-dash-month]'))){dash.month+=Number(b.dataset.dashMonth)||0;return dashRender()}
if(t.closest('#dash-configure')){if(!dashCanConfigure())return;dashConfigRender();openOverlay('dash-config-overlay');return}
if(t.closest('#dash-refresh'))dashPageEnter()});
const o=$('dash-compose-overlay');if(o)o.wnSave=async()=>(await dashComposeSave())!==false;
$('dash-compose-save')?.addEventListener('click',()=>dashComposeSave());
$('dash-compose-body')?.addEventListener('change',e=>{if(e.target.id!=='dash-aud-everyone')return;const on=e.target.checked;document.querySelectorAll('#dash-compose-body .dash-aud-target').forEach(x=>{if(on)x.checked=false;x.disabled=on})});
const cfg=$('dash-config-overlay');
cfg?.addEventListener('click',e=>{const t=e.target;let b;
if((b=t.closest('[data-dash-add]')))return dashCardAdd(b.dataset.dashAdd);
if(t.closest('#dash-cfg-access')){cfg.dataset.wnDirty='';closeOverlay('dash-config-overlay');wnOpen(WN_UTILITY,'permsec');return}
const row=t.closest('[data-dash-cfg]');if(!row)return;
if((b=t.closest('[data-dash-move]')))return dashCardMove(row.dataset.dashCfg,Number(b.dataset.dashMove));
if(t.closest('[data-dash-remove]'))return dashCardRemove(row.dataset.dashCfg)});
cfg?.addEventListener('change',e=>{const t=e.target,f=t.dataset.dashField,row=t.closest('[data-dash-cfg]');if(!f||!row)return;cfg.dataset.wnDirty='';const id=row.dataset.dashCfg;
if(f==='label'){const v=String(t.value||'').trim().replace(/\s+/g,' ');if(!v){toast('Enter a card label');t.value=dashCardById(id)?.label||'';return}return dashCardSave(id,{label:v})}
if(f==='visible')return dashCardSave(id,{visible:t.checked});return dashCardSave(id,{[f]:t.value})})}
/* ===== Hub › Tour Schedule (Ver. Beta 14 Rev. 1, brought in from Ver. B72 Rev. 1: the schedule and working the tours) =====
The Tour Parser (Utility › Parsers › Tour Parser, or Parse on the schedule) shows what saving would do — New, Changed (what, from, to),
Removed, Unchanged — and Save to Tour Schedule writes it (tour_bookings, one row per tour; tour_schedule_imports, one row per save).
Parsing for picks which schedule the paste is for. A tour's time is the time at its Resort; the schedule shows it on an Eastern clock.
A tour missing from a later paste (today or later, inside the dates the paste covers, on the schedule parsed) is marked Removed.
The schedule: Schedule (My Schedules from the signed-in person's Work Assignments, then All Other Schedules; 📌 opens one first), Tour Date
(by week, with counts), Time, Show Hidden, Columns (on / off, order, width), Statuses, Parse; the Be Backs list. On a tour: Status,
Assigned Rep, Sale / No Sale, Be Back Date / Time; a tour sharing an account with another tour today or later is flagged.
A save writes only the fields the paste fills, and an edit only its own field, so two people never overwrite each other's work.
Member Accounts, Balances, Slack Balances, Gifts, Setup, Record Editing / Update Record and the Worksheet come with Member Accounts.
Setup file 20261013060000_tour_schedule.sql. Nothing is deleted. */
const SCHEDULE_ZONE_HOME='America/New_York';
const SCHEDULE_COMPARE=[['resort_text','Resort'],['account_text','Account'],['tour_date','Tour Date'],['tour_time','Tour Time EST'],['mode','Mode'],['status','Status'],['confirmation_status','Confirmation'],['tour_type','Tour Type'],['booked_by','Booked By'],['in_house_tour_type','In-House Tour Type'],['phone','Phone'],['tour_schedule','Tour Schedule']];
const BOOKING_PARSED=['tour_schedule','booking_name','tour_date','tour_time','source_date','source_time','source_zone','resort_text','resort_id','account_text','tour_method','virtual','mode','status','confirmation_status','phone','tour_type','booked_by','in_house_tour_type','kiosk','kiosk_label','division_id','booking_url','sf_booking_id','account_url','sf_account_id','resort_url','sf_resort_id','booked_by_url','sf_booked_by_id','import_id','removed','removed_at'];
const BOOKING_LINKS=['booking_url','sf_booking_id','account_url','sf_account_id','resort_url','sf_resort_id','booked_by_url','sf_booked_by_id'];
const BOOKING_UUID=['resort_id','import_id','division_id','member_id','schedule_status_id','assigned_rep_id'],BOOKING_DATE=['tour_date','source_date','be_back_date'],BOOKING_TS=['removed_at','tour_end_at','booking_gifts_at'],BOOKING_BOOL=['virtual','kiosk','removed','loan_override_on','equity_override_on','rep_ready','deal_pending','ids_sent','ids_good','sale_final','no_show','tw_cleared'];
let bookingRows=[],scheduleImports=[],statusRows=[],schedulePlanNow=null,scheduleBusy=false,scheduleLastRes=null,scheduleRepWait=false;
async function tourQ(q){const {data,error}=await q;if(error)throw error;return data}
function bookingIsOn(v){return v===true||v==='true'||v===1||v==='1'}
function bookingOut(c,v){if(c==='link_history'||c==='gifts'||c==='booking_gifts')return Array.isArray(v)?v:[];if(c==='gift_items')return v&&typeof v==='object'&&!Array.isArray(v)?v:{};if(BOOKING_UUID.includes(c)||BOOKING_DATE.includes(c)||BOOKING_TS.includes(c))return v?String(v):null;if(BOOKING_BOOL.includes(c))return bookingIsOn(v);return String(v??'')}
function bookingIn(r){[...BOOKING_UUID,...BOOKING_DATE,...BOOKING_TS].forEach(c=>{if(r[c]==null)r[c]='';else if(BOOKING_DATE.includes(c))r[c]=String(r[c]).slice(0,10)});return r}
async function bookingRefresh(){bookingRows=(await wnSyncAll('tour_bookings',tourQ)).map(bookingIn)}
/* one tour's own fields only (a Status, a Rep ...): what someone else changed on the tour stays */
async function bookingPatch(id,patch){const out={};Object.keys(patch).forEach(c=>out[c]=bookingOut(c,patch[c]));const row=bookingIn(await tourQ(sb.from('tour_bookings').update(out).eq('id',id).select().single()));const i=bookingRows.findIndex(r=>String(r.id)===String(id));if(i>=0)bookingRows[i]=row;else bookingRows.push(row);return row}
function bookingToday(){const d=new Date();return d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0')}
function bookingDay(r){return String(r?.tour_date||'').slice(0,10)}
function bookingWhen(r){const d=bookingDay(r),t=String(r.tour_time||'');let out='';if(d){const [y,m,dd]=d.split('-').map(Number);out=m+'/'+dd+'/'+y}if(/^\d{1,2}:\d{2}/.test(t)){let [h,mi]=t.split(':').map(Number);const ap=h>=12?'PM':'AM';h=h%12||12;out+=(out?', ':'')+h+':'+String(mi).padStart(2,'0')+' '+ap}return out}
function scheduleTourById(id){return bookingRows.find(r=>String(r.id)===String(id))||null}
/* ---- the Divisions' Tour Methods (Personnel & Roles › Divisions) ---- */
function divActive(){return (peopleCache.division||[]).filter(d=>d&&d.active!==false)}
function divById(id){return (peopleCache.division||[]).find(d=>String(d.id)===String(id))||null}
function divShort(d){return d?String(d.abbreviation||'').trim()||d.division_name||'':''}
function divFamily(id){const out=new Set();if(!id)return out;out.add(String(id));let d=divById(id),n=0;while(d&&d.parent_division_id&&n<6){out.add(String(d.parent_division_id));d=divById(d.parent_division_id);n++}return out}
/* The tour's Tour Method, from what Salesforce sent. */
function tourMethodKey(r){const m=String(r&&r.mode||'').toLowerCase();if(m.includes('kiosk'))return 'kiosk';if(m.includes('person'))return 'in_person';if(m.includes('virtual'))return 'virtual';const raw=String(r&&r.tour_method||'').toLowerCase();if(raw.includes('kiosk'))return 'kiosk';if(raw.includes('virtual'))return 'virtual';if(/person|on-?site/.test(raw))return 'in_person';return bookingIsOn(r&&r.virtual)?'virtual':'kiosk'}
function tourMethodLabel(k){return (TOUR_METHODS.find(t=>t[0]===k)||[])[1]||''}
function divsForMethod(m){return divActive().filter(d=>divMethods(d.id).includes(m))}
function tourDivisionId(r){if(r&&r.division_id)return String(r.division_id);const ds=divsForMethod(tourMethodKey(r));return ds.length===1?String(ds[0].id):''}
/* ---- reading a paste ---- */
async function scheduleReady(){await Promise.all([orgLoad(),peopleLoad().catch(e=>console.error('Personnel & Roles could not load',e)),ravLoad().catch(()=>{}),bookingRefresh()]);await scheduleRelinkResorts();
scheduleImports=((await tourQ(sb.from('tour_schedule_imports').select('id,imported_at,added_count,changed_count,removed_count,unchanged_count,coverage_start,coverage_end,changes')))||[]).sort((a,b)=>String(b.imported_at||'').localeCompare(String(a.imported_at||'')))}
/* A tour saved with no Resort (Salesforce's name matched nothing) is linked when that Resort is in the system: its time is moved to Eastern from the time at the Resort, and a Kiosk gets the Resort's letters. */
async function scheduleRelinkResorts(){for(const r of bookingRows.filter(x=>!bookingIsOn(x.removed)&&!x.resort_id&&String(x.resort_text||'').trim())){const resort=scheduleResortFor(r.resort_text);if(!resort)continue;const zone=resort.time_zone||'',patch={resort_id:resort.id};
if(r.source_time&&r.source_date&&!r.source_zone){const e=scheduleToEastern(String(r.source_date).slice(0,10),String(r.source_time).slice(0,5),zone);Object.assign(patch,{tour_date:e.date,tour_time:e.time,source_zone:zone})}
if(bookingIsOn(r.kiosk)){const code=scheduleKioskCode(resort);patch.kiosk_label=code?code+' Kiosk':'Kiosk'}
await bookingPatch(r.id,patch)}}
function scheduleEsc(v){return escapeHtml(String(v??''))}
function scheduleKey(s){return String(s||'').toLowerCase().replace(/\bresorts?\b/g,' ').replace(/[^a-z0-9]+/g,' ').trim()}
function scheduleBookingNumber(name){const m=String(name||'').match(/(\d{3,})/);return m?m[1]:''}
function scheduleDateIso(v){const m=String(v||'').match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);return m?m[3]+'-'+m[1].padStart(2,'0')+'-'+m[2].padStart(2,'0'):String(v||'').slice(0,10)}
function scheduleTime24(v){const m=String(v||'').match(/^(\d{1,2}):(\d{2})\s?(AM|PM)$/i);if(!m)return String(v||'');let h=Number(m[1])%12;if(/pm/i.test(m[3]))h+=12;return String(h).padStart(2,'0')+':'+m[2]}
/* The Resort a Salesforce resort name belongs to: one exact match on Resort Name, Secondary Name or an Abbreviation (the word Resort is ignored). */
function scheduleResortFor(text){const k=scheduleKey(text);if(!k)return null;const hits=orgActive('resort').filter(r=>[r.resort_name,r.secondary_name,...String(r.abbreviations||'').split(/[,;/]/)].some(x=>scheduleKey(x)===k));return hits.length===1?hits[0]:null}
function scheduleResortName(id){return orgRows.resort.find(r=>String(r.id)===String(id))?.resort_name||''}
function scheduleZoneParts(utcMs,zone){const parts=new Intl.DateTimeFormat('en-US',{timeZone:zone,hourCycle:'h23',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit'}).formatToParts(new Date(utcMs)),p=Object.fromEntries(parts.map(x=>[x.type,x.value]));return {y:+p.year,m:+p.month,d:+p.day,h:+p.hour%24,mi:+p.minute,s:+p.second}}
function scheduleZoneOffset(utcMs,zone){const p=scheduleZoneParts(utcMs,zone);return (Date.UTC(p.y,p.m-1,p.d,p.h,p.mi,p.s)-utcMs)/60000}
/* A tour's time is what the clock says at its Resort. This gives the same moment on an Eastern clock. */
function scheduleToEastern(dateIso,time24,zone){if(!zone||zone===SCHEDULE_ZONE_HOME||!/^\d{4}-\d{2}-\d{2}$/.test(dateIso)||!/^\d{2}:\d{2}$/.test(time24))return {date:dateIso,time:time24};try{const [y,m,d]=dateIso.split('-').map(Number),[h,mi]=time24.split(':').map(Number),wall=Date.UTC(y,m-1,d,h,mi),utc=wall-scheduleZoneOffset(wall-scheduleZoneOffset(wall,zone)*60000,zone)*60000,p=scheduleZoneParts(utc,SCHEDULE_ZONE_HOME);return {date:p.y+'-'+String(p.m).padStart(2,'0')+'-'+String(p.d).padStart(2,'0'),time:String(p.h).padStart(2,'0')+':'+String(p.mi).padStart(2,'0')}}catch(e){console.error(e);return {date:dateIso,time:time24}}}
function scheduleZoneLabel(zone){return (ORG_TIME_ZONES.find(z=>z[0]===zone)||[])[1]||''}
function scheduleKioskCode(resort){return String(resort?.abbreviations||'').split(/[,;/]/)[0].toUpperCase().replace(/[^A-Z0-9]/g,'').slice(0,2)}
/* Ver. Beta 33 Rev. 1: a schedule picked in Fix (Terms for more than one schedule) is the tour's schedule */
function scheduleFields(o){const f=tourTermRoute(parseStamp(scheduleFieldsBase(o)));const sc=o.scheduleOverride?tourScheduleById(o.scheduleOverride):null;if(sc){f.tour_schedule=sc.id;if(sc.division_id&&peopleById('division',sc.division_id))f.division_id=String(sc.division_id)}return f}
function scheduleFieldsBase(o){const resort=scheduleResortFor(o.resort),zone=resort?.time_zone||'',sd=scheduleDateIso(o.tourDate),st=scheduleTime24(o.tourTime),e=scheduleToEastern(sd,st,zone),kiosk=o.mode==='Kiosk',code=scheduleKioskCode(resort);
return {booking_name:o.bookingName||'',tour_date:e.date,tour_time:e.time,source_date:sd,source_time:st,source_zone:zone,resort_text:o.resort||'',resort_id:resort?.id||'',account_text:String(o.account||''),tour_method:o.tourMethod||'',virtual:!!o.virtual,mode:o.mode||'',status:o.status||'',confirmation_status:o.confirmationStatus||'',phone:o.phone||'',tour_type:o.tourType||'',booked_by:String(o.bookedBy||''),in_house_tour_type:String(o.inHouseTourType||''),kiosk,kiosk_label:kiosk?(code?code+' Kiosk':'Kiosk'):'',_links:{booking_url:o.bookingUrl||'',sf_booking_id:o.bookingId||'',account_url:o.accountUrl||'',sf_account_id:o.accountId||'',resort_url:o.resortUrl||'',sf_resort_id:o.resortId||'',booked_by_url:o.bookedByUrl||'',sf_booked_by_id:o.bookedById||''}}}
function scheduleRowDate(r){return String(r.source_date||r.tour_date||'').slice(0,10)}
function scheduleShow(k,v){if(k==='tour_schedule'){if(!v)return '(not set)';if(v==='none')return 'No Term';const sc=tourScheduleById(v);return sc?sc.name:String(v)}if(k==='tour_date'){const d=String(v||'').slice(0,10);if(!d)return '';const [y,m,dd]=d.split('-').map(Number);return m+'/'+dd+'/'+y}if(k==='tour_time')return bookingWhen({tour_time:v})||String(v||'');return String(v??'')}
/* A Resort set on a tour stays through a reparse for as long as Salesforce keeps sending the same resort name that Waypoint cannot match. */
function scheduleKeepResort(ex,f){if(f.resort_id||!ex?.resort_id||scheduleKey(ex.resort_text)!==scheduleKey(f.resort_text))return;const resort=orgRows.resort.find(x=>String(x.id)===String(ex.resort_id));if(!resort)return;const zone=resort.time_zone||'',e=scheduleToEastern(f.source_date,f.source_time,zone);f.resort_id=resort.id;f.source_zone=zone;f.tour_date=e.date;f.tour_time=e.time;if(f.kiosk){const code=scheduleKioskCode(resort);f.kiosk_label=code?code+' Kiosk':'Kiosk'}}
/* Works out what a save would do, without saving: New, Changed (what, from, to), Unchanged, Removed. */
function schedulePlan(res,records){const today=bookingToday(),items=records.map(o=>({o,f:scheduleFields(o),num:scheduleBookingNumber(o.bookingName)})),live=bookingRows.filter(r=>!bookingIsOn(r.removed)),gone=bookingRows.filter(r=>bookingIsOn(r.removed)),byNum=rows=>{const m=new Map();rows.forEach(r=>{const n=scheduleBookingNumber(r.booking_name);if(n&&!m.has(n))m.set(n,r)});return m},liveBy=byNum(live),goneBy=byNum(gone),seen=new Set(),plan={adds:[],changes:[],same:[],removals:[],unmatched:[],doubles:[],start:'',end:''};
items.forEach(it=>{if(it.num)seen.add(it.num);const ex=it.num?liveBy.get(it.num):null;if(ex)scheduleKeepResort(ex,it.f);if(!ex){plan.adds.push({f:it.f,restore:it.num?goneBy.get(it.num)||null:null});return}const diffs=SCHEDULE_COMPARE.filter(([k])=>scheduleShow(k,ex[k])!==scheduleShow(k,it.f[k])).map(([k,l])=>({k,label:l,from:scheduleShow(k,ex[k]),to:scheduleShow(k,it.f[k])}));if(diffs.length)plan.changes.push({row:ex,f:it.f,diffs});else plan.same.push({row:ex,f:it.f})});
const dates=items.map(it=>it.f.source_date).filter(Boolean).sort();plan.start=dates[0]||'';plan.end=dates[dates.length-1]||'';
/* a tour that is today or later, inside the dates this paste covers, and not in the paste, no longer exists */
/* Ver. Beta 33 Rev. 1: flagged and Not a Real Tour bookings are in the paste too, so they are not taken off; a tour saved on its own from Flagged Tours takes nothing off */
const kept=new Set(((res&&res.present)||[]).map(scheduleBookingNumber).filter(Boolean));
if(plan.start&&!(res&&res._single))live.forEach(r=>{const n=scheduleBookingNumber(r.booking_name),d=scheduleRowDate(r);if(n&&!seen.has(n)&&!kept.has(n)&&d&&d>=today&&d>=plan.start&&d<=plan.end&&parseScopeHas(r,items))plan.removals.push(r)});
const names=new Map();items.forEach(it=>{if(!it.f.resort_id&&!names.has(it.f.resort_text))names.set(it.f.resort_text,0);if(!it.f.resort_id)names.set(it.f.resort_text,names.get(it.f.resort_text)+1)});plan.unmatched=[...names.entries()].sort((a,b)=>a[0].localeCompare(b[0]));
/* one tour at a time: any account with two or more tours today or later */
const removing=new Set(plan.removals.map(r=>String(r.id))),after=[...live.filter(r=>!removing.has(String(r.id))&&!seen.has(scheduleBookingNumber(r.booking_name))).map(r=>({member:r.member_id||'',account:r.account_text||'',booking:r.booking_name||'',date:scheduleRowDate(r)})),...items.map(it=>{const ex=it.num?liveBy.get(it.num)||goneBy.get(it.num):null;return {member:ex?.member_id||'',account:it.f.account_text,booking:it.f.booking_name,date:it.f.source_date}})].filter(x=>x.date&&x.date>=today),groups=new Map();
after.forEach(x=>{const key=x.member?'m:'+x.member:'a:'+scheduleKey(x.account);if(key==='a:')return;if(!groups.has(key))groups.set(key,[]);groups.get(key).push(x)});plan.doubles=[...groups.values()].filter(g=>g.length>1);return plan}
function scheduleListHtml(title,cls,rows,open){return ''+rows.length+' '+title+''+(rows.length?'
'+rows.join('')+'
':'')+''}
function scheduleRowHtml(f,extra){const local=f.source_zone&&f.source_zone!==SCHEDULE_ZONE_HOME&&f.source_time?' ('+scheduleEsc(scheduleShow('tour_time',f.source_time))+' '+scheduleEsc(scheduleZoneLabel(f.source_zone))+')':'';return '
'}
function scheduleChangeData(plan){const pick=f=>({booking_name:f.booking_name,account_text:f.account_text,resort_text:f.resort_text,tour_date:f.tour_date,tour_time:f.tour_time,source_time:f.source_time||'',source_zone:f.source_zone||''});return {adds:plan.adds.map(a=>pick(a.f)),changes:plan.changes.map(c=>Object.assign(pick(c.f),{diffs:c.diffs})),same:plan.same.map(s=>pick(s.f)),removals:plan.removals.map(r=>pick(r)),start:plan.start,end:plan.end}}
function scheduleChangesHtml(data,notes){const diff=d=>d.map(x=>''+scheduleEsc(x.label)+' '+scheduleEsc(x.from||'—')+' → '+scheduleEsc(x.to||'—')+'').join('');return (notes||'')+scheduleListHtml('New','is-new',data.adds.map(f=>scheduleRowHtml(f)),true)+scheduleListHtml('Changed','is-changed',data.changes.map(f=>scheduleRowHtml(f,diff(f.diffs))),true)+scheduleListHtml('Removed','is-removed',data.removals.map(f=>scheduleRowHtml(f)),true)+scheduleListHtml('Unchanged','is-same',data.same.map(f=>scheduleRowHtml(f)),false)}
function scheduleNotesHtml(plan){let h='';if(plan.unmatched.length)h+='
Resort not matched'+plan.unmatched.map(([n,c])=>scheduleEsc(n)+' ('+c+')').join(' · ')+'These tours keep the time as pasted until a Resort has this name as its Resort Name, Secondary Name or an Abbreviation.
';if(plan.doubles.length)h+='
More than one future tour'+plan.doubles.map(g=>''+scheduleEsc(g[0].account||'Account')+': '+g.map(x=>scheduleEsc(x.booking)+' '+scheduleEsc(scheduleShow('tour_date',x.date))).join(', ')+'').join('')+'
';return h}
/* Ver. Beta 33 Rev. 1: flagged tours wait in Flagged Tours; they don't stop the rest of the paste from saving */
function scheduleCanSave(res){return !!res&&!res.error&&!res.newHeader&&res.records.length>0&&(res.expected==null||res.expected===res.total)}
function scheduleImportWhen(r){const d=new Date(r.imported_at);return isNaN(d)?'':d.toLocaleString([],{month:'numeric',day:'numeric',year:'2-digit',hour:'numeric',minute:'2-digit'})}
function scheduleHistoryHtml(){const rows=scheduleImports.slice(0,5);return rows.length?'
Recent saves'+rows.map(r=>'').join('')+'
':''}
function scheduleRender(html,title){const host=document.getElementById('tour-schedule-changes'),hist=document.getElementById('tour-schedule-history');if(host){host.innerHTML=html?'
'+scheduleEsc(title||'')+'
'+html:'';host.classList.toggle('show',!!html)}if(hist)hist.innerHTML=scheduleHistoryHtml()}
/* Called by the Tour Parser every time it shows a result (or is cleared). */
async function scheduleAfterParse(res){scheduleLastRes=res;parseForFill();const btn=document.getElementById('tour-schedule-save');schedulePlanNow=null;if(btn)btn.disabled=true;try{await scheduleReady()}catch(e){console.error(e);return}parseForFill();if(!res||res.error||!res.records?.length){scheduleRender('','');return}const records=TourScheduleParserUtility.getRecords(),plan=schedulePlan(res,records),ok=scheduleCanSave(res);schedulePlanNow=ok?{plan,res,text:TourScheduleParserUtility.getLastText()}:null;if(btn)btn.disabled=!ok;
scheduleRender((ok?'':'
Not ready to saveEvery row has to be captured.
')+(res.flagged&&res.flagged.length?'
Flagged Tours'+res.flagged.length+' flagged tour'+(res.flagged.length===1?' waits':'s wait')+' in Flagged Tours above; the rest of the paste can be saved.
':'')+scheduleChangesHtml(scheduleChangeData(plan),scheduleNotesHtml(plan)),'What saving will do · '+scheduleShow('tour_date',plan.start)+' to '+scheduleShow('tour_date',plan.end))}
/* the parsed fields of one tour; a Salesforce link the paste left blank keeps the one on file */
function scheduleParsedRow(id,f,base,importId){const links=f._links||{},o={id:String(id)};BOOKING_PARSED.forEach(c=>{let v=BOOKING_LINKS.includes(c)?(links[c]||base?.[c]||''):f[c];if(c==='import_id')v=importId;else if(c==='removed')v=false;else if(c==='removed_at')v='';o[c]=bookingOut(c,v)});return o}
/* Ver. Beta 33 Rev. 1: a tour fixed in Flagged Tours after its paste was saved comes in on its own (one.single) */
async function scheduleSave(one){const single=!!(one&&one.single),now=single?one:schedulePlanNow,btn=single?null:document.getElementById('tour-schedule-save');if(!now||scheduleBusy)return false;if(!pmxAllow('schedule-parse','write'))return false;scheduleBusy=true;if(btn){btn.disabled=true;btn.textContent='Saving…'}
try{const {plan,res}=now,stamp=new Date().toISOString(),data=scheduleChangeData(plan),scope={name:parseForNames()};
const imp=await tourQ(sb.from('tour_schedule_imports').insert({imported_at:stamp,imported_by:(wnUser&&wnUser.email)||'',parsed_for:scope?scope.name:'',expected_count:res.expected==null?null:Number(res.expected),found_count:Number(res.total)||0,parsed_count:res.records.length,added_count:plan.adds.length,changed_count:plan.changes.length,unchanged_count:plan.same.length,removed_count:plan.removals.length,coverage_start:plan.start||null,coverage_end:plan.end||null,changes:data,raw_text:now.text||''}).select('id').single());
const rows=[...plan.adds.map(a=>scheduleParsedRow(a.restore?a.restore.id:tablesUuid(),a.f,a.restore,imp.id)),...plan.changes.map(c=>scheduleParsedRow(c.row.id,c.f,c.row,imp.id)),...plan.same.map(s=>scheduleParsedRow(s.row.id,s.f,s.row,imp.id))];
for(let i=0;iString(x.id)===String(id));if(!r)return;let data=r.changes;if(typeof data==='string'){try{data=JSON.parse(data)}catch(_){data=null}}if(!data||!data.adds)return;schedulePlanNow=null;const btn=document.getElementById('tour-schedule-save');if(btn)btn.disabled=true;scheduleRender(scheduleChangesHtml(data,''),'Saved '+scheduleImportWhen(r)+' · '+scheduleShow('tour_date',data.start)+' to '+scheduleShow('tour_date',data.end))}
function scheduleBind(){document.getElementById('tour-schedule-save')?.addEventListener('click',()=>scheduleSave());document.getElementById('tour-schedule-history')?.addEventListener('click',e=>{const b=e.target.closest('[data-schedule-import]');if(b)scheduleShowImport(b.dataset.scheduleImport)})}
async function scheduleEnter(){try{await scheduleReady();parseForFill();const hist=document.getElementById('tour-schedule-history');if(hist)hist.innerHTML=scheduleHistoryHtml()}catch(e){console.error(e)}}
function tourScheduleParserBind(){TourScheduleParserUtility.bind();scheduleBind()}
function tourScheduleParserEnter(){try{parseForFill()}catch(e){console.error(e)}if(!document.getElementById('schedule-parse-overlay')?.classList.contains('open'))scheduleParserHome();TourScheduleParserUtility.enter();scheduleEnter()}
/* ---- the schedules (list views): My Schedules from the signed-in person's Work Assignments, then All Other Schedules ---- */
function schedViewer(){const id=wn.ownPerson;return id?(peopleCache.personnel||[]).find(p=>String(p.id)===String(id)&&p.status!=='inactive')||null:null}
function schedResortName(id){const x=orgRows.resort.find(q=>String(q.id)===String(id));return x?(String(x.secondary_name||'').trim()||x.resort_name||''):''}
function schedMake(methods,divisionId,resorts,name){const o={methods:(methods||[]).slice(),divisionId:String(divisionId||''),resorts:resorts?resorts.map(String):null,name:String(name||'')};o.key=[o.methods.slice().sort().join('+'),o.divisionId,o.resorts?o.resorts.slice().sort().join('+'):'all'].join('|');return o}
function schedVirtualMethods(){const set=new Set();divActive().forEach(d=>divMethods(d.id).forEach(m=>{if(m!=='in_person')set.add(m)}));return set.size?TOUR_METHODS.map(t=>t[0]).filter(m=>set.has(m)):['virtual','kiosk']}
function schedTidy(list){const seen=new Map();list.forEach(o=>{if(o&&!seen.has(o.key))seen.set(o.key,o)});const arr=[...seen.values()],count=new Map();arr.forEach(o=>count.set(o.name,(count.get(o.name)||0)+1));arr.forEach(o=>{if(count.get(o.name)>1&&o.divisionId)o.name=o.name.replace(/ Tour Schedule$/,' '+divShort(divById(o.divisionId))+' Tour Schedule')});return arr}
function schedAll(){const out=[schedMake(schedVirtualMethods(),'',null,'Virtual Tour Schedule')],ids=[...new Set(scheduleTours().filter(r=>tourMethodKey(r)==='in_person'&&r.resort_id).map(r=>String(r.resort_id)))];
ids.sort((a,b)=>schedResortName(a).localeCompare(schedResortName(b))).forEach(id=>out.push(schedMake(['in_person'],'',[id],schedResortName(id)+' Tour Schedule')));return out}
/* Ver. Beta 34 Rev. 4: a Function on the signed-in (or previewed) Role; no Role applied = everything open, as elsewhere */
function wnFnOk(key){const role=wn.previewRole||wn.ownRole;if(!role)return true;return psFnOn(role,key,wn.previewRole?(wn.previewFit||null):(wn.ownFit||null))}
function schedAllOk(){return wnFnOk('all_schedules')}
/* while previewing: the schedules Preview's Work State, Division and Resort give */
function schedPreviewMine(){const f=wn.previewFit;if(!f||!(f.state||(f.divisions||[]).length||f.resort))return schedAll();const out=[],divs=(f.divisions||[]).length?f.divisions.map(String):[''];
divs.forEach(did=>{const ms=did?divMethods(did):[];if(f.state==='Virtual'||(!f.state&&!f.resort&&ms.length&&!ms.includes('in_person')))out.push(schedMake(ms.length?ms:schedVirtualMethods(),did,null,'Virtual Tour Schedule'));
else{const rs=f.resort?[String(f.resort)]:orgActive('resort').map(r=>String(r.id));rs.forEach(rid=>out.push(schedMake(ms.length?ms:['in_person'],did,[rid],schedResortName(rid)+' Tour Schedule')))}});return schedTidy(out.length?out:schedAll())}
function schedMine(){if(wn.previewRole)return schedPreviewMine();const me=schedViewer();if(!me)return schedAll();const out=[];
peopleAssignments(me.id).forEach(a=>{const st=peopleAssignmentState(a),divs=(Array.isArray(a.division_ids)?a.division_ids:[]).map(String);(divs.length?divs:['']).forEach(did=>{const ms=did?divMethods(did):[];
if(st==='virtual')out.push(schedMake(ms.length?ms:schedVirtualMethods(),did,null,'Virtual Tour Schedule'));
else{const rs=a.location_scope==='all'?orgActive('resort').map(r=>String(r.id)):peopleAssignmentResortIds(a);rs.forEach(rid=>out.push(schedMake(ms.length?ms:['in_person'],did,[rid],schedResortName(rid)+' Tour Schedule')))}})});
return schedTidy(out.length?out:schedAll())}
/* Every other schedule: the Virtual schedule, each Resort, and each Resort by its In-Person Divisions. */
function schedOthers(){if(!schedAllOk())return [];const me=schedViewer();if(!me&&!wn.previewRole)return [];const mine=schedMine(),have=new Set(mine.map(o=>o.key)),out=[schedMake(schedVirtualMethods(),'',null,'Virtual Tour Schedule')],ipDivs=divsForMethod('in_person').map(d=>String(d.id));
orgActive('resort').map(r=>String(r.id)).forEach(rid=>{out.push(schedMake(['in_person'],'',[rid],schedResortName(rid)+' Tour Schedule'));ipDivs.forEach(did=>out.push(schedMake(['in_person'],did,[rid],schedResortName(rid)+' '+divShort(divById(did))+' Tour Schedule')))});
return schedTidy(out).filter(o=>!have.has(o.key)).sort((a,b)=>a.name.localeCompare(b.name))}
let schedCur='';
function schedPinKey(){const me=schedViewer();return me?String(me.id):orgNameKey(wnUser&&wnUser.email)||'anyone'}
function schedPins(){const v=wnSettingGet('schedule.pins',{});return v&&typeof v==='object'?v:{}}
function schedPinned(){return String(schedPins()[schedPinKey()]||'')}
function schedCurrent(){const all=[...schedMine(),...schedOthers()];if(!all.length)return null;return all.find(o=>o.key===schedCur)||all.find(o=>o.key===schedPinned())||all[0]}
function schedInView(r){return schedFits(schedCurrent(),r)}
function schedFits(o,r){if(!o)return true;if(o.methods.length&&String(r&&r.tour_schedule||'')==='none')return false;const m=tourMethodKey(r);if(o.methods.length&&!o.methods.includes(m))return false;if(o.resorts&&!o.resorts.includes(String(r.resort_id||'')))return false;
if(o.divisionId){const td=tourDivisionId(r);if(td&&!divFamily(td).has(o.divisionId)&&!divFamily(o.divisionId).has(td))return false}return true}
function schedSelectOptions(){const cur=schedCurrent(),opt=o=>'',others=schedOthers();return ''+(others.length?'':'')}
function schedBarPick(){if(!schedAllOk()||schedMine().length+schedOthers().length<2)return '';const cur=schedCurrent(),pin=schedPinned(),on=!!cur&&pin===cur.key;return ''}
function schedPick(key){schedCur=String(key||'');if(window.twState)twState.group={};scheduleScreenRender()}
function schedPin(key){if(!key)return;const all=Object.assign({},schedPins()),k=schedPinKey();if(all[k]===key)delete all[k];else all[k]=key;wnSettingSet('schedule.pins',all);scheduleScreenRender();toast(all[k]?'Pinned: it opens first':'Pin removed')}
/* ---- Parsing for: which schedule the paste is for ---- */
function parseScopes(){if(schedViewer())return schedMine().map(o=>Object.assign({},o,{name:o.name+(o.divisionId?' · '+divShort(divById(o.divisionId)):'')}));
const list=divActive().slice().sort(peopleDivisionOrder).filter(d=>divMethods(d.id).length).map(d=>schedMake(divMethods(d.id),d.id,null,(d.division_name||'')+' — '+divMethods(d.id).map(tourMethodLabel).join(', ')));
return list.length?list:[schedMake([],'',null,'All tours (set Tour Methods on Divisions)')]}
function parseScopeNow(){const list=parseScopes(),saved=String((wnSettingGet('schedule.parseFor',{})||{})[schedPinKey()]||'');return list.find(o=>o.key===saved)||list.find(o=>o.methods.includes('virtual'))||list[0]||null}
function parseForFill(){const sel=document.getElementById('tour-parser-for');if(!sel)return;const now=parseScopeNow();sel.innerHTML=parseScopes().map(o=>'').join('')}
/* What a parsed tour gets from the schedule it is parsed for: In-Person when that schedule is In-Person only and Salesforce did not say Kiosk or Virtual, and its Division. */
function parseStamp(f){const sc=parseScopeNow();if(sc&&sc.methods.length&&sc.methods.every(m=>m==='in_person')&&tourMethodKey(f)==='kiosk'&&!/kiosk/i.test(String(f.tour_method||''))&&!bookingIsOn(f.virtual)){f.mode='In Person';f.kiosk=false;f.kiosk_label=''}
const m=tourMethodKey(f);if(sc&&sc.divisionId&&divMethods(sc.divisionId).includes(m))f.division_id=sc.divisionId;else{const ds=divsForMethod(m);f.division_id=ds.length===1?String(ds[0].id):''}return f}
/* A tour that is not in the paste is only taken off when it belongs to the schedule being parsed. */
function parseScopeHas(r,items){const types=new Set(items.map(it=>ttKey(it.f.tour_type)).filter(Boolean));if(types.size&&!types.has(ttKey(r.tour_type)))return false;/* Ver. Beta 33 Rev. 1: a paste only takes off tours on the Tour Schedules its own tours go to (Tour Terms) */const scheds=new Set(items.map(it=>String(it.f.tour_schedule||'')).filter(x=>x&&x!=='none'));if(scheds.size&&r.tour_schedule&&r.tour_schedule!=='none'&&!scheds.has(String(r.tour_schedule)))return false;
const tsc=tourScheduleById(r.tour_schedule);if(tsc&&tsc.resorts==='assigned'&&!items.some(it=>String(it.f.resort_id||'')===String(r.resort_id||'')))return false;
const sc=parseScopeNow();if(!sc||!sc.methods.length)return true;if(!sc.methods.includes(tourMethodKey(r)))return false;
if(sc.methods.every(m=>m==='in_person')){const rs=new Set(items.map(it=>String(it.f.resort_id||'')));if(!rs.has(String(r.resort_id||'')))return false}
if(sc.resorts&&!sc.resorts.includes(String(r.resort_id||'')))return false;if(sc.divisionId&&r.division_id&&!divFamily(String(r.division_id)).has(sc.divisionId))return false;return true}
document.addEventListener('change',e=>{if(!e.target||e.target.id!=='tour-parser-for')return;const all=Object.assign({},wnSettingGet('schedule.parseFor',{})||{});all[schedPinKey()]=String(e.target.value||'');wnSettingSet('schedule.parseFor',all);if(scheduleLastRes)scheduleAfterParse(scheduleLastRes).catch(console.error)});
/* ---- Statuses: the list fills itself from the Status values that arrive; Hide takes a tour off the day, Be Back moves it to Be Backs ---- */
function statusKey(s){return String(s||'').trim().toLowerCase()}
function statusSources(s){return Array.isArray(s?.source_names)?s.source_names:[]}
function statusSorted(){return statusRows.slice().sort((a,b)=>(Number(a.sort_order)||0)-(Number(b.sort_order)||0)||String(a.status_name||'').localeCompare(String(b.status_name||'')))}
function statusByName(name){const k=statusKey(name);return k?statusRows.find(s=>statusKey(s.status_name)===k||statusSources(s).some(n=>statusKey(n)===k))||null:null}
function statusById(id){return id?statusRows.find(s=>String(s.id)===String(id))||null:null}
function statusOf(r){return statusById(r?.schedule_status_id)||statusByName(r?.status)||null}
function statusIsHidden(r){return bookingIsOn(statusOf(r)?.hidden)}
function statusIsBeBack(r){return statusOf(r)?.behavior==='beBack'}
async function statusReady(){statusRows=(await tourQ(sb.from('tour_schedule_statuses').select('*')))||[];
const seen=new Set(),names=[];bookingRows.forEach(r=>{const n=String(r.status||'').trim(),k=statusKey(n);if(k&&!seen.has(k)){seen.add(k);names.push(n)}});let order=statusRows.reduce((m,s)=>Math.max(m,Number(s.sort_order)||0),0);
for(const n of names){if(statusByName(n))continue;try{statusRows.push(await tourQ(sb.from('tour_schedule_statuses').insert({status_name:n,sort_order:++order,source_names:[n]}).select().single()))}catch(e){if(e.code!=='23505')throw e;statusRows=(await tourQ(sb.from('tour_schedule_statuses').select('*')))||[]}}}
let statusMessage='';
function statusManagerRender(){const host=document.getElementById('schedule-status-rows'),msg=document.getElementById('schedule-status-message');if(!host)return;const d=statusSorted();
host.innerHTML='
'}).join('');if(msg)msg.textContent=statusMessage}
function statusManagerOpen(){if(!pmxAllow('schedule-statuses','write'))return;statusMessage='';statusManagerRender();openOverlay('schedule-status-overlay')}
/* every change saves as it is made */
async function statusSet(id,patch){if(!pmxAllow('schedule-statuses','write'))return;try{const row=await tourQ(sb.from('tour_schedule_statuses').update(patch).eq('id',id).select().single()),i=statusRows.findIndex(s=>String(s.id)===String(id));if(i>=0)statusRows[i]=row;statusMessage=''}catch(e){console.error(e);statusMessage=e.code==='23505'?'Status names must be different.':String(e.message||e)}statusManagerRender();scheduleScreenRender()}
async function statusAdd(){if(!pmxAllow('schedule-statuses','write'))return;let n=1,name='New Status';while(statusByName(name))name='New Status '+(++n);try{statusRows.push(await tourQ(sb.from('tour_schedule_statuses').insert({status_name:name,sort_order:statusRows.reduce((m,s)=>Math.max(m,Number(s.sort_order)||0),0)+1}).select().single()));statusMessage=''}catch(e){console.error(e);statusMessage=String(e.message||e)}statusManagerRender();scheduleScreenRender();const rows=document.querySelectorAll('#schedule-status-rows [data-status-field="status_name"]');rows[rows.length-1]?.select()}
async function statusMove(id,dir){const d=statusSorted(),i=d.findIndex(s=>String(s.id)===String(id)),j=i+dir;if(i<0||j<0||j>=d.length)return;[d[i],d[j]]=[d[j],d[i]];if(!pmxAllow('schedule-statuses','write'))return;
try{for(const [n,s] of d.entries())if((Number(s.sort_order)||0)!==n+1){const row=await tourQ(sb.from('tour_schedule_statuses').update({sort_order:n+1}).eq('id',s.id).select().single());Object.assign(s,row)}}catch(e){console.error(e);statusMessage=String(e.message||e)}statusManagerRender();scheduleScreenRender()}
/* ---- the schedule screen ---- */
const SCHEDULE_COLS=[['resort','Resort',true],['booking','Booking',true],['account','Account',true],['balance','Balance',true],['date','Date',true],['time','Time EST',true],['mode','Mode',true],['status','Status',true],['confirmation','Confirmation',false],['phone','Phone',false],['tour_type','Tour Type',false],['in_house_tour_type','In-House Tour Type',false],['booked_by','Booked By',false],['tour_method','Raw Tour Method',false],['virtual','Raw Virtual Flag',false],['actions','Actions',true],['rep','Assigned Rep',true],['sale','Sale / No Sale',true]];
const SCHEDULE_SALES=['Sale','No Sale'];
const SCHEDULE_BB_COLS=[['resort','Resort'],['booking','Booking'],['account','Account'],['date','Tour Date'],['time','Time EST'],['bbdate','Be Back Date'],['bbtime','Be Back Time'],['status','Status'],['rep','Assigned Rep']];
let scheduleView={date:'',time:''},scheduleColsOpen=false,scheduleDayView=false;
/* The Schedule's columns: which show, their order and their widths — the signed-in person's own setting (a key per column for on or off, _order and _width). */
function scheduleColsKey(){return 'schedule.columns.'+(orgNameKey(wnUser&&wnUser.email)||'anyone')}
function scheduleColsSaved(){const v=wnSettingGet(scheduleColsKey(),{});return v&&typeof v==='object'?v:{}}
function scheduleColsOrder(){const saved=scheduleColsSaved(),all=SCHEDULE_COLS.map(c=>c[0]),kept=(Array.isArray(saved._order)?saved._order.map(String):[]).filter((k,i,a)=>all.includes(k)&&a.indexOf(k)===i),out=kept.slice();all.forEach((k,i)=>{if(out.includes(k))return;const before=all.slice(0,i).reverse().find(x=>out.includes(x));out.splice(before?out.indexOf(before)+1:0,0,k)});return out}
function scheduleColsOn(){const saved=scheduleColsSaved();return scheduleColsOrder().filter(k=>{const c=SCHEDULE_COLS.find(x=>x[0]===k);return saved[k]===undefined?c[2]:!!saved[k]})}
function scheduleColWidth(k){const w=Number((scheduleColsSaved()._width||{})[k]);return w>=40?Math.min(600,Math.round(w)):0}
function scheduleColsSave(mutate){const saved=clone(scheduleColsSaved());saved._width=Object.assign({},saved._width||{});mutate(saved);wnSettingSet(scheduleColsKey(),saved)}
function scheduleTableStyle(cols){const w=cols.map(scheduleColWidth);if(!w.some(Boolean))return '';return w.every(Boolean)?' style="table-layout:fixed;width:'+w.reduce((a,b)=>a+b,0)+'px"':' style="width:max-content"'}
function scheduleColStyle(k){const w=scheduleColWidth(k);return w?' style="width:'+w+'px;min-width:'+w+'px;max-width:'+w+'px"':''}
/* Dragging the right edge of a heading sets that column's width; the other columns keep theirs. All are saved when the drag ends. */
function scheduleResizeStart(e,handle){const k=handle.dataset.scheduleResize,th=handle.closest('th'),table=th.closest('table'),startX=e.clientX,widths={};e.preventDefault();
table.querySelectorAll('thead th[data-col]').forEach(t=>{widths[t.dataset.col]=Math.max(40,Math.min(600,Math.round(t.getBoundingClientRect().width)))});const startW=widths[k];let w=startW;handle.classList.add('dragging');
const apply=()=>{widths[k]=w;Object.entries(widths).forEach(([c,cw])=>table.querySelectorAll('[data-col="'+c+'"]').forEach(x=>{x.style.width=x.style.minWidth=x.style.maxWidth=cw+'px';x.classList.add('sized')}));table.style.tableLayout='fixed';table.style.width=Object.values(widths).reduce((a,b)=>a+b,0)+'px'};apply();
const move=ev=>{w=Math.max(40,Math.min(600,Math.round(startW+ev.clientX-startX)));apply()},up=()=>{document.removeEventListener('pointermove',move);document.removeEventListener('pointerup',up);handle.classList.remove('dragging');scheduleColsSave(saved=>{Object.assign(saved._width,widths)});scheduleScreenRender()};
document.addEventListener('pointermove',move);document.addEventListener('pointerup',up)}
function scheduleResortShort(r){const x=r.resort_id?orgRows.resort.find(q=>String(q.id)===String(r.resort_id)):null;return x?(String(x.secondary_name||'').trim()||x.resort_name||''):(r.resort_text||'')}
function scheduleTours(){return bookingRows.filter(r=>!bookingIsOn(r.removed)&&bookingDay(r))}
function scheduleWeekStart(iso){const [y,m,d]=iso.split('-').map(Number),dt=new Date(y,m-1,d,12);dt.setDate(dt.getDate()-dt.getDay());return dt.getFullYear()+'-'+String(dt.getMonth()+1).padStart(2,'0')+'-'+String(dt.getDate()).padStart(2,'0')}
function scheduleDayLabel(iso){const [y,m,d]=iso.split('-').map(Number);return new Intl.DateTimeFormat('en-US',{weekday:'short',month:'numeric',day:'numeric',year:'2-digit'}).format(new Date(y,m-1,d,12))}
function scheduleMinutes(t){const m=String(t||'').match(/^(\d{1,2}):(\d{2})/);return m?Number(m[1])*60+Number(m[2]):9999}
/* one tour at a time: tours today or later that share an account (the Member Account once they are linked) */
function scheduleRowKey(r){return r.member_id?'m:'+r.member_id:(scheduleKey(r.account_text)?'a:'+scheduleKey(r.account_text):'')}
function scheduleDoubleKeys(){const today=bookingToday(),count=new Map();scheduleTours().forEach(r=>{if(bookingDay(r)x[1]>1).map(x=>x[0]))}
/* the Reps a tour can be given: the Reps on a schedule the tour falls in, working its wave (Rep Settings); everyone until a Role holds Rep. A Rep already on the tour always shows. */
function scheduleReps(r){const all=repFnAny()?repForTour(r):(peopleCache.personnel||[]).filter(p=>p.status!=='inactive'),cur=r?.assigned_rep_id?(peopleCache.personnel||[]).find(p=>String(p.id)===String(r.assigned_rep_id)):null;if(cur&&!all.includes(cur))all.push(cur);return all.sort((a,b)=>peopleFullName(a).localeCompare(peopleFullName(b)))}
function scheduleRepName(id){const p=id?(peopleCache.personnel||[]).find(x=>String(x.id)===String(id)):null;return p?peopleFullName(p):''}
async function scheduleRowPatch(id,patch){const base=scheduleTourById(id);if(!base)return;if(Object.prototype.hasOwnProperty.call(patch,'sale_result')){const was=String(base.sale_result||''),next=String(patch.sale_result||'');if(!next)patch=Object.assign({},patch,{tour_end_at:''});else if(!was||!base.tour_end_at)patch=Object.assign({},patch,{tour_end_at:new Date().toISOString()})}await bookingPatch(id,patch);scheduleScreenRender()}
function scheduleRowEdit(id,field,value){const r=scheduleTourById(id);if(!r)return;if(!pmxAllow('schedule-'+field,'write')){scheduleScreenRender();return}let patch=null;
if(field==='status'){const derived=statusByName(r.status),next=statusById(value);patch={schedule_status_id:derived&&String(derived.id)===String(value)?'':value};if(statusIsBeBack(r)&&next?.behavior!=='beBack'){patch.be_back_date='';patch.be_back_time=''}}
else if(field==='rep')patch={assigned_rep_id:value};
else if(field==='sale')patch={sale_result:SCHEDULE_SALES.includes(value)?value:''};
else if(field==='bbdate')patch={be_back_date:value};
else if(field==='bbtime')patch={be_back_time:value};
else if(field==='resort'){if(!scheduleUnlocked)return;patch={resort_id:value}}
else if(field==='date'){if(!scheduleUnlocked)return;patch={tour_date:value}}
else if(field==='time'){if(!scheduleUnlocked)return;patch={tour_time:value}}
if(patch)scheduleRowPatch(id,patch).catch(e=>{console.error(e);toast('That change could not be saved');scheduleScreenRender()})}
function scheduleStatusSelect(r){const cur=statusOf(r),list=statusSorted().filter(s=>s.active!==false||String(s.id)===String(cur?.id));return ''}
function scheduleRepSelect(r){return ''}
function scheduleCell(k,r){const link=(text,url)=>url?''+scheduleEsc(text)+'':scheduleEsc(text),id=scheduleEsc(r.id);
if(scheduleDayView&&!scheduleUnlocked&&(k==='rep'||k==='sale'||k==='status'))return scheduleTextCell(k,r);
if(k==='resort'){if(!scheduleUnlocked)return ''+scheduleEsc(scheduleResortShort(r))+'';const list=orgActive('resort').slice().sort((a,b)=>String(a.resort_name||'').localeCompare(String(b.resort_name||'')));return ''}
if(k==='actions')return scheduleActions(r);
if(k==='giftlist')return '';
if(k==='booking')return link(scheduleBookingNumber(r.booking_name)||r.booking_name||'',r.booking_url);
if(k==='rep')return scheduleRepSelect(r);
if(k==='sale'){const cur=String(r.sale_result||'');return ''}
if(k==='account')return link(docShownName(r.account_text),r.account_url);
if(k==='date')return scheduleUnlocked?'':scheduleEsc(scheduleShow('tour_date',r.tour_date));
if(k==='time'){if(scheduleUnlocked)return '';const local=r.source_zone&&r.source_zone!==SCHEDULE_ZONE_HOME&&r.source_time?''+scheduleEsc(scheduleShow('tour_time',r.source_time)+' '+scheduleZoneLabel(r.source_zone))+'':'';return ''+scheduleEsc(scheduleShow('tour_time',r.tour_time))+''+local}
if(k==='mode'){const kiosk=bookingIsOn(r.kiosk)||r.mode==='Kiosk';return ''+scheduleEsc(kiosk?(String(r.kiosk_label||'').trim()||'Kiosk'):(r.mode||''))+''}
if(k==='status')return scheduleStatusSelect(r);
if(k==='confirmation')return scheduleEsc(r.confirmation_status||'');if(k==='phone')return scheduleEsc(r.phone||'');if(k==='tour_type')return scheduleEsc(r.tour_type||'');if(k==='in_house_tour_type')return scheduleEsc(r.in_house_tour_type||'');if(k==='booked_by')return scheduleEsc(r.booked_by||'');
if(k==='tour_method')return scheduleEsc(r.tour_method||'');if(k==='virtual')return bookingIsOn(r.virtual)?'True':'False';return ''}
function scheduleBbSortValue(k,r){if(k==='resort')return (scheduleResortName(r.resort_id)||r.resort_text||'').toLowerCase();if(k==='booking')return String(r.booking_name||'').toLowerCase();if(k==='account')return String(r.account_text||'').toLowerCase();if(k==='date'||k==='time')return bookingDay(r)+' '+String(r.tour_time||'');if(k==='bbdate'||k==='bbtime')return (String(r.be_back_date||'').slice(0,10)||'9999-99-99')+' '+String(r.be_back_time||'');if(k==='status')return String(statusOf(r)?.status_name||'').toLowerCase();if(k==='rep')return (scheduleRepName(r.assigned_rep_id)||'').toLowerCase();return ''}
function scheduleBbCell(k,r){const id=scheduleEsc(r.id);if(k==='date')return scheduleEsc(scheduleShow('tour_date',r.tour_date));if(k==='time')return scheduleEsc(scheduleShow('tour_time',r.tour_time));if(k==='bbdate')return '';if(k==='bbtime')return '';return scheduleCell(k,r)}
function scheduleModeBar(mode,bbCount){return ''}
function scheduleOnScreen(){return !!document.getElementById('page-schedule')?.classList.contains('active')}
function scheduleScreenRender(){const host=document.getElementById('schedule-screen');if(!host||!scheduleOnScreen())return;if(scheduleTwRender(host))return;scheduleDayView=scheduleView.mode!=='beback';const v=scheduleView,mode=v.mode==='beback'?'beback':'day',tours=scheduleTours().filter(schedInView),bb=tours.filter(statusIsBeBack),all=tours.filter(r=>!statusIsBeBack(r)),doubles=scheduleDoubleKeys(),write=wnAccess(wn.sub)==='write',
tools=''+(write?''+(window.wnTP?wnTP.flaggedButton():'')+'':''),read=write?'':' data-wn-read="1"';if(!write)scheduleUnlocked=false;
const rowHtml=(r,cells)=>'
'+scheduleModeBar(mode,bb.length)+schedBarPick()+''+wnDaySelect(dates,v.date)+''+rows.length+' of '+day.length+''+(scheduleColsOpen?''+order.map((k,i)=>'').join('')+'':'')+''+tools+'
'+
(rows.length?'
'+cols.map(k=>'
'+label(k)+'
').join('')+'
'+rows.map(r=>rowHtml(r,cols.map(k=>'
'+scheduleCell(k,r)+'
').join(''))).join('')+'
':'
'+(all.length?(dayAll.length&&!day.length?'Every tour this day is hidden.':'No tours match.'):'No tours yet.')+'
')}
async function scheduleScreenEnter(){scheduleScreenRender();try{await Promise.all([scheduleReady(),statusReady(),scheduleMembersReady()])}catch(e){console.error(e);toast('The Tour Schedule could not be read: '+String(e&&e.message||e))}scheduleScreenRender()}
/* ---- Parse on the schedule: the Tour Parser, borrowed into a popout, then put back ---- */
function scheduleParserShell(){return document.getElementById('tour-parser-zone')?.closest('.tour-parser-shell')||null}
function scheduleParserHome(){const shell=scheduleParserShell(),pane=document.querySelector('.utility-pane[data-utility-pane="tour-parser"]');if(shell&&pane&&shell.parentElement!==pane)pane.appendChild(shell)}
function scheduleParseOpen(){if(!pmxAllow('schedule-parse','write'))return;const shell=scheduleParserShell(),body=document.getElementById('schedule-parse-body');if(!shell||!body)return;if(shell.parentElement!==body)body.appendChild(shell);openOverlay('schedule-parse-overlay');tourScheduleParserEnter();setTimeout(()=>document.getElementById('tour-parser-zone')?.focus(),30)}
function scheduleParseSaved(plan){const ov=document.getElementById('schedule-parse-overlay');if(!ov||!ov.classList.contains('open'))return;closeOverlay('schedule-parse-overlay');document.getElementById('tour-parser-clear')?.click();scheduleScreenRender()}
function scheduleScreenBind(){const sc=document.getElementById('schedule-screen');if(!sc||sc.dataset.bound)return;sc.dataset.bound='1';
sc.addEventListener('change',e=>{const t=e.target;if(t.dataset.rowField){scheduleRowEdit(t.dataset.rowId,t.dataset.rowField,t.value);return}if(t.hasAttribute('data-sched-select')){schedPick(t.value);return}if(t.hasAttribute('data-schedule-hidden')){scheduleView.showHidden=t.checked;scheduleScreenRender();return}
if(t.dataset.scheduleFilter){scheduleView[t.dataset.scheduleFilter]=t.value;if(t.dataset.scheduleFilter==='date')scheduleView.time='';scheduleScreenRender()}
else if(t.dataset.scheduleCol){scheduleColsSave(saved=>{saved[t.dataset.scheduleCol]=t.checked});scheduleScreenRender()}
else if(t.dataset.scheduleColwidth){const k=t.dataset.scheduleColwidth,w=Math.round(Number(t.value));scheduleColsSave(saved=>{if(String(t.value).trim()===''||!(w>0))delete saved._width[k];else saved._width[k]=Math.max(40,Math.min(600,w))});scheduleScreenRender()}});
sc.addEventListener('pointerdown',e=>{const h=e.target.closest?.('[data-schedule-resize]');if(h)scheduleResizeStart(e,h)});
sc.addEventListener('click',e=>{const t=e.target,hit=s=>t.closest(s);
const pin=hit('[data-sched-pin]');if(pin){schedPin(pin.dataset.schedPin);return}
const cm=hit('[data-schedule-colmove]');if(cm){const k=cm.dataset.col,dir=Number(cm.dataset.scheduleColmove);scheduleColsSave(saved=>{const o=scheduleColsOrder(),i=o.indexOf(k),j=i+dir;if(i<0||j<0||j>=o.length)return;[o[i],o[j]]=[o[j],o[i]];saved._order=o});scheduleScreenRender();return}
const dn=hit('[data-schedule-day]');if(dn){if(dn.disabled)return;const ds=scheduleView._dates||[],j=ds.indexOf(scheduleView.date)+Number(dn.dataset.scheduleDay);if(j>=0&&j{const t=e.target,f=t.dataset.statusField,row=t.closest('[data-status-row]');if(!f||!row)return;so.dataset.wnDirty='';const id=row.dataset.statusRow;
if(f==='status_name'){const v=String(t.value||'').trim().replace(/\s+/g,' ');if(!v){statusMessage='Every Status needs a name.';statusManagerRender();return}return statusSet(id,{status_name:v})}
if(f==='hidden'||f==='active')return statusSet(id,{[f]:t.checked});if(f==='behavior')return statusSet(id,{behavior:t.value==='beBack'?'beBack':'normal'})});
so?.addEventListener('click',e=>{const mv=e.target.closest('[data-status-move]');if(mv){statusMove(mv.closest('[data-status-row]').dataset.statusRow,Number(mv.dataset.statusMove));return}if(e.target.closest('#schedule-status-add'))statusAdd()})}
/* ===== Member Accounts (Ver. Beta 17 Rev. 1): VB72's Members, built from Test & Recall, brought over as it was set up.
The screen opens from a section named Member Accounts (or Test & Recall). Its data is in the database; see members_data below. ===== */
function memberUserKey(k){return k+'.'+(orgNameKey(wnUser&&wnUser.email)||'anyone')}
let scheduleRelink={tourId:'',candidateId:'',message:'',value:''},scheduleUnlocked=false;
/* Backend Members — Member Accounts. Equiant / Concord Number is the account key; Member Number is the worksheet's contract number.
Setup opens New Member Account: an account is created from scratch, or filled from a worksheet PDF through the Worksheet Parser.
Tour Date, Tour Time, and Kiosk in the account header are placeholders until the worksheet is opened from a Tour; they are not saved.
Resort is picked from the Resorts in Organization. */
const MEMBER_TABLE_NAME='members';
const MEMBER_FIELDS=[
['equiant_number','Equiant / Concord Number','text'],
['member_number','Member Number','text'],
['coast_to_coast_number','Coast to Coast Number','text'],
['rpi_number','RPI Number','text'],
['camp_life_customer_id','Camp Life Customer Id','text'],
['primary_member','Purchaser #1','text'],
['secondary_member','Purchaser #2','text'],
['membership_type','Membership','text'],
['member_since','Member Since','text'],
['resort_id','Resort','resort'],
['address','Street Address','text'],
['city','City','text'],
['state','State','text'],
['zip','ZIP','text'],
['email','Primary Email','email'],
['secondary_email','Secondary Email','email'],
['phone','Primary Phone','tel'],
['alternate_phone','Secondary Phone','tel'],
['current_loan_payoff','Current Loan Payoff','text'],
['current_dues','Current Dues','text'],
['next_mfee_due',"MFee's Due",'date'],
['trade_value','Trade Value','text'],
['trade_value_override','Trade Value Override','check'],
['dues_frozen','Frozen / Not Frozen','text'],
['autopay_note','Loan Payment Information','text'],
['not_in_good_standing','Not in Good Standing','check'],
['account_notes','Notes','notes'],
['account_notice','Not in Good Standing Notes','notes'],
['mfee_frequency','Time Period','text'],
['dues_status_text','Dues Status Text','text'],
['family_use','Family Use','text'],
['previous_rep','Previous Rep','text'],
['last_purchase_amount','Transfer or Last Purchase Price','text'],
['membership_equity','Membership Equity','text'],
['afms','AFMs','text'],
['rv_type','RV Type','text'],
['rv_length','RV Length','text'],
['slideouts','Slideouts','text'],
['amperage','Amperage','text'],
['rv_reservations_this_year','RV Reservations This Year','text'],
['cabin_reservations_this_year','Cabin Reservations This Year','text'],
['rv_reservations_last_year','RV Reservations Last Year','text'],
['cabin_reservations_last_year','Cabin Reservations Last Year','text'],
['max_nights_in','Max Nights In','text'],
['advanced_reservations','Advanced Reservations','text'],
['free_weeks_used','Free Weeks Used','text'],
['free_weeks_allowed','Free Weeks Allowed','text'],
['getaway_reservations_used','Getaway Reservations Used','text'],
['getaway_weeks_allowed','Getaway Weeks Allowed','text'],
['getaway_expiration_date','Getaway Expiration Date','text'],
['two_park_membership','Resort 2 Two Park Membership','text'],
['plu_count','PLUs / Referrals','text'],
['current_membership_notes','Current Membership Notes','notes'],
['reservation_notes','Reservation Notes','notes']
];
const MEMBER_FIELD_BY_NAME=Object.fromEntries(MEMBER_FIELDS.map(([name,label,input])=>[name,{name,label,input}]));
/* Where the program places everything until Arrange Fields is used. The first group follows the arrangement Greg uses in the Backend. */
const MEMBER_LAYOUT=[
{label:'Balance Update',rows:[
['equiant_number','coast_to_coast_number'],
['current_dues','mfee_frequency','next_mfee_due','dues_frozen','current_membership_notes'],
['membership_type','trade_value'],
['last_purchase_amount','purchase_history','current_loan_payoff','membership_equity','autopay_note'],
['phone','alternate_phone','email','secondary_email','advanced_reservations','max_nights_in'],
['account_notes','account_notice']]},
{label:'Balance Update',rows:[
['balance_card']]},
{label:'Account & Worksheet Information',rows:[
['primary_member','secondary_member'],
['member_since','resort_id'],
['address','city'],
['state','zip','previous_rep'],
['afms','rv_type','rv_length'],
['slideouts','amperage','rv_reservations_this_year'],
['cabin_reservations_this_year','rv_reservations_last_year','cabin_reservations_last_year'],
['free_weeks_used','free_weeks_allowed','getaway_reservations_used'],
['getaway_weeks_allowed','getaway_expiration_date','member_number'],
['two_park_membership'],
['plu_count','rpi_number','camp_life_customer_id','family_use'],
['reservation_notes','resort_address']]}
];
const MEMBER_LAYOUT_LABELS={mfee_frequency:'Pay Period Type',current_membership_notes:'Other Dues Info',purchase_history:'Purchase History'};
/* Worksheet Parser record key for each Member field it fills. Fields not listed are entered by hand. Trade Value is never taken from the worksheet: it will come from the Membership Type. */
const MEMBER_PARSER_MAP={
member_number:'memberNumber',member_since:'memberSince',primary_member:'purchaser1',secondary_member:'purchaser2',
address:'addressStreet',city:'addressCity',state:'addressState',zip:'addressZip',
afms:'afms',membership_type:'membership',current_dues:'currentDues',last_purchase_amount:'lastPurchaseAmount',
current_loan_payoff:'currentLoanPayoff',membership_equity:'membershipEquity',previous_rep:'previousRep',
rv_type:'rvType',rv_length:'rvLength',slideouts:'slideouts',amperage:'amperage',
rv_reservations_this_year:'rvReservationsThisYear',rv_reservations_last_year:'rvReservationsLastYear',
cabin_reservations_this_year:'cabinReservationsThisYear',cabin_reservations_last_year:'cabinReservationsLastYear',
max_nights_in:'maxNightsIn',advanced_reservations:'advancedReservations',dues_frozen:'frozenDues',
free_weeks_used:'freeWeeksUsed',free_weeks_allowed:'freeWeeksAllowed',getaway_reservations_used:'getawayReservationsUsed',
getaway_weeks_allowed:'getawayWeeksAllowed',getaway_expiration_date:'getawayExpirationDate'
};
let memberTable=null,memberRows=[],memberSelectedId='',memberDraft=null,memberDirty=false,memberSearch='',memberMessage='',memberSetupFile=null,memberSetupParsed=null,membersBound=false;
function memberDisplayName(r){return [r?.primary_member,r?.secondary_member].map(v=>String(v||'').trim()).filter(Boolean).join(' & ')||'Unnamed Member Account'}
function memberKey(v){return String(v||'').trim().toUpperCase().replace(/\s+/g,'')}
function memberById(id){return memberRows.find(r=>String(r.id)===String(id))||null}
function memberListRows(){const q=memberSearch.trim().toLowerCase();return memberRows.filter(r=>r.active!==false).filter(r=>!q||[memberDisplayName(r),r.equiant_number,r.member_number].some(v=>String(v||'').toLowerCase().includes(q))).sort((a,b)=>memberDisplayName(a).localeCompare(memberDisplayName(b))||String(a.equiant_number||'').localeCompare(String(b.equiant_number||'')))}
function membersRenderList(){const host=document.getElementById('members-list'),count=document.getElementById('members-count');if(!host)return;const rows=memberListRows();if(count)count.textContent=String(rows.length);host.innerHTML=rows.length?rows.map(r=>'').join(''):'
'+(memberSearch.trim()?'No Member Accounts match that search.':'No Member Accounts yet.')+'
'}
function membersRenderAll(){membersRenderList();membersRenderAccount()}
function memberSelect(id){memberDraftFlush();memberCards=null;const row=memberById(id),got=row?memberDraftApply(row):null;memberSelectedId=row?row.id:'';if(row)wnRecentAdd(row.id);memberDraft=got?got.draft:null;memberDirty=!!got?.restored;memberMessage=got?.restored?'Unsaved changes':'';membersRenderAll()}
function memberSetMessage(text){memberMessage=text;const m=document.getElementById('members-message');if(m)m.textContent=text;const c=document.getElementById('members-cards-status');if(c)c.textContent=text}
function memberHeaderRefresh(){const n=document.getElementById('members-account-name'),s=document.getElementById('members-account-sub'),cn=document.getElementById('members-cards-names');if(n)n.textContent=memberDisplayName(memberDraft);if(cn)cn.textContent=memberDisplayName(memberDraft);if(s)s.textContent=[memberDraft.equiant_number&&'Equiant '+memberDraft.equiant_number,memberDraft.member_number&&'Contract '+memberDraft.member_number].filter(Boolean).join(' · ')}
function memberDiscard(){const row=memberById(memberSelectedId);if(row)memberDraftClear(row.id);memberDraft=row?clone(row):null;memberDirty=false;memberMessage='';membersRenderAccount()}
/* ---- Member Setup (B14): Equiant / Concord Number -> Verify. Found: the account and Update. Not found: Upload Worksheet or From Scratch, then Setup. ---- */
let memberSetupState={phase:'',equiant:'',foundId:'',loaded:false,busy:false};
function memberSetupReset(){memberSetupFile=null;memberSetupParsed=null;memberSetupState={phase:'',equiant:'',foundId:'',loaded:false,busy:false};const eq=document.getElementById('members-setup-equiant');if(eq)eq.value='';const fi=document.getElementById('members-setup-file');if(fi)fi.value='';if(typeof b33cClearCtx==='function')b33cClearCtx('setup');memberSetupStatus('');memberSetupRender()}
function memberSetupOpen(){if(!pmxAllow('member-setup','write'))return;memberSetupReset();memberSetupTourHead();openOverlay('members-setup-overlay');setTimeout(()=>document.getElementById('members-setup-equiant')?.focus(),20)}
function memberSetupStatus(html){const s=document.getElementById('members-setup-status');if(s)s.innerHTML=html}
/* The one lookup everything goes through: an Equiant / Concord Number in, the Member Account (or null) out. */
function memberFindByEquiant(value){const key=memberKey(value);return key?memberRows.find(r=>memberKey(r.equiant_number)===key)||null:null}
function memberResortName(id){const r=id?orgRows.resort.find(x=>String(x.id)===String(id)):null;return r?.resort_name||''}
function memberSetupResortOptions(sel){return ''+orgActive('resort').sort((a,b)=>String(a.resort_name||'').localeCompare(String(b.resort_name||''))).map(r=>'').join('')}
function memberSetupRender(){const host=document.getElementById('members-setup-result');if(!host)return;const s=memberSetupState;
/* Ver. Beta 33 Rev. 1: Raw Data or Worksheet (or both), then the Equiant / Concord Number, then Parse. */
const fn=document.getElementById('members-setup-file-name');if(fn)fn.textContent=memberSetupFile?memberSetupFile.name:'No worksheet selected.';if(typeof b33cRawRefresh==='function')b33cRawRefresh('setup');
const go=document.getElementById('members-setup-verify');if(go){go.disabled=!!s.busy;go.textContent=s.busy?'Working…':'Parse'}
if(s.phase==='found'){const r=memberById(s.foundId)||{},place=[r.city,r.state].filter(Boolean).join(', '),rows=[['Equiant / Concord',r.equiant_number],['Member Number',r.member_number],['Membership',r.membership_type],['Resort',memberResortName(r.resort_id)],['Member Since',r.member_since],['Location',place]].filter(x=>String(x[1]||'').trim());
const btns=s.loaded?'':'';
host.innerHTML='
Load the Raw Data or a Worksheet, then Parse, to set the account up from it.
';return}
host.innerHTML=setupMatchesHtml()}
async function memberSetupVerify(){const s=memberSetupState;if(s.busy)return;const input=document.getElementById('members-setup-equiant'),raw=typeof b33cRawReady==='function'&&b33cRawReady('setup');
/* Parse: a blank Equiant is filled in from the Account page; Salesforce's Equiant must be the one entered; pages from different accounts stop it. */
if(raw)b33cMemberSetup.prefill('setup',input);const equiant=String(input?.value||'').trim();
if(raw){const why=b33cMemberSetup.stopReason('setup',equiant,!memberSetupFile);if(why){memberSetupStatus(''+escapeHtml(why)+'');return}}
if(!equiant){memberSetupStatus('Enter the Equiant / Concord Number.');input?.focus();return}
if(!memberTable)await memberEnsureSchema();await memberRefresh();const hit=memberFindByEquiant(equiant),loaded=raw||!!memberSetupFile;Object.assign(s,{equiant,foundId:hit?hit.id:'',loaded});
if(hit){s.phase='found';memberSetupStatus('');memberSetupRender();return}
s.phase='new';if(!loaded){memberSetupStatus('No Member Account has that number.');memberSetupRender();return}
await memberSetupGo(false)}
/* A matching Equiant with Raw Data or a Worksheet loaded: Update Account puts the loaded data into the account; Open As It Is leaves it as it is. On a tour, either one links the tour and opens the account. */
async function memberSetupApply(asIs){const s=memberSetupState,id=s.foundId;if(!id||s.busy)return;
if(!asIs){s.busy=true;memberSetupRender();let done=false;try{memberSetupStatus(memberSetupFile?'Reading '+escapeHtml(memberSetupFile.name)+'…':'');const P=await b33cMemberSetup.load('setup',memberSetupFile);if(P)done=await b33cMemberSetup.applyUpdate(id,P,s.tourId||'',s.equiant);if(!done)memberSetupStatus('')}catch(e){console.error(e);memberSetupStatus(''+escapeHtml(memberSaveError(e))+'')}finally{s.busy=false}if(!done){memberSetupRender();return}toast('Account updated')}
if(s.tourId){const tourId=s.tourId;try{if(String(scheduleTourById(tourId)?.member_id||'')!==String(id))await scheduleLinkTour(tourId,id)}catch(e){console.error(e);memberSetupStatus(''+escapeHtml(String(e.message||e))+'');return}await closeOverlay('members-setup-overlay');scheduleScreenRender();await scheduleAccountOpen(id,tourId);return}
memberDraftFlush();memberDirty=false;memberSearch='';const search=document.getElementById('members-search');if(search)search.value='';closeOverlay('members-setup-overlay');memberSelect(id)}
function memberSetupUpdate(){const id=memberSetupState.foundId;if(!id)return;if(memberSetupState.tourId){scheduleSetupLink();return}memberDraftFlush();memberDirty=false;memberSearch='';const search=document.getElementById('members-search');if(search)search.value='';closeOverlay('members-setup-overlay');memberSelect(id)}
/* Worksheets without a Member Number get the resort's abbreviation (first two characters) and the last five digits of the Equiant / Concord Number. */
function memberTemporaryNumber(resortId,equiant){const r=resortId?orgRows.resort.find(x=>String(x.id)===String(resortId)):null,code=String(r?.abbreviations||'').split(/[,;/]/)[0].toUpperCase().replace(/[^A-Z0-9]/g,'').slice(0,2),five=String(equiant||'').replace(/\D/g,'').slice(-5);return code&&five?code+five:''}
function memberSetupBind(){const ov=document.getElementById('members-setup-overlay');if(!ov)return;const q=id=>document.getElementById(id);q('members-setup-verify')?.addEventListener('click',()=>memberSetupVerify().catch(console.error));q('members-setup-equiant')?.addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();memberSetupVerify().catch(console.error)}});q('members-setup-equiant')?.addEventListener('input',()=>{if(memberSetupState.phase&&!memberSetupState.busy){memberSetupState.phase='';memberSetupStatus('');memberSetupRender()}});
q('members-setup-file')?.addEventListener('change',e=>{memberSetupFile=e.target.files?.[0]||null;if(!memberSetupState.busy)memberSetupState.phase='';memberSetupStatus('');memberSetupRender();if(memberSetupFile)setTimeout(()=>q('members-setup-equiant')?.focus(),20)});
ov.addEventListener('click',e=>{const t=e.target,use=t.closest('[data-setup-use]');if(use){const m=memberById(use.dataset.setupUse),eq=document.getElementById('members-setup-equiant');if(m&&eq){eq.value=String(m.equiant_number||'');memberSetupVerify().catch(console.error)}return}if(t.closest('#members-setup-update'))memberSetupUpdate();else if(t.closest('#members-setup-apply'))memberSetupApply(false).catch(console.error);else if(t.closest('#members-setup-asis'))memberSetupApply(true).catch(console.error);else if(t.closest('#members-setup-file-btn'))q('members-setup-file').click();else if(t.closest('#members-setup-go'))memberSetupGo(true).catch(console.error)})}
/* ---- Member Bookings (B15): one row per tour. Tour Date, Tour Time and Kiosk belong to the booking, not the Member Account. ---- */
function bookingsFor(memberId){return bookingRows.filter(r=>String(r.member_id)===String(memberId)&&!bookingIsOn(r.removed))}
/* The booking on the strip: today's or the next upcoming one; failing that, one with no date yet. */
function bookingCurrent(memberId){const rows=bookingsFor(memberId),today=bookingToday(),up=rows.filter(r=>bookingDay(r)&&bookingDay(r)>=today).sort((a,b)=>(bookingDay(a)+String(a.tour_time||'')).localeCompare(bookingDay(b)+String(b.tour_time||'')));if(up.length)return up[0];const open=rows.filter(r=>!bookingDay(r)).sort((a,b)=>String(b.created_at||'').localeCompare(String(a.created_at||'')));return open[0]||null}
function bookingPast(memberId){const cur=bookingCurrent(memberId);return bookingsFor(memberId).filter(r=>!cur||String(r.id)!==String(cur.id)).sort((a,b)=>(bookingDay(b)+String(b.tour_time||'')).localeCompare(bookingDay(a)+String(a.tour_time||'')))}
function bookingKioskDefault(){const r=memberDraft?.resort_id?orgRows.resort.find(x=>String(x.id)===String(memberDraft.resort_id)):null,code=String(r?.abbreviations||'').split(/[,;/]/)[0].toUpperCase().replace(/[^A-Z0-9]/g,'').slice(0,2);return code?code+' Kiosk':'Kiosk'}
function bookingFieldsHtml(b){const on=bookingIsOn(b?.kiosk);return ''+escapeHtml(String(b?.kiosk_label||'').trim()||bookingKioskDefault())+''}
function bookingStripHtml(memberId){const b=bookingCurrent(memberId),past=bookingPast(memberId).length;return '
'+bookingFieldsHtml(b)+'
'}
function bookingReadRow(wrap){const g=k=>wrap.querySelector('[data-booking-field="'+k+'"]');return {booking_name:String(g('booking_name')?.value||'').trim(),tour_date:g('tour_date')?.value||'',tour_time:g('tour_time')?.value||'',kiosk:!!g('kiosk')?.checked,kiosk_label:String(wrap.querySelector('[data-booking-kiosk-tag]')?.textContent||'').trim()}}
/* Saves the row the changed field sits in. A blank strip becomes a booking the first time something is entered. */
function bookingPastRender(){const host=document.getElementById('members-bookings-list'),id=memberSelectedId;if(!host||!id)return;const rows=bookingPast(id);document.getElementById('members-bookings-title').textContent='Past Bookings — '+memberDisplayName(memberDraft||memberById(id));host.innerHTML=rows.length?'
BookingTour DateTour Time — ESTKiosk
'+rows.map(r=>'
'+bookingFieldsHtml(r)+'
').join(''):'
No past bookings.
'}
function bookingPastOpen(){bookingPastRender();openOverlay('members-bookings-overlay')}
function bookingStripRefresh(){const cur=document.querySelector('#members-workspace .members-tour');if(cur&&memberSelectedId)cur.outerHTML=bookingStripHtml(memberSelectedId)}
function bookingBind(){const ws=document.getElementById('members-workspace'),ov=document.getElementById('members-bookings-overlay'),fail=e=>{console.error(e);toast('The booking could not be saved')};
[ws,ov].forEach(root=>root?.addEventListener('change',e=>{const f=e.target.dataset?.bookingField;if(!f)return;bookingSaveRow(e.target.closest('[data-booking-row]'),f).then(()=>{if(root===ov)bookingStripRefresh();else{const btn=ws.querySelector('[data-booking-action="past"]'),n=bookingPast(memberSelectedId).length;if(btn){btn.disabled=!n;btn.innerHTML='Past Bookings'+(n?' '+n+'':'')}}}).catch(fail)}));
ws?.addEventListener('click',e=>{if(e.target.closest('[data-booking-action="past"]'))bookingPastOpen()});
ov?.addEventListener('click',e=>{const b=e.target.closest('[data-booking-action="remove"]');if(b)bookingRemove(b.closest('[data-booking-row]')).catch(fail)});}
/* ---- Member Purchases (B16): the purchase ledger. Equity and Last Purchase Price are worked out from it, never stored. ---- */
const PURCHASE_TABLE_NAME='member_purchases';
let purchaseTable=null,purchaseRows=[];
/* '$1,234.50' -> 1234.5; anything that is not a number -> null */
function purchaseMoney(v){const s=String(v??'').replace(/[$,\s]/g,'');if(!s||!/^-?\d*\.?\d+$/.test(s))return null;return Number(s)}
function purchaseCurrency(n){const v=Number(n)||0;return (v<0?'-':'')+'$'+Math.abs(v).toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2})}
function purchaseDay(r){return String(r?.purchase_date||'').slice(0,10)}
function purchaseStamp(r){const d=purchaseDay(r);return d?Date.parse(d+'T00:00:00'):(Date.parse(r?.created_at||'')||0)}
function purchasesFor(memberId){return purchaseRows.filter(r=>String(r.member_id)===String(memberId)).sort((a,b)=>purchaseStamp(b)-purchaseStamp(a))}
function purchaseIsTransfer(r){return bookingIsOn(r?.is_transfer)}
function purchaseEquityAmount(r){return purchaseIsTransfer(r)?0:(purchaseMoney(r?.equity_amount)||0)}
/* What a purchase shows as its price: Custom Override as typed, else TRANSFER, else the Purchase Price Override, else the Equity amount. Overrides never change Equity. */
function purchaseDisplay(r){const custom=String(r?.custom_override||'').trim();if(custom)return custom;if(purchaseIsTransfer(r))return 'TRANSFER';const over=purchaseMoney(r?.price_override);if(over!==null)return purchaseCurrency(over);return purchaseCurrency(purchaseEquityAmount(r))}
function purchaseTotals(memberId,payoffValue){const rows=purchasesFor(memberId),total=rows.reduce((s,r)=>s+purchaseEquityAmount(r),0),payoff=purchaseMoney(payoffValue)||0;return {count:rows.length,total,payoff,equity:total-payoff,last:rows.length?purchaseDisplay(rows[0]):''}}
/* The two account boxes the ledger feeds. With no purchases entered they show what the worksheet reported. */
function purchaseAccountValues(){const d=memberDraft||{},t=purchaseTotals(d.id,d.current_loan_payoff);return t.count?{last:t.last,equity:purchaseCurrency(t.equity),count:t.count}:{last:String(d.last_purchase_amount||''),equity:String(d.membership_equity||''),count:0}}
/* ---- Balance Update: Purchase History, Purchase Summary, Loan Balance, Equity and Trade Value on one screen with one Save. ---- */
let balanceState=null;
const BALANCE_HELP={loan:'The override changes only the worksheet display and does not replace the actual saved Loan Balance.',equity:'The override accepts text such as “$12,970.00 - Bal” and does not change calculated Membership Equity.'};
function balanceRowHtml(r,i){const tr=!!r.is_transfer;return ''+(r.tour_name&&balanceTourShown(r)?'
':'')}
function balanceRowStamp(r,i){return r.purchase_date?Date.parse(r.purchase_date+'T00:00:00'):(r.created?Date.parse(r.created)||0:Date.now()+i)}
function balanceSummary(){const s=balanceState,rows=s.rows,total=rows.reduce((a,r)=>a+(r.is_transfer?0:(purchaseMoney(r.equity_amount)||0)),0),sorted=rows.map((r,i)=>[r,balanceRowStamp(r,i)]).sort((a,b)=>b[1]-a[1]),latest=sorted[0]?.[0],last=latest?purchaseDisplay(latest):'',loan=purchaseMoney(s.loan)||0;return {total,last,loan,equity:total-loan}}
function balanceOverrideBox(kind,title,baseLabel,baseAttr,baseValue){const s=balanceState,o=s[kind+'Ov'],dis='';return '
'+title+'
'+escapeHtml(BALANCE_HELP[kind])+'
Worksheet Value
'}
function balanceRender(){if(typeof b34BalanceRender==='function'&&b34BalanceRender())return;const s=balanceState,body=document.getElementById('members-balance-body');if(!s||!body)return;const d=memberDraft||{},b=s.bookingId?bookingRows.find(r=>String(r.id)===String(s.bookingId)):null,kv=(k,v)=>'
'+
'';
document.getElementById('members-balance-status').textContent='';balanceRefresh()}
/* Everything worked out on screen: totals, Calculated Equity, and the Worksheet Values that are not overridden. */
function balanceRefresh(){const s=balanceState;if(!s)return;const q=id=>document.getElementById(id),sum=balanceSummary();q('balance-total').textContent=purchaseCurrency(sum.total);q('balance-last').textContent=sum.last||'—';q('balance-equity-base').value=purchaseCurrency(sum.equity);if(!s.loanOv.on)q('balance-loan-value').value=purchaseCurrency(sum.loan);if(!s.equityOv.on)q('balance-equity-value').value=purchaseCurrency(sum.equity);q('balance-open').checked=s.loanOv.on&&s.equityOv.on}
function balanceOpen(allowed){if(allowed!==true&&!pmxAllow('member-balance','write'))return;const d=memberDraft;if(!d)return;const b=bookingCurrent(d.id),on=k=>b?bookingIsOn(b[k]):false,fmt=v=>{const n=purchaseMoney(v);return n===null?String(v||''):purchaseCurrency(n)};
balanceState={dirty:false,bookingId:b?.id||'',removed:[],rows:purchasesFor(d.id).slice().reverse().map(r=>({id:r.id,created:r.created_at||'',purchase_date:purchaseDay(r),equity_amount:purchaseMoney(r.equity_amount)===null?'':purchaseCurrency(purchaseMoney(r.equity_amount)),price_override:purchaseMoney(r.price_override)===null?'':purchaseCurrency(purchaseMoney(r.price_override)),is_transfer:purchaseIsTransfer(r),custom_override:String(r.custom_override||''),tour_stage:String(r.tour_stage||''),tour_name:String(r.tour_name||''),tour_link:String(r.tour_link||''),tour_amount:String(r.tour_amount||'')})),loan:fmt(d.current_loan_payoff),loanOv:{on:on('loan_override_on'),value:String(b?.loan_override_value||'')},equityOv:{on:on('equity_override_on'),value:String(b?.equity_override_value||'')}};
if(!balanceState.rows.length){const raw=String(d.last_purchase_amount||'').trim(),n=purchaseMoney(raw);if(raw&&(n!==null||/transfer/i.test(raw)))balanceState.rows.push({id:'',created:'',purchase_date:'',equity_amount:n===null?'':purchaseCurrency(n),price_override:'',is_transfer:n===null,custom_override:n===null&&!/^transfer$/i.test(raw)?raw:''})}
document.getElementById('members-balance-title').textContent='Balance Update — '+memberDisplayName(d);balanceRender();if(document.getElementById('member-balance-card'))return;openOverlay('members-balance-overlay');setTimeout(()=>document.getElementById('balance-loan-base')?.focus(),20)}
function balanceReadRows(){const s=balanceState;document.querySelectorAll('#balance-rows [data-balance-row]').forEach(w=>{const r=s.rows[Number(w.dataset.balanceRow)],g=k=>w.querySelector('[data-balance-field="'+k+'"]');if(!r)return;r.purchase_date=g('purchase_date').value||'';r.is_transfer=g('is_transfer').checked;r.equity_amount=r.is_transfer?'':g('equity_amount').value;r.price_override=r.is_transfer?'':g('price_override').value;r.custom_override=g('custom_override').value})}
function balanceSetOverride(kind,on,noFocus){const s=balanceState,o=s[kind+'Ov'],input=document.getElementById('balance-'+kind+'-value'),box=document.getElementById('balance-'+kind+'-on');if(o.on===on)return;o.on=on;if(box)box.checked=on;if(!input)return;input.readOnly=!on;if(on){o.value=kind==='loan'?'$':purchaseCurrency(balanceSummary().total)+' - Bal =';input.value=o.value;if(!noFocus)requestAnimationFrame(()=>{input.focus();const end=input.value.length;try{input.setSelectionRange(end,end)}catch(_){}})}}
function balanceMoneyTidy(el){const n=purchaseMoney(el.value),bad=String(el.value||'').trim()!==''&&n===null;el.classList.toggle('invalid',bad);if(n!==null)el.value=purchaseCurrency(n);return !bad}
/* Trade Value is one amount. Loan payment text that an older Setup put beside it ("$97.99/mo Has Autopay") is Loan Payment Information. */
function balanceSplitTrade(v){const s=String(v||''),pay=s.match(/\$?\s*\d[\d,]*(?:\.\d+)?\s*\/\s*mo\b[^|]*/i),rest=(pay?s.replace(pay[0],''):s).trim(),first=rest.match(/\$?\s*\d[\d,]*(?:\.\d+)?/),n=purchaseMoney(rest)??(first?purchaseMoney(first[0]):null);return {trade:n===null?rest:purchaseCurrency(n),payment:pay?pay[0].trim():''}}
let balanceSaving=Promise.resolve();
function balanceSave(opts){const o=Object.assign({close:false,quiet:false},opts||{});balanceSaving=balanceSaving.then(()=>balanceSaveNow(o)).catch(e=>console.error(e));return balanceSaving}
async function balanceClose(){if(balanceState&&balanceState.dirty){const v=await wnAsk('Save changes?');if(v==='stay')return;if(v==='yes'){await balanceSave({close:true});return}}balanceState=null;closeOverlay('members-balance-overlay');membersRenderAccount();if(!document.getElementById('schedule-account-overlay')?.classList.contains('open'))scheduleScreenRender()}
/* Setup with a worksheet: a Last Purchase Amount on the worksheet becomes the first purchase. */
function purchaseBind(){const ws=document.getElementById('members-workspace'),ov=document.getElementById('members-balance-overlay');
ws?.addEventListener('click',e=>{if(e.target.closest('[data-purchase-action="open"]'))balanceOpen()});
ws?.addEventListener('input',e=>{if(e.target.dataset?.memberField==='current_loan_payoff')purchaseAccountRefresh()});
if(!ov)return;
ov.addEventListener('input',e=>{const s=balanceState,t=e.target;if(!s||t.type==='checkbox')return;s.dirty=true;if(t.dataset.balanceTop==='loan')s.loan=t.value;else if(t.id==='balance-loan-value')s.loanOv.value=t.value;else if(t.id==='balance-equity-value')s.equityOv.value=t.value;else if(t.dataset.balanceField)balanceReadRows();balanceRefresh()});
ov.addEventListener('change',e=>{const s=balanceState,t=e.target;if(!s)return;s.dirty=true;const f=t.dataset.balanceField;
if(f==='is_transfer'){const w=t.closest('[data-balance-row]'),eq=w.querySelector('[data-balance-field="equity_amount"]'),po=w.querySelector('[data-balance-field="price_override"]');[eq,po].forEach(el=>{el.disabled=t.checked;if(t.checked){el.dataset.previousValue=el.value;el.value=''}else el.value=el.dataset.previousValue||el.value||'';el.classList.remove('invalid')});eq.placeholder=t.checked?'TRANSFER':'$0.00'}
else if(f==='equity_amount'||f==='price_override'||t.dataset.balanceTop)balanceMoneyTidy(t);
else if(t.id==='balance-loan-on')balanceSetOverride('loan',t.checked);else if(t.id==='balance-equity-on')balanceSetOverride('equity',t.checked);
else if(t.id==='balance-open'){balanceSetOverride('loan',t.checked,true);balanceSetOverride('equity',t.checked,true)}
if(t.dataset.balanceTop==='loan')s.loan=t.value;if(f)balanceReadRows();balanceRefresh();balanceSave({quiet:true})});
ov.addEventListener('click',e=>{const s=balanceState,t=e.target;if(t===ov||t.closest('[data-balance-action="close"]')){e.stopPropagation();balanceClose();return}if(!s)return;
if(t.closest('[data-balance-action="add"]')){balanceReadRows();s.rows.push({id:'',created:'',purchase_date:'',equity_amount:'',price_override:'',is_transfer:false,custom_override:''});s.dirty=true;document.getElementById('balance-rows').innerHTML=s.rows.map(balanceRowHtml).join('');balanceRefresh();document.querySelector('#balance-rows [data-balance-row]:last-child input')?.focus();return}
const rm=t.closest('[data-balance-action="remove"]');if(rm){balanceReadRows();const i=Number(rm.closest('[data-balance-row]').dataset.balanceRow),r=s.rows[i];if(r?.id&&!confirm('Remove this purchase? It is set aside, not deleted.'))return;if(r?.id)s.removed.push(r.id);s.rows.splice(i,1);s.dirty=true;document.getElementById('balance-rows').innerHTML=s.rows.map(balanceRowHtml).join('');balanceRefresh();balanceSave({quiet:true});return}
const info=t.closest('[data-balance-action="info"]');ov.querySelectorAll('.balance-info-text').forEach(x=>{x.hidden=!(info&&x===info.nextElementSibling&&x.hidden)});
if(t.closest('#members-balance-save'))balanceSave({close:true})},true);
document.addEventListener('keydown',e=>{if(!balanceState||!ov.classList.contains('open'))return;if(e.key==='Escape'){e.stopPropagation();balanceClose()}else if((e.ctrlKey||e.metaKey)&&String(e.key).toLowerCase()==='s'){e.preventDefault();e.stopPropagation();const el=document.activeElement;if(el&&ov.contains(el)&&el.matches('input[type=text]'))el.dispatchEvent(new Event('change',{bubbles:true}));balanceSave()}},true)}
/* ---- Member Account fields with their own controls (B17): pick lists, Not in Good Standing, Loan Payment Information ---- */
const MEMBER_CHOICES={dues_frozen:[['','Not Set'],['No','Not Frozen'],['Yes','Frozen']],mfee_frequency:[['',''],['Annual','Annual'],['Semi Annual','Semi Annual'],['Quarterly','Quarterly'],['Monthly','Monthly']],family_use:[['','Not Verified'],['Yes','Yes'],['No','No']]};
const MEMBER_DEFAULT_NOTICE='This Member is Past Due on Their Loan, Must Pay First to Upgrade.';
/* Worksheets and older entries say yes/no in different ways; the pick list shows the matching choice without changing what is stored until it is picked. */
function memberChoiceValue(name,v){const s=String(v??'').trim();if(name==='dues_frozen'||name==='family_use'){if(/^(y|yes|true|frozen)$/i.test(s))return 'Yes';if(/^(n|no|false|not frozen)$/i.test(s))return 'No'}const hit=(MEMBER_CHOICES[name]||[]).find(o=>o[0].toLowerCase()===s.toLowerCase());return hit?hit[0]:s}
function memberLoanPaymentParts(text){const t=String(text||''),m=t.match(/\$?\s*(\d[\d,]*(?:\.\d+)?)/);return {amount:m?Number(m[1].replace(/,/g,'')):'',autopay:/no\s*-?\s*auto\s*-?\s*pay/i.test(t)?false:/auto\s*-?\s*pay/i.test(t)}}
function memberLoanPaymentLine(amount,autopay){const n=purchaseMoney(amount);return n===null?'':purchaseCurrency(n)+'/mo '+(autopay?'Has Autopay':'No Autopay')}
function memberHasLoan(){return (purchaseMoney(memberDraft?.current_loan_payoff)||0)>0}
function memberTouched(){memberDirty=true;memberSetMessage('Unsaved changes')}
function memberLoanPaymentChanged(){const a=document.getElementById('member-loan-payment'),c=document.getElementById('member-loan-autopay');if(!a||!memberDraft)return;const raw=String(a.value||'').trim();memberDraft.autopay_note=raw?(memberLoanPaymentLine(raw,!!c?.checked)||memberDraft.autopay_note||''):(a.dataset.legacy||'');a.classList.toggle('invalid',!!raw&&purchaseMoney(raw)===null);memberTouched()}
/* ---- Trade Value from Membership (B28). The account's Membership is found on Upgrade by membership name, secondary name or alternate name; memberships that share a name by Day Rule are told apart by the account's Max Nights In. Trade Value is that membership's Level amount in the Upgrade Matrix, unless Override is checked on the field, when the typed amount is used. ---- */
function memberTradeValue(d){if(!d)return '';if(bookingIsOn(d.trade_value_override))return String(d.trade_value||'');return memberTradeFromMembership(d)||String(d.trade_value||'')}
function memberTradeMarkup(label){const d=memberDraft||{},on=bookingIsOn(d.trade_value_override);return '
'}
function memberTradeShow(){const d=memberDraft,box=document.getElementById('member-trade-value');if(!d||!box||bookingIsOn(d.trade_value_override))return;const v=memberTradeValue(d);if(box.value!==v)box.value=v}
function memberTradeOverrideChanged(on){const d=memberDraft;if(!d)return;if(!pmxAllow('member-trade-override','write')){const box=document.getElementById('member-trade-override');if(box)box.checked=!on;return}if(on&&!String(d.trade_value||'').trim())d.trade_value=memberTradeFromMembership(d);d.trade_value_override=on;memberDirty=true;memberSetMessage('Unsaved changes');const wrap=document.getElementById('member-trade-wrap');if(wrap){wrap.outerHTML=memberTradeMarkup(escapeHtml(memberLabel('trade_value')));if(on){const box=document.getElementById('member-trade-value');box?.focus();box?.select()}}}
function memberFieldsBind(){const ws=document.getElementById('members-workspace');if(!ws)return;
ws.addEventListener('change',e=>{const t=e.target;if(t.id==='member-trade-override'){memberTradeOverrideChanged(t.checked);return}if(t.id==='member-trade-value'&&memberDraft&&bookingIsOn(memberDraft.trade_value_override)){const n=purchaseMoney(t.value);if(n!==null){t.value=purchaseCurrency(n);memberDraft.trade_value=t.value}return}if(t.id==='member-loan-autopay')memberLoanPaymentChanged();else if(t.id==='member-loan-payment'){const n=purchaseMoney(t.value);if(n!==null)t.value=purchaseCurrency(n);memberLoanPaymentChanged()}});
ws.addEventListener('input',e=>{const t=e.target;if(t.id==='member-loan-payment')memberLoanPaymentChanged();else if(t.dataset?.memberField==='current_loan_payoff')memberLoanPaymentShow();else if(t.dataset?.memberField==='membership_type'||t.dataset?.memberField==='max_nights_in')memberTradeShow()})}
/* ---- Card View (Ver. Beta 19 Rev. 1): the account one group at a time. Each group from Arrange Fields is a card, in its order;
every card carries Members, Tour Date and Tour Time. Previous / Next move between cards, Done closes them. Changes save on their own. ---- */
let memberCards=null,memberCardsTimer=0,memberCardsBusy=null;
function memberCardsGroups(){return memberLayoutStored().groups.map(g=>({id:g.id,label:g.label,rows:g.rows.filter(r=>r.fields.length)})).filter(g=>g.rows.length)}
function memberCardsWhen(memberId){const b=bookingCurrent(memberId),iso=bookingDay(b),t=String(b?.tour_time||'').trim();let date='',time='';
if(iso){const d=new Date(iso+'T12:00:00');date=isNaN(d)?iso:d.toLocaleDateString('en-US',{weekday:'short',month:'2-digit',day:'2-digit',year:'numeric'})}
const m=/^(\d{1,2}):(\d{2})/.exec(t);if(m){let h=Number(m[1]);const ap=h>=12?'PM':'AM';h=h%12||12;time=h+':'+m[2]+' '+ap}return {date,time}}
function memberCardsHtml(row){const groups=memberCardsGroups(),n=groups.length;
if(!n)return '
Card View
This account has no groups with fields. Arrange Fields sets up the groups.
'}
/* the first box on a card takes the cursor, so Tab runs straight through it */
function memberCardsFocus(){if(!memberCards||!memberCards.focus)return;memberCards.focus=false;const el=document.querySelector('#members-cards .members-cards-rows input:not([disabled]):not([readonly]),#members-cards .members-cards-rows select:not([disabled]),#members-cards .members-cards-rows textarea:not([disabled])');if(el)try{el.focus({preventScroll:true})}catch(_){el.focus()}}
function memberCardsOpen(){if(memberArrange||!memberDraft||!memberById(memberSelectedId))return;memberCards={idx:0,focus:true};membersRenderAccount()}
function memberCardsGo(step){if(!memberCards)return;memberCards.idx=(memberCards.idx|0)+step;memberCards.focus=true;membersRenderAccount()}
/* a pause in typing saves the account (only the fields that changed) */
function memberCardsQueue(){if(!memberCards||!memberDirty)return;clearTimeout(memberCardsTimer);memberSetMessage('Unsaved changes');memberCardsTimer=setTimeout(()=>{memberCardsSaveNow()},800)}
async function memberCardsSaveNow(){clearTimeout(memberCardsTimer);memberCardsTimer=0;if(memberCardsBusy){await memberCardsBusy;if(!memberDirty)return true}if(!memberDirty||!memberDraft)return true;memberSetMessage('Saving…');memberCardsBusy=memberSaveDraft({quiet:true});try{return await memberCardsBusy}finally{memberCardsBusy=null}}
async function memberCardsDone(){if(!memberCards)return;if(memberDirty){const ok=await memberCardsSaveNow();if(!ok)return}memberCards=null;try{memberDraftFlush()}catch(e){console.error(e)}if(!memberDirty)memberMessage='';membersRenderAccount()}
function memberCardsBind(){document.getElementById('members-cards-open')?.addEventListener('click',memberCardsOpen);const ws=document.getElementById('members-workspace');if(!ws)return;
ws.addEventListener('click',e=>{const b=e.target.closest&&e.target.closest('[data-member-cards]');if(!b||b.disabled)return;const a=b.dataset.memberCards;if(a==='prev')memberCardsGo(-1);else if(a==='next')memberCardsGo(1);else if(a==='done')memberCardsDone().catch(console.error)});
['input','change'].forEach(ev=>ws.addEventListener(ev,e=>{if(memberCards&&e.target.closest&&e.target.closest('#members-cards'))setTimeout(memberCardsQueue,0)}))}
/* ---- Member Account layout (B18): groups, rows and labels are arranged on screen with Arrange Fields and kept in WNSYS. ---- */
/* Things that sit in the layout but are not stored boxes: the Purchase History button and the Resort's address. */
const MEMBER_EXTRA_ITEMS={balance_card:{name:'balance_card',label:'Balance Update Card',input:'card'},purchase_history:{name:'purchase_history',label:'Previous Purchase History',input:'action'},resort_address:{name:'resort_address',label:'Resort Address',input:'readonly'}};
const MEMBER_HIDDEN_ITEMS=['not_in_good_standing','dues_status_text','trade_value_override'];
function memberItemDef(name){return MEMBER_FIELD_BY_NAME[name]||MEMBER_EXTRA_ITEMS[name]||null}
function memberLayoutKeys(){return [...MEMBER_FIELDS.map(f=>f[0]),...Object.keys(MEMBER_EXTRA_ITEMS)].filter(k=>!MEMBER_HIDDEN_ITEMS.includes(k))}
const MEMBER_LAYOUT_KEY='members.accountLayout',MEMBER_COLLAPSE_KEY='members.collapsedGroups';
let memberLayoutCache=null,memberArrange=null,memberDragKey='';
function memberLayoutId(p){return p+'-'+Date.now().toString(36)+Math.random().toString(36).slice(2,7)}
function memberLayoutDefault(){return {groups:MEMBER_LAYOUT.map((g,i)=>({id:'group-'+(i+1),label:g.label,rows:g.rows.map((r,j)=>({id:'g'+(i+1)+'-row-'+(j+1),fields:r.filter(Boolean)}))})),labels:Object.assign({},MEMBER_LAYOUT_LABELS),unplaced:[]}}
/* Every item is in exactly one place: a row, or the Not Placed tray. Anything new that the saved layout has never seen goes into a new row at the end. */
function memberLayoutNormalize(src){const valid=new Set(memberLayoutKeys()),seen=new Set(),base=src&&Array.isArray(src.groups)?src:memberLayoutDefault(),groups=[];
(base.groups||[]).forEach((g,i)=>{const rows=[];(Array.isArray(g?.rows)?g.rows:[]).forEach((r,j)=>{const fields=(Array.isArray(r?.fields)?r.fields:[]).filter(k=>valid.has(k)&&!seen.has(k)&&seen.add(k));rows.push({id:String(r?.id||memberLayoutId('row')),fields})});groups.push({id:String(g?.id||memberLayoutId('group')),label:String(g?.label||'Group '+(i+1)),rows})});
const unplaced=(Array.isArray(base.unplaced)?base.unplaced:[]).filter(k=>valid.has(k)&&!seen.has(k)&&seen.add(k)),fresh=[...valid].filter(k=>!seen.has(k));
/* Ver. Beta 34 Rev. 1: a layout saved before the card existed gets it as its own group, right after the group with Current Loan Payoff */
const bci=fresh.indexOf('balance_card');if(bci>=0){fresh.splice(bci,1);let at=groups.findIndex(g=>g.rows.some(r=>r.fields.includes('current_loan_payoff')));groups.splice(at<0?Math.min(1,groups.length):at+1,0,{id:'group-balance-card',label:'Balance Update',rows:[{id:'row-balance-card',fields:['balance_card']}]})}
if(fresh.length){if(!groups.length)groups.push({id:memberLayoutId('group'),label:'Member Account',rows:[]});const g=groups[groups.length-1];for(let i=0;i{const s=String(v??'').trim();if(valid.has(k)&&s&&s!==memberItemDef(k).label)labels[k]=s});return {groups,labels,unplaced}}
function memberLayoutStored(){if(!memberLayoutCache){let saved=null;try{saved=wnSettingGet(MEMBER_LAYOUT_KEY,null)}catch(_){}memberLayoutCache=memberLayoutNormalize(saved)}return memberLayoutCache}
function memberLayoutActive(){return memberArrange?memberArrange.draft:memberLayoutStored()}
function memberLabel(name){const d=memberItemDef(name);return memberLayoutActive().labels[name]||d?.label||name}
function memberCollapsed(){try{const v=wnSettingGet(memberUserKey(MEMBER_COLLAPSE_KEY),[]);return new Set(Array.isArray(v)?v:[])}catch(_){return new Set()}}
function memberResortAddress(id){const row=id?orgRows.resort.find(r=>String(r.id)===String(id)):null;if(!row)return '';const values=profileJsonObject(row.field_values),f=orgFields().find(x=>profileFieldKind(x)==='map');return f?profileFieldValue(f,values):''}
/* Transfer or Last Purchase Price on the account is the latest purchase. Typing here changes that purchase (or starts the first one); a purchase with an override is corrected in Purchase History. */
function memberLastPurchaseState(){const d=memberDraft||{},rows=purchasesFor(d.id),latest=rows[0]||null;if(latest){const locked=!!String(latest.custom_override||'').trim()||purchaseMoney(latest.price_override)!==null,tr=purchaseIsTransfer(latest);return {latest,transfer:tr,locked,text:locked?purchaseDisplay(latest):tr?'':(purchaseMoney(latest.equity_amount)===null?'':purchaseCurrency(purchaseMoney(latest.equity_amount)))}}const raw=String(d.last_purchase_amount||'').trim(),n=purchaseMoney(raw),tr=/^transfer$/i.test(raw);return {latest:null,transfer:tr,locked:false,text:tr?'':n===null?raw:purchaseCurrency(n)}}
function memberItemMarkup(name){const f=memberItemDef(name);if(!f)return '';const label=escapeHtml(memberLabel(name)),d=memberDraft||{},id='member-field-'+name;
if(name==='balance_card')return '';
if(name==='purchase_history')return '';
if(name==='resort_address')return '';
if(name==='membership_equity')return '';
if(name==='last_purchase_amount'){const s=memberLastPurchaseState();return '
'}
function memberArrangeHtml(){const L=memberArrange.draft,up='↑',down='↓';return '
Arrange Fields
'+
L.groups.map((g,gi)=>'
'+
g.rows.map((r,ri)=>'
Row '+(ri+1)+'
'+r.fields.map(memberArrangeChip).join('')+'
').join('')+'').join('')+
'
Not Placed
'+L.unplaced.map(memberArrangeChip).join('')+'
'}
function memberArrangeOpen(){if(!pmxAllow('member-arrange','write'))return;memberDraftFlush();memberArrange={draft:clone(memberLayoutStored())};membersRenderAccount();const ws=document.getElementById('members-workspace');if(ws)ws.scrollTop=0}
function memberArrangeRead(){const L=memberArrange.draft;document.querySelectorAll('#members-workspace [data-arrange-label]').forEach(el=>{const k=el.dataset.arrangeLabel,v=String(el.value||'').trim();if(v&&v!==memberItemDef(k).label)L.labels[k]=v;else delete L.labels[k]});document.querySelectorAll('#members-workspace [data-arrange-group-label]').forEach(el=>{const g=L.groups.find(x=>x.id===el.dataset.arrangeGroupLabel);if(g)g.label=String(el.value||'').trim()||g.label})}
function memberArrangeTake(k){const L=memberArrange.draft;L.groups.forEach(g=>g.rows.forEach(r=>{r.fields=r.fields.filter(x=>x!==k)}));L.unplaced=L.unplaced.filter(x=>x!==k)}
function memberArrangeDrop(k,rowId,beforeKey){const L=memberArrange.draft;if(!k||k===beforeKey)return;memberArrangeRead();memberArrangeTake(k);const list=rowId==='__tray'?L.unplaced:L.groups.flatMap(g=>g.rows).find(r=>r.id===rowId)?.fields;if(!list){L.unplaced.push(k);return}const i=beforeKey?list.indexOf(beforeKey):-1;if(i>=0)list.splice(i,0,k);else list.push(k)}
function memberArrangeAct(act,el){const L=memberArrange.draft,gid=el.closest('[data-arrange-group]')?.dataset.arrangeGroup,rid=el.closest('[data-arrange-row]')?.dataset.arrangeRow,gi=L.groups.findIndex(g=>g.id===gid),g=L.groups[gi],swap=(a,i,j)=>{if(j<0||j>=a.length)return;const t=a[i];a[i]=a[j];a[j]=t};memberArrangeRead();
if(act==='cancel'){memberArrange=null}
else if(act==='save'){const next=memberLayoutNormalize(L);try{wnSettingSet(MEMBER_LAYOUT_KEY,next)}catch(e){console.error(e);toast('The layout could not be saved');return}memberLayoutCache=next;memberArrange=null;toast('Layout saved')}
else if(act==='reset'){if(!confirm('Put every field back where the program places it?'))return;memberArrange.draft=memberLayoutNormalize(memberLayoutDefault())}
else if(act==='add-group')L.groups.push({id:memberLayoutId('group'),label:'New Group',rows:[{id:memberLayoutId('row'),fields:[]}]});
else if(act==='add-row'&&g)g.rows.push({id:memberLayoutId('row'),fields:[]});
else if(act==='group-up'&&g)swap(L.groups,gi,gi-1);else if(act==='group-down'&&g)swap(L.groups,gi,gi+1);
else if(act==='group-remove'&&g){if(g.rows.some(r=>r.fields.length)&&!confirm('Remove this group? Its fields move to Not Placed.'))return;g.rows.forEach(r=>L.unplaced.push(...r.fields));L.groups.splice(gi,1)}
else if(g&&rid){const ri=g.rows.findIndex(r=>r.id===rid);if(act==='row-up')swap(g.rows,ri,ri-1);else if(act==='row-down')swap(g.rows,ri,ri+1);else if(act==='row-remove'){L.unplaced.push(...g.rows[ri].fields);g.rows.splice(ri,1)}}
membersRenderAccount()}
function memberLayoutBind(){const ws=document.getElementById('members-workspace');if(!ws)return;document.getElementById('members-arrange-open')?.addEventListener('click',memberArrangeOpen);
ws.addEventListener('click',e=>{const a=e.target.closest('[data-arrange-action]');if(a&&memberArrange)memberArrangeAct(a.dataset.arrangeAction,a)});
ws.addEventListener('toggle',e=>{const g=e.target?.dataset?.memberGroup;if(!g)return;const set=memberCollapsed();if(e.target.open)set.delete(g);else set.add(g);try{wnSettingSet(memberUserKey(MEMBER_COLLAPSE_KEY),[...set])}catch(_){}} ,true);
ws.addEventListener('change',e=>{const t=e.target;if(t.id==='member-last-transfer'||t.id==='member-last-amount')memberLastPurchaseChanged().catch(err=>{console.error(err);toast('The purchase could not be saved')});else if(t.dataset?.memberField==='resort_id'){const a=document.getElementById('member-resort-address');if(a)a.value=memberResortAddress(t.value)}});
ws.addEventListener('dragstart',e=>{const c=e.target.closest?.('[data-arrange-field]');if(!c||!memberArrange||e.target.matches('input'))return;memberDragKey=c.dataset.arrangeField;c.classList.add('dragging');try{e.dataTransfer.effectAllowed='move';e.dataTransfer.setData('text/plain',memberDragKey)}catch(_){}});
ws.addEventListener('dragend',()=>{memberDragKey='';ws.querySelectorAll('.dragging,.drop-target').forEach(x=>x.classList.remove('dragging','drop-target'))});
ws.addEventListener('dragover',e=>{if(!memberDragKey)return;const row=e.target.closest?.('[data-arrange-row]');if(!row)return;e.preventDefault();ws.querySelectorAll('.drop-target').forEach(x=>x.classList.remove('drop-target'));(e.target.closest('[data-arrange-field]')||row).classList.add('drop-target')});
ws.addEventListener('drop',e=>{if(!memberDragKey)return;const row=e.target.closest?.('[data-arrange-row]');if(!row)return;e.preventDefault();const before=e.target.closest('[data-arrange-field]')?.dataset.arrangeField||'';memberArrangeDrop(memberDragKey,row.dataset.arrangeRow,before);memberDragKey='';membersRenderAccount()})}
function purchaseAccountRefresh(){const b=document.getElementById('member-calc-equity');if(b)b.value=purchaseAccountValues().equity;const box=document.getElementById('member-last-transfer'),input=document.getElementById('member-last-amount');if(box&&input&&document.activeElement!==input){const s=memberLastPurchaseState();box.checked=s.transfer;box.disabled=s.locked;input.value=s.text;input.readOnly=s.locked;input.disabled=!s.locked&&s.transfer;input.placeholder=s.transfer&&!s.locked?'TRANSFER':'$0.00'}}
function memberLoanPaymentShow(){const on=memberHasLoan();['member-loan-payment','member-loan-autopay'].forEach(id=>{const el=document.getElementById(id);if(el)el.disabled=!on})}
/* ---- Member Account workspace (B19): unsaved edits are kept as drafts; the Member Accounts list can be resized. Both live in WNSYS. ---- */
const MEMBER_DRAFTS_KEY='members.drafts',MEMBER_LIST_WIDTH_KEY='members.listWidth',MEMBER_LIST_WIDTH_DEFAULT=300;
let memberDraftTimer=0;
function memberDraftStore(){try{const v=wnSettingGet(memberUserKey(MEMBER_DRAFTS_KEY),{});return v&&typeof v==='object'&&!Array.isArray(v)?v:{}}catch(_){return {}}}
function memberDraftWrite(all){try{wnSettingSet(memberUserKey(MEMBER_DRAFTS_KEY),all)}catch(e){console.error(e)}}
/* Only what differs from the saved account is kept. */
function memberDraftDiff(){const d=memberDraft,row=d?memberById(d.id):null,out={};if(!d||!row)return out;MEMBER_FIELDS.forEach(([k])=>{const a=d[k]??'',b=row[k]??'';if(String(a)!==String(b))out[k]=d[k]});return out}
function memberDraftFlush(){clearTimeout(memberDraftTimer);memberDraftTimer=0;if(!memberDraft?.id)return;const all=memberDraftStore(),diff=memberDirty?memberDraftDiff():{},id=String(memberDraft.id);if(Object.keys(diff).length)all[id]={values:diff,at:new Date().toISOString()};else if(all[id])delete all[id];else return;memberDraftWrite(all)}
function memberDraftQueue(){if(!memberDirty)return;clearTimeout(memberDraftTimer);memberDraftTimer=setTimeout(memberDraftFlush,500)}
function memberDraftClear(id){clearTimeout(memberDraftTimer);memberDraftTimer=0;const all=memberDraftStore();if(all[String(id)]){delete all[String(id)];memberDraftWrite(all)}}
function memberDraftApply(row){const saved=memberDraftStore()[String(row.id)],next=clone(row);if(!saved?.values||!Object.keys(saved.values).length)return {draft:next,restored:false};Object.entries(saved.values).forEach(([k,v])=>{if(MEMBER_FIELD_BY_NAME[k])next[k]=v});return {draft:next,restored:true}}
function memberListWidthBounds(){const lay=document.querySelector('#page-members .members-layout'),total=lay?lay.getBoundingClientRect().width:1200;return {min:220,max:Math.max(260,Math.round(total*0.6))}}
function memberListWidthSet(px,save){const lay=document.querySelector('#page-members .members-layout');if(!lay)return;const b=memberListWidthBounds(),w=Math.min(b.max,Math.max(b.min,Math.round(Number(px)||MEMBER_LIST_WIDTH_DEFAULT)));lay.style.setProperty('--members-list-w',w+'px');if(save){try{wnSettingSet(memberUserKey(MEMBER_LIST_WIDTH_KEY),w)}catch(e){console.error(e)}}}
function memberListWidthLoad(){let w=MEMBER_LIST_WIDTH_DEFAULT;try{w=Number(wnSettingGet(memberUserKey(MEMBER_LIST_WIDTH_KEY),MEMBER_LIST_WIDTH_DEFAULT))||MEMBER_LIST_WIDTH_DEFAULT}catch(_){}memberListWidthSet(w,false)}
function memberWorkspaceBind(){const ws=document.getElementById('members-workspace'),bar=document.getElementById('members-resizer'),lay=document.querySelector('#page-members .members-layout');
['input','change'].forEach(ev=>ws?.addEventListener(ev,()=>memberDraftQueue()));
window.addEventListener('beforeunload',()=>memberDraftFlush());
if(!bar||!lay)return;let start=null;
bar.addEventListener('pointerdown',e=>{start={x:e.clientX,w:lay.querySelector('.members-list-card').getBoundingClientRect().width};bar.setPointerCapture(e.pointerId);bar.classList.add('dragging');e.preventDefault()});
bar.addEventListener('pointermove',e=>{if(start)memberListWidthSet(start.w+(e.clientX-start.x),false)});
const done=e=>{if(!start)return;memberListWidthSet(start.w+(e.clientX-start.x),true);start=null;bar.classList.remove('dragging')};bar.addEventListener('pointerup',done);bar.addEventListener('pointercancel',done);
bar.addEventListener('dblclick',()=>memberListWidthSet(MEMBER_LIST_WIDTH_DEFAULT,true));
bar.addEventListener('keydown',e=>{if(e.key!=='ArrowLeft'&&e.key!=='ArrowRight')return;e.preventDefault();memberListWidthSet(lay.querySelector('.members-list-card').getBoundingClientRect().width+(e.key==='ArrowLeft'?-16:16),true)})}
/* ---- Tour Schedule screen (B21): the bookings that are tours, one day at a time, in Eastern time. ---- */
function scheduleMember(r){return r.member_id?memberRows.find(m=>String(m.id)===String(r.member_id))||null:null}
function scheduleBalance(r){const m=scheduleMember(r);if(!m)return {text:'',open:null};const n=purchaseMoney(m.current_loan_payoff)||0;return n>0.004?{text:purchaseCurrency(n),open:true}:{text:'Clear',open:false}}
function scheduleActions(r){const m=scheduleMember(r),gifts=scheduleDayView?'':'';return (m?''+(scheduleUnlocked?'':''):(schedSetupOk()?'':''))+gifts}
/* Slack Balances: the day's open balances by Eastern tour time. */
function scheduleSlackTime(t){const m=String(t||'').match(/^(\d{1,2}):(\d{2})/);if(!m)return 'Unknown';let h=Number(m[1]),mi=Number(m[2]);const suffix=h>=12?'pm':'am';h=h%12||12;return String(h)+(mi?String(mi).padStart(2,'0'):'')+suffix}
function scheduleSlackText(){const date=scheduleView.date||bookingToday(),[y,mo,d]=date.split('-').map(Number),label=new Intl.DateTimeFormat('en-US',{weekday:'long',month:'numeric',day:'numeric',year:'2-digit'}).format(new Date(y,mo-1,d,12)),rows=scheduleTours().filter(schedInView).filter(r=>bookingDay(r)===date&&!statusIsBeBack(r)),groups=new Map();
rows.forEach(r=>{const t=String(r.tour_time||'').slice(0,5);if(!t)return;if(!groups.has(t))groups.set(t,[]);const m=scheduleMember(r);if(m&&scheduleBalance(r).open)groups.get(t).push({name:(String(r.account_text||'').trim()||memberDisplayName(m)).replace(/\s+family$/i,'').trim()||'Unknown',num:String(m.equiant_number||'').trim()})});
const times=[...groups.keys()].sort((a,b)=>scheduleMinutes(a)-scheduleMinutes(b)),lines=[':rotating_light: *Balances for '+label+'* :rotating_light:',''];if(!times.length){lines.push('No tour times scheduled.');return lines.join('\n')}
times.forEach((t,i)=>{lines.push(scheduleSlackTime(t)+':');const list=groups.get(t).sort((a,b)=>a.name.localeCompare(b.name));if(!list.length)lines.push('NONE');else list.forEach(x=>lines.push(x.name+(x.num?' '+x.num:'')));if(i'
'+(cand?card('Replacement Member Account',cand):'')+''}
function scheduleRelinkOpen(tourId){if(!pmxAllow('schedule-record-edit','write'))return;if(!scheduleUnlocked)return;scheduleRelink={tourId,candidateId:'',message:'',value:''};scheduleRelinkRender();openOverlay('schedule-relink-overlay');setTimeout(()=>document.getElementById('schedule-relink-equiant')?.focus(),20)}
async function scheduleRelinkVerify(){const s=scheduleRelink,r=scheduleTourById(s.tourId);if(!r)return;s.value=String(document.getElementById('schedule-relink-equiant')?.value||'').trim();s.candidateId='';await memberRefresh();const hit=s.value?memberFindByEquiant(s.value):null;if(!s.value)s.message='Enter the Equiant / Concord Number.';else if(!hit)s.message='No Member Account has that number.';else if(String(hit.id)===String(r.member_id))s.message='That Member Account is already linked to this tour.';else{s.candidateId=hit.id;s.message=''}scheduleRelinkRender();document.getElementById('schedule-relink-equiant')?.focus()}
function scheduleTourLine(r){return [r.booking_name,r.account_text,memberResortName(r.resort_id)||r.resort_text,[scheduleShow('tour_date',r.tour_date),scheduleShow('tour_time',r.tour_time)].filter(Boolean).join(' · ')].filter(x=>String(x||'').trim()).map(scheduleEsc).join('|')}
/* A new booking for a Salesforce account that is already linked keeps that link (one Member Account only). */
function scheduleCarryLink(f){const links=f._links||{},sf=String(links.sf_account_id||'').trim(),linked=bookingRows.filter(r=>String(r.member_id||'').trim());let hits=sf?linked.filter(r=>String(r.sf_account_id||'').trim()===sf):[];if(!hits.length){const k=scheduleKey(f.account_text);if(k)hits=linked.filter(r=>scheduleKey(r.account_text)===k)}const ids=[...new Set(hits.map(r=>String(r.member_id)))];return ids.length===1&&memberRows.some(m=>String(m.id)===ids[0])?ids[0]:''}
function scheduleSetupOpen(tourId){if(!pmxAllow('member-setup','write'))return;const r=scheduleTourById(tourId);if(!r)return;memberSetupReset();memberSetupState.tourId=r.id;memberSetupTourHead();{const only=setupMatches(r);if(only.length===1){const eq=document.getElementById('members-setup-equiant');if(eq)eq.value=String(only[0].equiant_number||'')}}memberSetupRender();openOverlay('members-setup-overlay');setTimeout(()=>document.getElementById('members-setup-equiant')?.focus(),20)}
/* Possible Matches: Member Accounts whose Purchaser's first and last name are both in the tour's Account name. */
const SETUP_MATCH_SKIP=new Set(['and','family','the','jr','sr','ii','iii','iv','mr','mrs','ms']);
function setupNameWords(v){return String(v||'').toLowerCase().replace(/&/g,' ').replace(/[^a-z0-9' -]+/g,' ').replace(/'/g,'').split(/[\s-]+/).filter(w=>w&&!SETUP_MATCH_SKIP.has(w))}
function setupPurchaserParts(name){const raw=String(name||'').trim();if(!raw)return null;let first,last;if(raw.includes(',')){const [l,...rest]=raw.split(',');const f=setupNameWords(rest.join(' '));first=f[0];last=setupNameWords(l).pop()}else{const w=setupNameWords(raw);first=w[0];last=w.length>1?w[w.length-1]:''}return first&&last?{first,last}:null}
function setupMatches(r){if(!r)return [];const words=new Set(setupNameWords(r.account_text));if(!words.size)return [];
return memberRows.filter(m=>m.active!==false&&String(m.equiant_number||'').trim()).map(m=>{const hits=[m.primary_member,m.secondary_member].map(setupPurchaserParts).filter(Boolean).filter(p=>words.has(p.first)&&words.has(p.last)).length;return hits?{m,score:hits*10+(r.resort_id&&String(m.resort_id||'')===String(r.resort_id)?100:0)}:null}).filter(Boolean)
.sort((a,b)=>b.score-a.score||memberDisplayName(a.m).localeCompare(memberDisplayName(b.m))).slice(0,5).map(x=>x.m)}
function setupMatchesHtml(){const s=memberSetupState,r=s.tourId?scheduleTourById(s.tourId):null;if(!r||s.phase==='found')return '';const list=setupMatches(r);if(!list.length)return '';
return '
'}
function memberSetupTourHead(){const r=memberSetupState.tourId?scheduleTourById(memberSetupState.tourId):null,title=document.getElementById('members-setup-title'),line=document.getElementById('members-setup-tour');if(title)title.textContent=r?'Tour Account Setup':'Member Account Setup';if(line){line.innerHTML=r?scheduleTourLine(r):'';line.hidden=!r}}
async function scheduleSetupLink(){const s=memberSetupState;if(!s.tourId||!s.foundId||s.busy)return;s.busy=true;try{await scheduleLinkTour(s.tourId,s.foundId);closeOverlay('members-setup-overlay');toast('Tour linked');scheduleScreenRender()}catch(e){console.error(e);memberSetupStatus(''+escapeHtml(String(e.message||e))+'')}finally{s.busy=false}}
/* ---- Tour Schedule (B22 R2): a tour's Member Account opens in a popout over the schedule. It is the same account screen, borrowed, then put back. ---- */
let scheduleAccountPending=false,scheduleAccountTourId='';
function scheduleAccountCard(){return document.getElementById('members-workspace')?.closest('.members-card')||null}
function scheduleAccountHome(){const card=scheduleAccountCard(),lay=document.querySelector('#page-members .members-layout');if(card&&lay&&card.parentElement!==lay)lay.appendChild(card)}
function scheduleAccountShow(){const card=scheduleAccountCard(),body=document.getElementById('schedule-account-body');if(!card||!body)return;if(card.parentElement!==body)body.appendChild(card);openOverlay('schedule-account-overlay')}
async function scheduleAccountOpen(memberId,tourId){if(String(memberSelectedId)!==String(memberId)||String(scheduleAccountTourId)!==String(tourId||'')){if(typeof b33cClearCtx==='function')b33cClearCtx('update')}if(!pmxAllow('schedule-open-account','read'))return;if(!memberById(memberId)){await memberRefresh();await purchaseRefresh()}if(!memberById(memberId))return;scheduleAccountTourId=tourId||'';memberListWidthLoad();await mshipLoad().catch(e=>console.error('Memberships could not load',e));memberSelect(memberId);scheduleAccountShow()}
async function scheduleAccountClose(){const ov=document.getElementById('schedule-account-overlay');if(!ov||!ov.classList.contains('open'))return;if(memberCards){if(memberDirty&&!(await memberCardsSaveNow()))return;memberCards=null}if(memberDirty&&memberDraft&&!memberArrange){const v=await wnAsk('Save changes?');if(v==='stay')return;if(v==='yes'&&!(await memberSaveDraft()))return}try{memberDraftFlush()}catch(e){console.error(e)}await closeOverlay('schedule-account-overlay');scheduleAccountTourId='';if(typeof b33cClearCtx==='function')b33cClearCtx('update');scheduleAccountHome();scheduleScreenRender()}
function scheduleAccountBind(){const ov=document.getElementById('schedule-account-overlay');if(!ov)return;ov.addEventListener('click',e=>{if(e.target===ov||e.target.closest('#schedule-account-close'))scheduleAccountClose().catch(console.error)});document.addEventListener('keydown',e=>{if(e.key==='Escape'&&ov.classList.contains('open')&&![...document.querySelectorAll('.overlay.open')].some(o=>o!==ov))scheduleAccountClose().catch(console.error)})}
const TIDY_MEMBER_FIELDS=['primary_member','secondary_member','address','city','previous_rep'];
/* A Member Account row as the Worksheet Parser hands it over: names and address tidied, State in capitals. */
function tidyMemberRow(row,lists){const L=lists||tidyLists();TIDY_MEMBER_FIELDS.forEach(f=>{if(row[f]!=null&&String(row[f]).trim())row[f]=tidyText(row[f],L)});if(row.state!=null&&String(row.state).trim().length===2)row.state=String(row.state).trim().toUpperCase();return row}
let tidyPending=[];
/* ---- Import Member Accounts (B50 Rev. 4): reads the file Backend v142 writes with Export Member Accounts.
Nothing is saved until Import is pressed. An account whose Equiant / Concord Number is already here is updated with what the file holds; a blank in the file does not erase what is here (B60). Copies of one account in the file are combined, and Compare shows each change and conflict;
a new one is added. The Resort and anything else B takes from the Tour Schedule are not in the file. Purchase History is added; a purchase already
on the account (same date, Equity, overrides and Transfer) is not added twice. ---- */
let mimpPlan=null;
function mimpOpen(){if(!pmxAllow('member-setup','write'))return;document.getElementById('members-import-file')?.click()}
async function mimpRead(file){if(!memberTable)await memberEnsureSchema();await memberRefresh();if(!purchaseTable)await purchaseEnsureSchema();await purchaseRefresh();
const d=JSON.parse(await file.text());if(!d||d.type!=='waypoint-member-accounts'||!Array.isArray(d.accounts))throw new Error('That file is not a Member Accounts export from Backend v142.');
const groups=new Map(),plan={file:file.name,from:String(d.from||''),items:[]};
d.accounts.forEach(a=>{const f=a&&a.fields&&typeof a.fields==='object'?a.fields:{},eq=String(f.equiant_number||'').trim(),key=memberKey(eq),name=String(a.name||'').trim()||[f.primary_member,f.secondary_member].filter(Boolean).join(' & ')||'Unnamed account';
const copy={name,fields:f,purchases:Array.isArray(a.purchases)?a.purchases:[],older:Array.isArray(a.older_equiants)?a.older_equiants.map(String).filter(Boolean):[]};
if(!key){plan.items.push({name,equiant:'',copies:[copy],fields:{},purchases:[],older:[],conflicts:{},picks:{},action:'skip',reason:'No Equiant / Concord Number',targetId:'',open:false});return}
if(groups.has(key)){groups.get(key).copies.push(copy);return}
const item={name,equiant:eq,copies:[copy],fields:{},purchases:[],older:[],conflicts:{},picks:{},action:'',reason:'',targetId:'',open:false};groups.set(key,item);plan.items.push(item)});
groups.forEach(item=>{mimpCombine(item);const hit=memberFindByEquiant(item.equiant);item.action=hit?'update':'new';item.targetId=hit?String(hit.id):''});
return plan}
/* Copies of one account in the file become one: for each field the copy that has it filled in wins; two copies filled in with different values are a conflict you pick. */
function mimpVal(k,v){return MEMBER_FIELD_BY_NAME[k]?.input==='check'?(v===true||v==='true'):String(v??'').trim()}
function mimpFilled(k,v){return MEMBER_FIELD_BY_NAME[k]?.input==='check'?v===true:v!==''}
function mimpCombine(item){const keys=new Set();item.copies.forEach(c=>Object.keys(c.fields).forEach(k=>{if(MEMBER_FIELD_BY_NAME[k]&&k!=='resort_id')keys.add(k)}));
item.fields={};item.conflicts={};keys.forEach(k=>{const vals=item.copies.map(c=>mimpVal(k,c.fields[k])),filled=[...new Set(vals.filter(v=>mimpFilled(k,v)).map(v=>JSON.stringify(v)))].map(v=>JSON.parse(v));
if(filled.length>1)item.conflicts[k]=filled;item.fields[k]=filled.length?filled[0]:(MEMBER_FIELD_BY_NAME[k].input==='check'?false:'')});
item.purchases=item.copies.flatMap(c=>c.purchases);const main=memberKey(item.equiant);item.older=[...new Set(item.copies.flatMap(c=>c.older))].filter(v=>memberKey(v)!==main)}
function mimpOpenConflicts(x){return Object.keys(x.conflicts).filter(k=>!Object.prototype.hasOwnProperty.call(x.picks,k))}
/* What goes in for a field: your pick on a conflict, else the filled-in value from the file; a blank in the file never erases what is here. */
function mimpIncoming(x,k){return Object.prototype.hasOwnProperty.call(x.picks,k)?x.picks[k]:x.fields[k]}
function mimpGoingIn(x,k,here){const v=mimpIncoming(x,k);return mimpFilled(k,v)?v:(here===undefined?v:here)}
function mimpChanges(x){const base=x.action==='update'?memberById(x.targetId):null;if(!base)return [];return Object.keys(x.fields).filter(k=>{const here=mimpVal(k,base[k]);return JSON.stringify(mimpGoingIn(x,k,here))!==JSON.stringify(here)})}
function mimpShow(k,v){return MEMBER_FIELD_BY_NAME[k]?.input==='check'?(v?'Yes':'No'):String(v??'')}
function mimpCompareHtml(x,i){const base=x.action==='update'?memberById(x.targetId):null,fields=MEMBER_FIELDS.map(f=>f[0]).filter(k=>Object.prototype.hasOwnProperty.call(x.fields,k));
const rows=fields.filter(k=>{const here=base?mimpVal(k,base[k]):undefined,vals=x.copies.map(c=>mimpVal(k,c.fields[k])),all=(base?[here]:[]).concat(vals).map(v=>JSON.stringify(v));return x.conflicts[k]||new Set(all).size>1});
const same=fields.length-rows.length,multi=x.copies.length>1;
const head='
'+(rows.length?same+' other field'+(same===1?' is':'s are')+' the same.':'Nothing changes on this account.')+(x.copies.length>1?' Purchases from all '+x.copies.length+' copies come in; the same purchase is not added twice.':'')+'
'}
function mimpRender(){const p=mimpPlan,body=document.getElementById('members-import-list'),count=document.getElementById('members-import-count'),go=document.getElementById('members-import-go');if(!p||!body)return;
const n=k=>p.items.filter(x=>x.action===k).length,live=p.items.filter(x=>x.action!=='skip'),buys=live.reduce((s,x)=>s+x.purchases.length,0),older=live.filter(x=>x.older.length).length,combined=live.filter(x=>x.copies.length>1).length,open=live.reduce((s,x)=>s+mimpOpenConflicts(x).length,0);
count.innerHTML=''+escapeHtml(p.file)+' · '+n('new')+' new · '+n('update')+' already here, updated · '+n('skip')+' not imported · '+buys+' purchase'+(buys===1?'':'s')+(combined?' · '+combined+' with copies combined':'')+(older?' · '+older+' with older Equiants left behind':'')+(open?' · '+open+' conflict'+(open===1?'':'s')+' to pick':'');
go.disabled=!(n('new')+n('update'))||open>0;go.textContent=open?'Pick '+open+' conflict'+(open===1?'':'s')+' first':'Import '+(n('new')+n('update'));
const what=x=>{if(x.action==='skip')return 'Not imported — '+escapeHtml(x.reason)+'';const ch=mimpChanges(x).length,oc=mimpOpenConflicts(x).length,parts=[x.action==='new'?'New':'Updates the account here'+(ch?' · '+ch+' change'+(ch===1?'':'s'):' · no changes')];
if(x.copies.length>1)parts.push(x.copies.length+' copies combined');if(oc)parts.push(''+oc+' conflict'+(oc===1?'':'s')+' to pick');return parts.join(' · ')};
const order={skip:0,update:1,new:2},list=p.items.map((x,i)=>({x,i})).sort((a,b)=>(mimpOpenConflicts(b.x).length?1:0)-(mimpOpenConflicts(a.x).length?1:0)||order[a.x.action]-order[b.x.action]||a.x.name.localeCompare(b.x.name));
body.innerHTML='
'}
function mimpBind(){const q=id=>document.getElementById(id);q('members-import-open')?.addEventListener('click',mimpOpen);q('members-import-go')?.addEventListener('click',()=>mimpGo());
const ml=q('members-import-list');if(ml&&!ml.dataset.cmpBound){ml.dataset.cmpBound='1';ml.addEventListener('click',e=>{const b=e.target.closest('[data-mimp-cmp]');if(!b||!mimpPlan)return;const x=mimpPlan.items[Number(b.dataset.mimpCmp)];if(x){x.open=!x.open;mimpRender()}});ml.addEventListener('change',e=>{const t=e.target;if(t.dataset.mimpPick===undefined||!mimpPlan)return;const x=mimpPlan.items[Number(t.dataset.mimpPick)],k=t.dataset.field;if(!x||!x.conflicts[k])return;x.picks[k]=x.conflicts[k][Number(t.dataset.n)];mimpRender()})}
q('members-import-file')?.addEventListener('change',e=>{const t=e.target,file=t.files&&t.files[0];t.value='';if(!file)return;try{memberDraftFlush()}catch(err){console.error(err)}mimpRead(file).then(plan=>{mimpPlan=plan;mimpRender();openOverlay('members-import-overlay')}).catch(err=>{console.error(err);toast(String(err.message||err))})})}
function tidyExistingOpen(){if(!pmxAllow('member-edit','write'))return;try{memberDraftFlush()}catch(e){console.error(e)}const L=tidyLists();tidyPending=[];
memberRows.filter(r=>r.active!==false).forEach(r=>{const next=tidyMemberRow(clone(r),L),changes=[...TIDY_MEMBER_FIELDS,'state'].filter(f=>String(r[f]??'')!==String(next[f]??'')).map(f=>({field:f,from:String(r[f]??''),to:String(next[f]??'')}));if(changes.length)tidyPending.push({id:r.id,name:memberDisplayName(next),changes})});
const n=tidyPending.reduce((m,x)=>m+x.changes.length,0);document.getElementById('tidy-existing-count').textContent=tidyPending.length?n+' change'+(n===1?'':'s')+' on '+tidyPending.length+' account'+(tidyPending.length===1?'':'s'):'Nothing to tidy';document.getElementById('tidy-existing-go').disabled=!tidyPending.length;
document.getElementById('tidy-existing-list').innerHTML=tidyPending.length?'
Account
Field
Now
Becomes
'+tidyPending.map(x=>x.changes.map((c,i)=>'
'+(i?'':escapeHtml(x.name))+'
'+escapeHtml(memberLabel(c.field))+'
'+escapeHtml(c.from)+'
'+escapeHtml(c.to)+'
').join('')).join('')+'
':'';
openOverlay('tidy-existing-overlay')}
/* ---- Arrange Fields: each change is kept; Done leaves Arrange (VB72 B70). The layout is everyone's. ---- */
function wnArrangeKeep(){if(!memberArrange||!memberArrange.draft)return;if(!pmxAllow('member-arrange','write',true))return;try{memberArrangeRead();const next=memberLayoutNormalize(memberArrange.draft);wnSettingSet(MEMBER_LAYOUT_KEY,next);memberLayoutCache=next}catch(e){console.error(e);toast('The layout could not be saved')}}
function memberArrangeKeepBind(){const ws=document.getElementById('members-workspace');if(!ws)return;ws.addEventListener('change',e=>{if(e.target.closest&&e.target.closest('.arrange'))setTimeout(wnArrangeKeep,40)},true);ws.addEventListener('click',e=>{const b=e.target.closest&&e.target.closest('.arrange [data-arrange-action]');if(b&&!['cancel','save'].includes(b.dataset.arrangeAction))setTimeout(wnArrangeKeep,60)},true);ws.addEventListener('drop',()=>setTimeout(wnArrangeKeep,60))}
/* ---- Member Accounts in the database (Ver. Beta 17 Rev. 1). VB72 kept these in its own file; here they are the members and member_purchases
tables, and the tours are tour_bookings (the Tour Schedule's own table). A save writes only the fields that changed since the account was read,
so two people working on the same account never overwrite each other. Nothing is deleted: a purchase or a booking that is removed is set aside. ---- */
const MEMBER_BOOL=['trade_value_override','not_in_good_standing'];
async function memberQ(q){const {data,error}=await q;if(error)throw error;return data}
/* every row of a table, a thousand at a time */
async function memberAll(table){return wnSyncAll(table,memberQ)}
function memberOutVal(k,v){if(k==='resort_id')return v?String(v):null;if(k==='active')return !(v===false||v==='false');if(MEMBER_BOOL.includes(k))return bookingIsOn(v);return String(v??'')}
function memberInRow(r){const x=Object.assign({},r);if(x.resort_id==null)x.resort_id='';return x}
async function memberEnsureSchema(){if(!memberTable)memberTable={name:MEMBER_TABLE_NAME};return memberTable}
async function memberRefresh(){memberRows=(await memberAll('members')).map(memberInRow)}
function memberSaveError(e){const m=String(e&&e.message||e||'');return /members_equiant|duplicate key/i.test(m)?'Another Member Account already uses that Equiant / Concord Number.':m}
/* A new account is added; an existing one gets only the fields that differ from the account as it was read (base). */
async function memberPut(row,base){const cols=[...MEMBER_FIELDS.map(f=>f[0]),'active'];let saved;
if(!base){const ins={id:String(row.id||tablesUuid())};cols.forEach(k=>{if(row[k]!==undefined)ins[k]=memberOutVal(k,row[k])});saved=memberInRow(await memberQ(sb.from('members').insert(ins).select().single()))}
else{const patch={};cols.forEach(k=>{if(row[k]===undefined)return;const a=memberOutVal(k,row[k]),b=memberOutVal(k,base[k]);if(JSON.stringify(a)!==JSON.stringify(b))patch[k]=a});if(!Object.keys(patch).length)return memberById(base.id)||base;saved=memberInRow(await memberQ(sb.from('members').update(patch).eq('id',base.id).select().single()))}
const i=memberRows.findIndex(r=>String(r.id)===String(saved.id));if(i>=0)memberRows[i]=saved;else memberRows.push(saved);return saved}
async function memberSaveDraft(opt){const quiet=!!(opt&&opt.quiet);if(!pmxAllow('member-edit','write',quiet)){if(quiet)memberSetMessage('Read only');return false}if(!memberDraft)return false;if(!String(memberDraft.equiant_number||'').trim()){memberSetMessage('Equiant / Concord Number is required.');if(!quiet)document.getElementById('member-field-equiant_number')?.focus();return false}const key=memberKey(String(memberDraft.equiant_number).trim()),clash=memberRows.find(r=>String(r.id)!==String(memberDraft.id)&&memberKey(r.equiant_number)===key);if(clash){memberSetMessage('Another Member Account already uses that Equiant / Concord Number.');return false}
if(quiet){const draft=memberDraft,sent=JSON.stringify(draft);try{const out=clone(draft);out.equiant_number=String(out.equiant_number).trim();const saved=await memberPut(out,memberById(draft.id));if(memberDraft!==draft)return true;if(JSON.stringify(memberDraft)===sent){memberDraftClear(saved.id);memberDirty=false;memberSetMessage('All changes saved')}else memberCardsQueue();return true}catch(e){console.error(e);memberSetMessage(memberSaveError(e));return false}}
memberDraft.equiant_number=String(memberDraft.equiant_number).trim();
try{const saved=await memberPut(memberDraft,memberById(memberDraft.id));memberDraftClear(saved.id);await memberRefresh();memberDraft=clone(memberById(saved.id)||saved);memberDirty=false;memberMessage='Saved.';membersRenderAll();toast('Member Account saved');return true}catch(e){console.error(e);memberSetMessage(memberSaveError(e));return false}}
/* Purchase History */
async function purchaseEnsureSchema(){if(!purchaseTable)purchaseTable={name:PURCHASE_TABLE_NAME};return purchaseTable}
async function purchaseRefresh(){purchaseRows=(await memberAll('member_purchases')).filter(r=>r.active!==false).map(r=>Object.assign({},r,{purchase_date:r.purchase_date?String(r.purchase_date).slice(0,10):''}))}
async function purchasePut(row){const o={member_id:String(row.member_id),purchase_date:row.purchase_date?String(row.purchase_date).slice(0,10):null,equity_amount:String(row.equity_amount??''),price_override:String(row.price_override??''),is_transfer:bookingIsOn(row.is_transfer),custom_override:String(row.custom_override??'')};
if(row.id&&purchaseRows.some(r=>String(r.id)===String(row.id)))return memberQ(sb.from('member_purchases').update(o).eq('id',row.id).select().single());return memberQ(sb.from('member_purchases').insert(Object.assign({id:String(row.id||tablesUuid())},o)).select().single())}
async function purchaseSetAside(id){await memberQ(sb.from('member_purchases').update({active:false}).eq('id',id))}
/* Setup with a worksheet: a Last Purchase Amount on the worksheet becomes the first purchase. */
async function purchaseFromWorksheet(memberId,value){const raw=String(value??'').trim();if(!raw)return;const n=purchaseMoney(raw),transfer=/transfer/i.test(raw);if(n===null&&!transfer)return;await purchasePut({member_id:memberId,purchase_date:'',equity_amount:n===null?'':String(n),price_override:'',is_transfer:n===null,custom_override:n===null&&!/^transfer$/i.test(raw)?raw:''});await purchaseRefresh()}
async function memberLastPurchaseChanged(){const d=memberDraft;if(!d)return;if(!pmxAllow('member-edit','write'))return;const box=document.getElementById('member-last-transfer'),input=document.getElementById('member-last-amount'),s=memberLastPurchaseState();if(!box||!input||s.locked)return;const tr=box.checked;input.disabled=tr;input.placeholder=tr?'TRANSFER':'$0.00';if(tr)input.value='';const raw=String(input.value||'').trim(),n=purchaseMoney(raw);input.classList.toggle('invalid',!tr&&!!raw&&n===null);if(!tr&&raw&&n===null)return;if(n!==null)input.value=purchaseCurrency(n);if(!s.latest&&!tr&&n===null)return;
const row=s.latest?clone(s.latest):{member_id:d.id,purchase_date:'',price_override:'',custom_override:''};Object.assign(row,{member_id:d.id,is_transfer:tr,equity_amount:tr||n===null?'':String(n)});await purchasePut(row);await purchaseRefresh();purchaseAccountRefresh()}
/* The tour on the account (a booking on the Tour Schedule's table) */
function bookingNewRow(o){const row={id:tablesUuid()};Object.keys(o).forEach(c=>row[c]=bookingOut(c,o[c]));return row}
async function bookingAdd(o){const row=bookingIn(await memberQ(sb.from('tour_bookings').insert(bookingNewRow(o)).select().single()));bookingRows.push(row);return row}
async function bookingSaveRow(wrap,changed){if(!pmxAllow('member-edit','write'))return;if(!wrap)return;const memberId=wrap.dataset.bookingMember,v=bookingReadRow(wrap),lab=wrap.querySelector('[data-booking-kiosk-tag]');if(changed==='kiosk'&&lab){if(v.kiosk){v.kiosk_label=bookingKioskDefault();lab.textContent=v.kiosk_label}lab.hidden=!v.kiosk}if(!v.kiosk)v.kiosk_label='';let id=wrap.dataset.bookingRow;if(!id&&!v.booking_name&&!v.tour_date&&!v.tour_time&&!v.kiosk)return;
const base=id?bookingRows.find(r=>String(r.id)===String(id)):null;let saved;
if(base){const patch={};(changed==='kiosk'?['kiosk','kiosk_label']:[changed]).forEach(k=>{if(k in v)patch[k]=v[k]});if(!Object.keys(patch).length)return;saved=await bookingPatch(base.id,patch)}
else saved=await bookingAdd(Object.assign({member_id:memberId},v));wrap.dataset.bookingRow=saved.id}
/* Remove on Past Bookings sets the booking aside (Removed), the way a tour that leaves the schedule is. */
async function bookingRemove(wrap){if(!pmxAllow('member-edit','write'))return;const id=wrap?.dataset.bookingRow;if(!id||!confirm('Remove this booking?'))return;await bookingPatch(id,{removed:true,removed_at:new Date().toISOString()});bookingPastRender();bookingStripRefresh()}
/* Balance Update's Save */
async function balanceSaveNow(o){const s=balanceState,d=memberDraft,status=document.querySelector('#member-balance-card #members-balance-status')||document.getElementById('members-balance-status'),btn=document.getElementById('members-balance-save');if(!s||!d)return false;balanceReadRows();const say=t=>{status.textContent=o.quiet?'Not saved yet. '+t:t;status.classList.toggle('flag',!o.quiet);return false};
if(!pmxAllow('member-balance','write'))return false;
const rows=s.rows.filter(r=>r.id||r.purchase_date||String(r.equity_amount).trim()||String(r.price_override).trim()||r.is_transfer||String(r.custom_override).trim());
for(const r of rows){if(!r.is_transfer&&purchaseMoney(r.equity_amount)===null)return say('Each purchase needs an Equity amount or the Transfer checkbox.');if(String(r.price_override).trim()&&purchaseMoney(r.price_override)===null)return say('Purchase Price Override must be an amount.')}
const loanText=String(document.getElementById('balance-loan-base').value||'').trim(),loan=loanText===''?0:purchaseMoney(loanText);if(loan===null)return say('Enter a valid Actual Loan Balance.');
let loanOv='',equityOv='';if(s.loanOv.on){const t=String(document.getElementById('balance-loan-value').value||'').trim();if(t===''||t==='$')loanOv='$';else{const n=purchaseMoney(t);if(n===null)return say('Enter a valid Loan Balance override.');loanOv=purchaseCurrency(n)}}if(s.equityOv.on){equityOv=String(document.getElementById('balance-equity-value').value||'').trim();if(!equityOv)return say('Enter the Equity override text.')}
if(o.close)btn.disabled=true;status.classList.remove('flag');status.textContent='Saving…';
try{for(const id of s.removed)await purchaseSetAside(id);s.removed=[];
for(const r of rows){const ov=purchaseMoney(r.price_override),saved=await purchasePut({id:r.id,member_id:d.id,purchase_date:r.purchase_date||'',equity_amount:r.is_transfer?'':String(purchaseMoney(r.equity_amount)),price_override:ov===null?'':String(ov),is_transfer:!!r.is_transfer,custom_override:String(r.custom_override||'').trim()});if(!r.id)purchaseRows.push(saved);r.id=saved.id;if(!r.created)r.created=saved.created_at||new Date().toISOString()}
/* the account keeps only the real figures; other unsaved edits on the account screen are left as they are */
const loanStr=purchaseCurrency(loan),stored=memberById(d.id);if(stored)await memberPut(Object.assign(clone(stored),{current_loan_payoff:loanStr}),stored);d.current_loan_payoff=loanStr;
if(!s.bookingId&&(s.loanOv.on||s.equityOv.on)){const made=await bookingAdd({member_id:d.id,booking_name:'',tour_date:'',tour_time:'',kiosk:false,kiosk_label:''});s.bookingId=made.id}
if(s.bookingId){const base=bookingRows.find(r=>String(r.id)===String(s.bookingId));if(base)await bookingPatch(base.id,{loan_override_on:s.loanOv.on,loan_override_value:s.loanOv.on?loanOv:String(base.loan_override_value||''),equity_override_on:s.equityOv.on,equity_override_value:s.equityOv.on?equityOv:String(base.equity_override_value||'')})}
await memberRefresh();await purchaseRefresh();await bookingRefresh();s.dirty=false;
const a=document.getElementById('member-field-current_loan_payoff');if(a)a.value=loanStr;purchaseAccountRefresh();memberLoanPaymentShow();bookingStripRefresh();
if(o.close){balanceState=null;closeOverlay('members-balance-overlay');membersRenderAccount();toast('Balance update saved')}else status.textContent='Saved '+new Date().toLocaleTimeString([],{hour:'numeric',minute:'2-digit'});return true}
catch(e){console.error(e);status.textContent=String(e.message||e);status.classList.add('flag');return false}finally{btn.disabled=false}}
/* Setup's new account */
async function memberSetupGo(scratch){const s=memberSetupState;if(s.phase!=='new'||s.busy)return;const raw=typeof b33cRawReady==='function'&&b33cRawReady('setup');if(!scratch&&!memberSetupFile&&!raw)return;await memberRefresh();if(memberFindByEquiant(s.equiant)){memberSetupVerify();return}memberDraftFlush();s.busy=true;memberSetupRender();let P=null,note='';const tour=s.tourId?scheduleTourById(s.tourId):null;
/* Ver. Beta 33 Rev. 1: from the Worksheet, the Raw Data or both. The Resort is the tour's, else the data's (Member Resort, or the Member Number's resort code). */
try{if(!scratch){memberSetupStatus(memberSetupFile?'Reading '+escapeHtml(memberSetupFile.name)+'…':'Reading the Raw Data…');P=await b33cMemberSetup.load('setup',memberSetupFile);if(!P){s.busy=false;memberSetupStatus('');memberSetupRender();return}}
const row={id:tablesUuid(),equiant_number:s.equiant,active:true},resortId=tour?.resort_id||P?.resortId||'';if(P){Object.assign(row,P.row);if(!String(row.member_number||'').trim()){const temp=memberTemporaryNumber(resortId,s.equiant);if(temp)row.member_number=temp}tidyMemberRow(row)}if(resortId)row.resort_id=resortId;
const saved=await memberPut(row,null);if(s.tourId){await scheduleLinkTour(s.tourId,saved.id);scheduleAccountPending=true}if(P){await b33cMemberSetup.purchases(saved.id,P);note=Object.keys(P.row).filter(f=>String(saved[f]??'').trim()!=='').length+' fields from '+P.source}await memberRefresh();await purchaseRefresh();memberSearch='';const search=document.getElementById('members-search');if(search)search.value='';memberDirty=false;memberSelectedId=saved.id;memberDraft=clone(memberById(saved.id)||saved);memberMessage=note;closeOverlay('members-setup-overlay');membersRenderAll();toast('Member Account created');if(scheduleAccountPending){scheduleAccountPending=false;scheduleAccountTourId=s.tourId||'';scheduleAccountShow()}if(!P)setTimeout(()=>document.getElementById('member-field-primary_member')?.focus(),30)}
catch(e){console.error(e);s.busy=false;memberSetupRender();memberSetupStatus(''+escapeHtml(memberSaveError(e))+'')}}
/* Tours and accounts */
async function scheduleLinkTour(tourId,memberId){await bookingRefresh();const base=scheduleTourById(tourId);if(!base||!memberId)return false;const patch={member_id:memberId};
if(bookingDay(base)&&bookingDay(base)>=bookingToday()&&!bookingIsOn(base.loan_override_on)&&!bookingIsOn(base.equity_override_on)){const blank=bookingsFor(memberId).filter(r=>!bookingDay(r)&&!String(r.booking_name||'').trim()&&(bookingIsOn(r.loan_override_on)||bookingIsOn(r.equity_override_on))).sort((a,b)=>String(b.created_at||'').localeCompare(String(a.created_at||'')))[0];if(blank)['loan_override_on','loan_override_value','equity_override_on','equity_override_value'].forEach(k=>{patch[k]=blank[k]})}
await bookingPatch(base.id,patch);return true}
async function scheduleRelinkGo(){const s=scheduleRelink,r=scheduleTourById(s.tourId);if(!r||!s.candidateId)return;if(!pmxAllow('schedule-record-edit','write'))return;const hist=Array.isArray(r.link_history)?r.link_history.slice():[];hist.push({from:r.member_id||'',to:s.candidateId,at:new Date().toISOString(),by:(wnUser&&wnUser.email)||''});try{await scheduleRowPatch(r.id,{member_id:s.candidateId,link_history:hist});closeOverlay('schedule-relink-overlay');toast('Tour linked to the corrected account')}catch(e){console.error(e);s.message=String(e.message||e);scheduleRelinkRender()}}
/* Import and Tidy Existing */
async function mimpGo(){const p=mimpPlan;if(!p||!pmxAllow('member-setup','write'))return;if(p.items.some(x=>x.action!=='skip'&&mimpOpenConflicts(x).length)){toast('Pick the conflicting values first');return}const go=document.getElementById('members-import-go');go.disabled=true;let added=0,updated=0,buys=0;
try{for(const x of p.items){if(x.action==='skip')continue;const base=x.action==='update'?memberById(x.targetId):null,row=base?clone(base):{id:tablesUuid(),active:true};
Object.keys(x.fields).forEach(k=>{const v=mimpIncoming(x,k);if(base&&!mimpFilled(k,v))return;row[k]=v});
const saved=await memberPut(row,base);if(base)updated++;else added++;
const have=purchasesFor(saved.id).map(r=>[purchaseDay(r),String(r.equity_amount??''),String(r.price_override??''),String(r.custom_override??''),purchaseIsTransfer(r)].join('|'));
for(const b of x.purchases){const one={member_id:saved.id,purchase_date:String(b.purchase_date||''),equity_amount:String(b.equity_amount??''),price_override:String(b.price_override??''),is_transfer:b.is_transfer===true,custom_override:String(b.custom_override??'')},sig=[one.purchase_date.slice(0,10),one.equity_amount,one.price_override,one.custom_override,one.is_transfer].join('|');if(have.includes(sig))continue;have.push(sig);await purchasePut(one);buys++}}
await memberRefresh();await purchaseRefresh();if(memberSelectedId&&!memberDirty)memberDraft=clone(memberById(memberSelectedId));membersRenderAll();mimpPlan=null;closeOverlay('members-import-overlay');
toast('Imported '+added+' new and updated '+updated+' account'+(added+updated===1?'':'s')+(buys?' · '+buys+' purchase'+(buys===1?'':'s'):''))}
catch(e){console.error(e);go.disabled=false;toast('The import stopped: '+memberSaveError(e))}}
async function tidyExistingGo(){if(!pmxAllow('member-edit','write')||!tidyPending.length)return;const btn=document.getElementById('tidy-existing-go');btn.disabled=true;let done=0;
try{for(const x of tidyPending){const row=memberById(x.id);if(!row)continue;const next=clone(row);x.changes.forEach(c=>{if(String(row[c.field]??'')===c.from)next[c.field]=c.to});await memberPut(next,row);done++}
await memberRefresh();if(memberSelectedId&&!memberDirty)memberDraft=clone(memberById(memberSelectedId));membersRenderAll();tidyPending=[];closeOverlay('tidy-existing-overlay');toast('Tidied '+done+' account'+(done===1?'':'s'))}
catch(e){console.error(e);btn.disabled=false;toast('The accounts could not be tidied: '+String(e.message||e))}}
/* Trade Value: worked out from the Membership types once they are in the program; until then it is the amount typed on the account. */
function memberTradeFromMembership(d){const m=memberTradeMembership(d),lv=m?mshipLevelRow(m.level):null;return lv?mshipMoney(lv.trade_value):''}
/* The screen */
let memberLoading=null;
function membersOnScreen(){return !!document.getElementById('page-members')?.classList.contains('active')}
async function membersEnter(){memberListWidthLoad();membersRenderAll();if(!memberLoading)memberLoading=Promise.all([orgLoad().catch(e=>console.error('Organization could not load',e)),memberRefresh(),purchaseRefresh(),bookingRefresh(),peopleLoad().catch(()=>{}),mshipLoad(true).catch(e=>console.error('Memberships could not load',e))]).finally(()=>{memberLoading=null});try{await memberLoading}catch(e){console.error(e);toast('Member Accounts could not be read: '+String(e&&e.message||e));return}
if(memberSelectedId&&!memberById(memberSelectedId)){memberSelectedId='';memberDraft=null;memberDirty=false}if(!memberDirty&&memberSelectedId)memberDraft=clone(memberById(memberSelectedId));membersRenderAll()}
function membersBind(){if(membersBound)return;membersBound=true;const q=id=>document.getElementById(id);q('members-search')?.addEventListener('input',e=>{memberSearch=e.target.value||'';membersRenderList()});q('members-list')?.addEventListener('click',e=>{const b=e.target.closest('[data-member-id]');if(b)memberSelect(b.dataset.memberId)});const ws=q('members-workspace');
ws?.addEventListener('input',e=>{const t=e.target,name=t.dataset.memberField;if(!name||!memberDraft)return;if(MEMBER_FIELD_BY_NAME[name]?.input==='tel'){const masked=applyDigitMask(currentFormats().phone.mask,digitsOnly(t.value).slice(0,10));if(masked!==t.value)t.value=masked}memberDraft[name]=t.value;memberDirty=true;memberSetMessage('Unsaved changes');if(['primary_member','secondary_member','equiant_number','member_number'].includes(name))memberHeaderRefresh()});
ws?.addEventListener('click',e=>{const a=e.target.closest('[data-member-action]');if(!a)return;const act=a.dataset.memberAction;if(act==='save')memberSaveDraft();else if(act==='discard')memberDiscard();else if(act==='update-account')b33cUpdateOpen()});
q('members-setup-open')?.addEventListener('click',memberSetupOpen);q('members-tidy-open')?.addEventListener('click',tidyExistingOpen);mimpBind();q('tidy-existing-go')?.addEventListener('click',()=>tidyExistingGo());memberSetupBind();bookingBind();purchaseBind();memberFieldsBind();memberLayoutBind();memberWorkspaceBind();memberArrangeKeepBind();memberCardsBind();scheduleAccountBind();scheduleMembersBind();
/* Ctrl+S on the account saves it (a popout in front saves itself) */
document.addEventListener('keydown',e=>{if(!((e.ctrlKey||e.metaKey)&&String(e.key).toLowerCase()==='s')||e.altKey)return;if(document.querySelector('.overlay.open:not(#schedule-account-overlay)'))return;if(!(membersOnScreen()||document.getElementById('schedule-account-overlay')?.classList.contains('open'))||!memberDraft||memberArrange)return;if(memberCards){e.preventDefault();memberCardsSaveNow();return}memberSaveDraft()})}
/* the Tour Schedule's buttons for accounts: Open, Setup, Update Record (with Record Editing on) and Slack Balances */
function scheduleMembersBind(){const sc=document.getElementById('schedule-screen');if(sc&&!sc.dataset.membersBound){sc.dataset.membersBound='1';
sc.addEventListener('click',e=>{const t=e.target,hit=s=>t.closest(s);const o=hit('[data-schedule-open]');if(o){scheduleAccountOpen(o.dataset.scheduleOpen,o.dataset.scheduleTour||'').catch(console.error);return}const su=hit('[data-schedule-setup]');if(su){scheduleSetupOpen(su.dataset.scheduleSetup);return}const rl=hit('[data-schedule-relink]');if(rl){scheduleRelinkOpen(rl.dataset.scheduleRelink);return}
const a=hit('[data-schedule-action]')?.dataset.scheduleAction;if(a==='slack'){scheduleSlackOpen();return}if(a==='lock'){if(!pmxAllow('schedule-record-edit','write'))return;scheduleUnlocked=!scheduleUnlocked;scheduleScreenRender()}})}
document.getElementById('schedule-slack-copy')?.addEventListener('click',()=>scheduleSlackCopy());
const ro=document.getElementById('schedule-relink-overlay');ro?.addEventListener('click',e=>{if(e.target.closest('#schedule-relink-verify'))scheduleRelinkVerify().catch(console.error);else if(e.target.closest('#schedule-relink-go'))scheduleRelinkGo();else if(e.target.closest('#schedule-relink-cancel'))closeOverlay('schedule-relink-overlay')});ro?.addEventListener('keydown',e=>{if(e.key==='Enter'&&e.target.id==='schedule-relink-equiant'){e.preventDefault();scheduleRelinkVerify().catch(console.error)}})}
/* the screen is ready to use from the Tour Schedule too: the accounts load with the schedule */
async function scheduleMembersReady(){try{await Promise.all([memberRefresh(),purchaseRefresh(),orgLoad()])}catch(e){console.error('Member Accounts could not load',e)}}
/* ===== Utility › Profile Layout and Profiles (Ver. Beta 6 Rev. 1; the profile engine from Greg's Profile Generator since Ver. Beta 7 Rev. 3) =====
Profile Layout, the record adapters and the Personnel model are VB72's. The profile engine (poster, scenes, emblems and logo styles,
designer, Customize Profile) is the Profile Generator's, with its layered replacements made single functions. The Beta's own
Customize Profile save and popout keep their permission checks. Settings go to the settings table (profileKv), pictures to the
'profiles' storage folder (profileFilePut / profileFileGet).
profileSrc: profiles read Family, Resort and Personnel records through it, picking up each section's own functions by name
(orgFields, orgActive, ... since Organization came in with Ver. Beta 7 Rev. 1; peopleFields, peopleCache, ... since Personnel &
Roles came in with Ver. Beta 9 Rev. 1). Until a section is in, its entries give back nothing. Nothing here changes when one comes in, and none of
these empty versions may be copied into a section. pmxAllow is the Beta's own permission check. */
const profileSrc={
get ORG_BUILTIN_WIRES(){try{return typeof ORG_BUILTIN_WIRES!=='undefined'?ORG_BUILTIN_WIRES:[]}catch(_){return []}},
get ORG_KINDS(){try{return typeof ORG_KINDS!=='undefined'?ORG_KINDS:{}}catch(_){return {}}},
get orgRows(){try{return typeof orgRows!=='undefined'?orgRows:{wire:[]}}catch(_){return {wire:[]}}},
get orgActive(){try{return typeof orgActive==='function'?orgActive:()=>[]}catch(_){return ()=>[]}},
get orgById(){try{return typeof orgById==='function'?orgById:()=>null}catch(_){return ()=>null}},
get orgFamilyById(){try{return typeof orgFamilyById==='function'?orgFamilyById:()=>null}catch(_){return ()=>null}},
get orgFields(){try{return typeof orgFields==='function'?orgFields:()=>[]}catch(_){return ()=>[]}},
get orgParts(){try{return typeof orgParts==='function'?orgParts:()=>[]}catch(_){return ()=>[]}},
get orgNameKey(){try{return typeof orgNameKey==='function'?orgNameKey:()=>''}catch(_){return ()=>''}},
get orgAmenRows(){try{return typeof orgAmenRows==='function'?orgAmenRows:()=>[]}catch(_){return ()=>[]}},
get infraRoots(){try{return typeof infraRoots==='function'?infraRoots:()=>[]}catch(_){return ()=>[]}},
get infraChildren(){try{return typeof infraChildren==='function'?infraChildren:()=>[]}catch(_){return ()=>[]}},
get orgLoad(){try{return typeof orgLoad==='function'?orgLoad:async()=>{}}catch(_){return async()=>{}}},
get PEOPLE_PRIMARY_WORK_MODES(){try{return typeof PEOPLE_PRIMARY_WORK_MODES!=='undefined'?PEOPLE_PRIMARY_WORK_MODES:[]}catch(_){return []}},
get peopleCache(){try{return typeof peopleCache!=='undefined'?peopleCache:{personnel:[],department:[],division:[],role:[],assignment:[]}}catch(_){return {personnel:[],department:[],division:[],role:[],assignment:[]}}},
get peopleById(){try{return typeof peopleById==='function'?peopleById:()=>null}catch(_){return ()=>null}},
get peopleFields(){try{return typeof peopleFields==='function'?peopleFields:()=>[]}catch(_){return ()=>[]}},
get peopleFieldOn(){try{return typeof peopleFieldOn==='function'?peopleFieldOn:()=>false}catch(_){return ()=>false}},
get peopleFieldValue(){try{return typeof peopleFieldValue==='function'?peopleFieldValue:()=>''}catch(_){return ()=>''}},
get peopleFullName(){try{return typeof peopleFullName==='function'?peopleFullName:()=>''}catch(_){return ()=>''}},
get peopleAssignments(){try{return typeof peopleAssignments==='function'?peopleAssignments:()=>[]}catch(_){return ()=>[]}},
get peoplePrimaryAssignment(){try{return typeof peoplePrimaryAssignment==='function'?peoplePrimaryAssignment:()=>null}catch(_){return ()=>null}},
get peoplePrimaryDivisionId(){try{return typeof peoplePrimaryDivisionId==='function'?peoplePrimaryDivisionId:()=>''}catch(_){return ()=>''}},
get peopleRoleForAssignment(){try{return typeof peopleRoleForAssignment==='function'?peopleRoleForAssignment:()=>null}catch(_){return ()=>null}},
get peopleDepartmentPath(){try{return typeof peopleDepartmentPath==='function'?peopleDepartmentPath:()=>''}catch(_){return ()=>''}},
get peopleResortName(){try{return typeof peopleResortName==='function'?peopleResortName:()=>''}catch(_){return ()=>''}},
get peopleReportingManagerIds(){try{return typeof peopleReportingManagerIds==='function'?peopleReportingManagerIds:()=>[]}catch(_){return ()=>[]}},
get peopleNormalizePrimaryWorkMode(){try{return typeof peopleNormalizePrimaryWorkMode==='function'?peopleNormalizePrimaryWorkMode:()=>''}catch(_){return ()=>''}},
get peopleProfileVisibility(){try{return typeof peopleProfileVisibility==='function'?peopleProfileVisibility:()=>({})}catch(_){return ()=>({})}},
get peopleRenderWork(){try{return typeof peopleRenderWork==='function'?peopleRenderWork:()=>{}}catch(_){return ()=>{}}},
get peopleSetMessage(){try{return typeof peopleSetMessage==='function'?peopleSetMessage:()=>{}}catch(_){return ()=>{}}},
get peopleLoad(){try{return typeof peopleLoad==='function'?peopleLoad:async()=>{}}catch(_){return async()=>{}}},
get pmxAllow(){try{return typeof pmxAllow==='function'?pmxAllow:()=>true}catch(_){return ()=>true}},
};
/* Profile pictures: a profile's header photo, a person's background photo, and Family and Resort logos (Organization saves them here too). */
const PROFILE_FILES='profiles';
async function profileFilePut(f){await wnFilePut(Object.assign({},f,{bucket:PROFILE_FILES}))}
async function profileFileGet(id){const {dataUrl}=await wnFileGet(PROFILE_FILES,id);return {blob:dataUrlToBlob(dataUrl)}}
function dataUrlToBlob(dataUrl){const parts=String(dataUrl||'').split(','),meta=parts[0]||'',raw=atob(parts[1]||''),type=(meta.match(/^data:([^;]+)/)||[])[1]||'application/octet-stream',bytes=new Uint8Array(raw.length);for(let i=0;i{const contact=f.mask==='phone'||f.mask==='phone-ext'||f.mask==='email',hidden=f.column_name==='first_name'||f.column_name==='last_name';return {key:'pf:'+f.id,source:f.label||'Field',def:hidden?'hidden':contact?'contact':'info'}});return [...fields,{key:'p:role',source:'Title',def:'ribbon'},{key:'p:division',source:'Division',def:'hidden'},{key:'p:location',source:'Based',def:'ribbon'},{key:'p:tenure',source:'Tenure',def:'ribbon'},{key:'p:department',source:'Department',def:'info'},{key:'p:family',source:'Family',def:'info'},{key:'p:work_mode',source:'Work Mode',def:'info'},{key:'p:reports_to',source:'Reports To',def:'info'}]}
/* Title: each Work Assignment's Division (with Virtual when that assignment is Virtual, since Virtual is not tied to a resort), then the Role: 'Inhouse Virtual Admin', 'Frontline & Inhouse Admin'. */
function profilePersonTitle(p){const rows=profileSrc.peopleAssignments(p.id),role=profileSrc.peopleRoleForAssignment(rows.find(a=>a.assignment_type==='primary')),parts=[];rows.forEach(a=>{const d=profileSrc.peopleCache.division.find(x=>String(x.id)===String(profileSrc.peoplePrimaryDivisionId(a)));if(!d)return;const txt=(d.division_name||'')+((Array.isArray(a.work_types)?a.work_types:[]).includes('virtual')?' Virtual':'');if(txt&&!parts.includes(txt))parts.push(txt)});return [parts.join(' & '),role?.role_name||''].filter(Boolean).join(' ')}
function profilePersonModel(p){const vis=profileSrc.peopleProfileVisibility(p),items={};profileSrc.peopleFields().filter(profileSrc.peopleFieldOn).forEach(f=>{let v=String(profileSrc.peopleFieldValue(p,f)??'').trim();if((f.column_name==='birthday'&&!vis.birthday)||(f.column_name==='start_date'&&!vis.start_date))v='';if(f.mask==='date'&&v)v=profilePersonDate(v);if(!v)return;const kind=f.mask==='phone'||f.mask==='phone-ext'?'tel':f.mask==='email'?'mailto':(/^https?:\/\//i.test(v)?'link':'');items['pf:'+f.id]={v,kind}});
const pa=profileSrc.peoplePrimaryAssignment(p.id),role=profileSrc.peopleRoleForAssignment(pa),div=profileSrc.peopleCache.division.find(x=>String(x.id)===String(profileSrc.peoplePrimaryDivisionId(pa))),fam=profileSrc.orgFamilyById(p.family_id),loc=pa?(pa.location_scope==='all'?'All Locations':profileSrc.peopleResortName(pa.resort_id)):'',mgrs=profileSrc.peopleReportingManagerIds(p).map(id=>profileSrc.peopleFullName(profileSrc.peopleById('personnel',id))).filter(Boolean),add=(k,v)=>{if(v)items[k]={v:String(v),kind:''}};
add('p:role',profilePersonTitle(p));add('p:division',div?.division_name);add('p:location',loc);add('p:tenure',vis.start_date?profilePersonTenure(p.start_date):'');add('p:department',role?.department_id?profileSrc.peopleDepartmentPath(role.department_id):'');add('p:family',fam?.family_name);add('p:work_mode',pa?(profileSrc.PEOPLE_PRIMARY_WORK_MODES.find(x=>x[0]===profileSrc.peopleNormalizePrimaryWorkMode(pa.work_mode))?.[1]||''):'');add('p:reports_to',mgrs.join(', '));
return {kind:'personnel',id:p.id,name:profileSrc.peopleFullName(p)||'Personnel',title:profilePersonTitle(p),items,logoFiles:p.photo_file_id?[{id:p.photo_file_id,name:'Photo'}]:[],familyBrand:null,bio:String(p.bio||''),degrees:String(p.degrees||''),certifications:String(p.certifications||'')}}
function profilePersonSections(section,m){if(section.key==='about'){const bio=String(m.bio||'').trim();return m.kind==='personnel'&&bio?profileSection(section.label,'
'}
/* ===== Scenes (Ver. B10): every landscape is drawn across the full header (no mirrored copy).
Trees, ridges, plants and buildings are placed from a seed, so each profile has its own layout; Shuffle picks a new seed.
The seed, Snow Cover and Snowing are part of the user's own customization (WNSYS.db). ===== */
const PROFILE_LANDSCAPE_GROUPS=[
['Forest',[['pines','Pine Forest'],['denseforest','Dense Forest'],['mixedforest','Mixed Forest'],['forestridge','Forest Ridge'],['fallforest','Fall Forest'],['winterforest','Winter Forest'],['northwoods','North Woods']]],
['Hills',[['hills','Rolling Hills'],['steephills','Steep Hills'],['foothills','Layered Foothills'],['woodedhills','Wooded Hills'],['ozark','Ozark Ridges'],['vineyard','Vineyard Hills']]],
['Valleys & Rivers',[['valley','Wooded Valley'],['sidevalley','One-Sided Valley'],['river','Winding River'],['threerivers','Three Rivers'],['bluffs','River Bluffs'],['waterfall','Waterfall']]],
['Mountains',[['mountains','Mountains'],['jaggedpeaks','Jagged Peaks'],['snowpeaks','Snowcapped Peaks'],['smokymountains','Rounded / Smoky Mountains'],['mountainlake','Mountain Lake']]],
['Water & Coast',[['lakeshore','Lakeshore'],['forestlake','Forested Lake'],['lakefoothills','Lake Foothills'],['coast','Beach / Coast'],['rockycoast','Rocky Coast'],['tropical','Tropical / Palms'],['bayou','Bayou / Swamp']]],
['Open Country',[['prairie','Prairie'],['desert','Desert Mesas'],['canyon','Canyon']]],
['City',[['skyline','City Skyline']]]
];
const PROFILE_LANDSCAPE_LABELS=Object.fromEntries(PROFILE_LANDSCAPE_GROUPS.flatMap(g=>g[1]));
const PROFILE_FALL_LEAVES=['#b8321f','#d0531b','#e07b22','#eda72c','#c9611a','#93301c','#d9902a'];
const PROFILE_PINE_KINDS=['pine','spruce','fir','wide','rough','young'];
const PROFILE_LEAF_KINDS=['round','oval','spread','cluster'];
const PROFILE_SNOW='#f3f7fa',PROFILE_SNOW_SHADE='#d5e0ea';
/* seeded random numbers */
function sceneRng(seed){let s=(Number(seed)>>>0)||1;return function(){s=(s+0x6D2B79F5)>>>0;let t=s;t=Math.imul(t^(t>>>15),t|1);t^=t+Math.imul(t^(t>>>7),t|61);return((t^(t>>>14))>>>0)/4294967296}}
function sceneHash(str){let h=2166136261;for(const ch of String(str)){h^=ch.charCodeAt(0);h=Math.imul(h,16777619)}return (h>>>0)||1}
function sceneNewSeed(){return (Math.floor(Math.random()*4294967295)>>>0)||1}
function sR(r,a,b){return a+(b-a)*r()}
function sPick(r,list){return list[Math.floor(r()*list.length)%list.length]}
function sWeighted(r,pairs){const total=pairs.reduce((s,p)=>s+p[1],0);let v=r()*total;for(const [k,w] of pairs){v-=w;if(v<=0)return k}return pairs[pairs.length-1][0]}
function sN(v){return Math.round(v*10)/10}
function sceneMix(a,b,k){const p=h=>{const n=parseInt(String(h||'#000000').slice(1),16);return [n>>16,(n>>8)&255,n&255]},x=p(a),y=p(b);return '#'+x.map((v,i)=>Math.round(v+(y[i]-v)*k)).map(v=>Math.max(0,Math.min(255,v)).toString(16).padStart(2,'0')).join('')}
/* ridges */
function sceneRidge(r,y,amp,step,mode){const pts=[];let x=-60,i=0;while(x<1260+step){let yy;if(mode==='peaks')yy=i%2===0?y-sR(r,amp*.35,amp):y+sR(r,0,amp*.35);else yy=y+sR(r,-amp,amp);pts.push([x,yy]);x+=step*sR(r,.65,1.35);i++}return pts}
function sceneRough(r,pts,jit){const out=[];for(let i=0;isN(p[0])+' '+sN(p[1])).join(' L')}
function sceneSmoothLine(pts){let d='M'+sN(pts[0][0])+' '+sN(pts[0][1]);for(let i=0;i'}
function sceneYAt(pts,x){for(let i=0;i=a[0]&&x<=b[0]){const f=(x-a[0])/((b[0]-a[0])||1);return a[1]+(b[1]-a[1])*f}}return pts[pts.length-1][1]}
function sceneCaps(r,pts,depth,color,shade){let out='';for(let i=1;i=1;k--){const f=k/n;edge+=' L'+sN(L[0]+(Rr[0]-L[0])*f)+' '+sN(L[1]+(k%2?-d*.28:d*.1)*sR(r,.5,1.1))}out+='';out+=''}return out}
let sceneClipN=0;
function sceneCapsOn(k,base,drawn,depth){const id=k.wat+'_cap'+(++sceneClipN),r=k.r;let caps='';for(let i=1;i=1;q--){const f=q/n;edge+=' L'+sN(L[0]+(Rr[0]-L[0])*f)+' '+sN(L[1]+(q%2?-d*.3:d*.08)*sR(r,.5,1.1))}caps+=''}return caps?''+caps+'':''}
function sceneWater(r,wat,y){let ripples='';for(let i=0;i<9;i++){const yy=y+12+i*((300-y)/10),x=sR(r,-80,1100),w=sR(r,90,260);ripples+=''}return ''+ripples+''}
/* trees and plants (x = base center, y = base, h = height) */
function scenePine(kind,x,y,h,col,r,snow){const [lit,shd]=scenePair(col,.32),tiers={pine:1,spruce:5,fir:3+Math.floor(r()*2),wide:2,rough:5+Math.floor(r()*2),young:2}[kind]||1,wr={pine:.34,spruce:.21,fir:.42,wide:.56,rough:.36,young:.46}[kind]||.34;if(kind==='young')h*=.55;const trunkH=h*(kind==='pine'?.06:.1),top=y-h,body=h-trunkH,dark=sceneDk(shd,.4);let s='',half='';if(kind!=='pine')s+='';const caps=[];
for(let i=0;i';half+='M'+sN(x+off*.3)+' '+sN(tierTop)+' L'+sN(x+w/2+off)+' '+sN(tierBase)+' Q'+sN(x+off+w*.22)+' '+sN(tierBase-droop*1.6)+' '+sN(x+off*.6)+' '+sN(tierBase-droop*2)+' Z';caps.push([x+off*.3,tierTop,x-w/2+off,x+w/2+off,tierBase])}
s+='';
if(snow)caps.forEach(([ax,ay,lx,rx,by],ci)=>{const f=ci===0?sR(r,.38,.5):sR(r,.22,.32),ly=ay+(by-ay)*f,l=ax+(lx-ax)*f,rr=ax+(rx-ax)*f,mid=ly+sR(r,-1,2);s+=''});return s}
function sceneLeafy(kind,x,y,h,col,r,leaves){const [lit,shd]=scenePair(col,.3),trunk=sceneDk(shd,.4),cy=y-h*.6,pick=()=>{if(leaves){const c=sPick(r,leaves);return [c,sceneDk(c,.3)]}const d=Math.round(sR(r,-12,12));return [profileShade(lit,d),profileShade(shd,d)]};let s='',a='',b='';const main=pick();
const blob=(cx,cyy,rx,ry,c)=>{a+='';b+=''};
if(kind==='oval')blob(x,cy-h*.05,h*.2,h*.36,main);
else if(kind==='spread'){blob(x,cy+h*.06,h*.42,h*.22,main);blob(x+h*sR(r,-.15,.15),cy-h*.1,h*.26,h*.17,leaves?pick():main)}
else{const n=kind==='cluster'?5+Math.floor(r()*3):3+Math.floor(r()*2),rad=h*(kind==='cluster'?.2:.27);for(let i=0;i{const x1=x0+Math.sin(a)*len,y1=y0-Math.cos(a)*len;segs.push([x0,y0,x1,y1,w]);if(depth<=0)return;const n=r()<.35?3:2;for(let i=0;i{const w=Math.max(.5,Math.round(s[4]*2)/2);if(!buckets.has(w))buckets.set(w,'');buckets.set(w,buckets.get(w)+'M'+sN(s[0])+' '+sN(s[1])+'L'+sN(s[2])+' '+sN(s[3]))});let out='';buckets.forEach((d,w)=>out+='');out+='';
if(snow){let d='';segs.forEach(s=>{const dx=s[2]-s[0],dy=s[3]-s[1];if(Math.abs(dx)>Math.abs(dy)*.35&&s[4]>1.1)d+='M'+sN(s[0])+' '+sN(s[1]-s[4]*.45)+'L'+sN(s[2])+' '+sN(s[3]-s[4]*.45)});if(d)out+=''}return out}
function sceneTree(kind,x,y,h,col,r,o){o=o||{};if(o.snow&&PROFILE_LEAF_KINDS.includes(kind))kind='bare';if(PROFILE_PINE_KINDS.includes(kind))return scenePine(kind,x,y,h,col,r,o.snow);if(kind==='bare')return sceneBare(x,y,h,o.bareColor||profileShade(Array.isArray(col)?col[0]:col,-6),r,o.snow);return sceneLeafy(kind,x,y,h,col,r,o.leaves)}
/* a row of trees along a ridge, with random spacing, sizes, kinds and the odd clearing */
function sceneTreeRow(r,pts,o){if(sceneTreeAmt<=0)return '';const gap=o.gap/sceneTreeAmt,cl=o.clearings?o.clearings/Math.max(1,sceneTreeAmt):0;let s=o.mass?'':'',x=(o.from==null?-30:o.from)+sR(r,0,gap);const end=o.to==null?1235:o.to,base=scenePair(o.color,.34);while(x';const n=5+Math.floor(r()*3);for(let i=0;i'}return s}
function sceneCypress(x,y,h,col,moss,r){const [lit,shd]=scenePair(col,.3);col=lit;let s='';const n=3+Math.floor(r()*3);for(let i=0;i'}let d='';for(let i=0;i<5+Math.floor(r()*5);i++){const mx=x+sR(r,-h*.28,h*.28),my=y-h*sR(r,.55,.7);d+='M'+sN(mx)+' '+sN(my)+' q'+sN(sR(r,-3,3))+' '+sN(h*.06)+' '+sN(sR(r,-2,2))+' '+sN(h*sR(r,.08,.16))}return s+''}
function sceneCactus(kind,x,y,h,col,r){const [lit,shd]=scenePair(col,.3);col=lit;if(kind==='pear'){let s='';const n=3+Math.floor(r()*4);for(let i=0;i';return s}if(kind==='barrel')return '';const w=Math.max(3,h*.12);let s='';const arms=Math.floor(r()*3);for(let i=0;i'}return s}
function sceneGrass(r,x,y,h,col){let d='';const n=3+Math.floor(r()*4);for(let i=0;i'}
function sceneRock(r,x,y,w,h,col){const pts=[[x-w/2,y]];const n=4+Math.floor(r()*3);for(let i=1;i'}
/* ===== Scene colors and depth (Ver. Beta 15 Rev. 1), done the way the logo palettes do it =====
Each scene has its own colors for the far, middle and near land (Back / Mid / Front Land), the open ground (Land), the trees behind and in
front, and sand / paths. Land is lighter at its top edge and darker at its foot, peaks have a lit face and a shaded face, trees have a lit
half and a shaded half, and far layers fade into the horizon. A Custom Scene saved before this has no Land / Trees / Sand colors: they
follow its land colors (trees a good step darker than the ground they stand on). */
/* Trees on Customize Profile: how many trees every scene draws (Normal is the scene as designed) */
const SCENE_TREE_AMOUNTS=[['None',0],['Fewer',.55],['Normal',1],['More',1.6],['Lots',2.4]];
let sceneTreeAmt=1;
function profileTreeAmount(v){const n=typeof v==='number'?v:(typeof v==='string'&&v.trim()!==''?Number(v):NaN);return Number.isFinite(n)?Math.max(0,Math.min(4,Math.round(n))):2}
function sceneHex(v){return /^#[0-9a-f]{6}$/i.test(String(v||''))}
function sceneDk(c,k){return sceneMix(c,'#0b1220',k==null?.3:k)}
function sceneLt(c,k){return sceneMix(c,'#fff3d6',k==null?.15:k)}
function scenePair(c,k){return Array.isArray(c)?c:[c,sceneDk(c,k)]}
function scenePalette(t){const sky=Array.isArray(t.sky)&&t.sky.length?t.sky:['#3f7fae','#9cc7dc','#f1dcae'],rg=Array.isArray(t.ridge)?t.ridge:[],far=sceneHex(rg[0])?rg[0]:'#93b6a8',mid=sceneHex(rg[1])?rg[1]:'#62916f',near=sceneHex(rg[2])?rg[2]:'#44734f',pick=(v,f)=>sceneHex(v)?v:f,haze=sceneHex(sky[2])?sky[2]:'#cccccc';
const land=pick(t.land,sceneLt(sceneMix(near,mid,.3),.08)),treeB=pick(t.treeB,sceneDk(mid,.4)),treeF=pick(t.treeF,sceneDk(near,.42)),sand=pick(t.sand,sceneMix(near,'#e6cf98',.62));
return {sky,haze,far,mid,near,land,treeB:[treeB,sceneDk(treeB,.34)],treeF:[treeF,sceneDk(treeF,.38)],sand:[sand,sceneDk(sand,.2)],water:pick(t.water,'#2C8A86'),rock:[sceneMix(sceneMix(mid,'#c2b293',.68),haze,.06),sceneMix(sceneMix(near,'#6e6050',.62),'#0b1220',.1)],accent:pick(t.accent,'#E0A526'),leaves:Array.isArray(t.leaves)&&t.leaves.length?t.leaves:PROFILE_FALL_LEAVES}}
/* the derived colors a Custom Scene shows for Land / Back Trees / Front Trees / Sand until they are picked */
function sceneDerivedColors(c){const P=scenePalette(Object.assign({},c,{land:'',treeB:'',treeF:'',sand:''}));return {land:P.land,treeB:P.treeB[0],treeF:P.treeF[0],sand:P.sand[0]}}
let sceneGradN=0;
function sceneFill(k,top,bot){const id=k.wat+'_g'+(++sceneGradN);k.defs+='';return 'url(#'+id+')'}
/* land: a gradient from a lit top edge to a darker foot, and a thin light rim along the top */
function sceneHill(k,pts,col,o){o=o||{};const line=o.sharp?sceneLine(pts):sceneSmoothLine(pts),x0=pts[0][0],x1=pts[pts.length-1][0],ix=o.innerX==null?x0:o.innerX,close=o.open?(x0<=-59?' L'+sN(ix)+' 330 L'+sN(x0)+' 330 Z':' L'+sN(x1)+' 330 L'+sN(ix)+' 330 Z'):' L1260 330 L-60 330 Z';
return ''+(o.rim===false?'':'')}
/* peaks: a lit face, and a shaded face from each summit down to the next saddle */
function scenePeaks(k,base,drawn,col,o){o=o||{};const r=k.r,sh=sceneDk(col,o.dk==null?.3:o.dk);let faces='';
for(let i=1;iq[0]>p[0]&&q[0]<=b[0]),fx=p[0]+(b[0]-p[0])*sR(r,.3,.6),h=330-p[1];let crease='';for(let j=5;j>=1;j--){const f=j/6;crease+=' L'+sN(p[0]+(fx-p[0])*Math.pow(f,.8)+sR(r,-4,4))+' '+sN(p[1]+h*f)}
faces+=''}
return sceneHill(k,drawn,col,{sharp:true,lt:o.lt==null?.12:o.lt,dk:.2,rimO:.35})+''+faces+''}
/* a forested ridge edge: a slow wave with many small crowns along it */
function sceneCanopy(r,y,amp,step,bump){const base=sceneRidge(r,y,amp,step||230),out=[];for(let x=-60;x<=1262;x+=sR(r,8,15))out.push([x,sceneYAt(base,x)-sR(r,0,bump||5)]);return out}
/* a small bush: shaded circles, then lit ones a little up and to the left */
function sceneShrub(r,x,y,s,col){const [lit,shd]=scenePair(col,.32);let a='',b='';const n=3+Math.floor(r()*3);for(let i=0;i';b+=''}return ''+a+''+b+''}
/* open ground that isn't empty: soft bands that follow the land, bushes, grass and a few flowers, bigger toward the front */
function sceneMeadow(k,pts,col,o){o=o||{};const r=k.r,P=k.P,dep=o.depth||46,x0=o.x0==null?-20:o.x0,x1=o.x1==null?1220:o.x1;let s='';
const bands=o.bands==null?3:o.bands;for(let i=1;i<=bands;i++){const dy=i*(o.step||15),ph=sR(r,0,6),fr=sR(r,110,190),p2=pts.map(q=>[q[0],q[1]+dy+Math.sin(q[0]/fr+ph)*(3+i)]);s+=''}
const sh=o.shrubs==null?8:o.shrubs,sc=o.shrubColor||P.treeF;for(let i=0;i';if(r()<.5)d1+=c;else d2+=c}s+=''+d1+''+d2+''}
return s}
/* a wandering line from the far end (top) to the near end (bottom); each point carries how near it is (0 far, 1 near) */
function sceneWind(r,x0,y0,x1,y1,amp,turns,n){const pts=[],ph=sR(r,0,Math.PI*2),N=n||36;for(let i=0;i<=N;i++){const t=i/N,e=Math.pow(t,1.7);pts.push([x0+(x1-x0)*e+Math.sin(t*Math.PI*(turns||2)+ph)*amp*e,y0+(y1-y0)*e,e])}return pts}
/* a river along such a line: narrow far away and wide up close, with sandy banks and light on the water */
function sceneRiver(k,path,o){o=o||{};const w0=o.w0==null?3:o.w0,w1=o.w1==null?180:o.w1,wAt=p=>w0+(w1-w0)*(p[2]==null?0:p[2]);
const edges=extra=>{const L=[],R=[];path.forEach((p,i)=>{const q=path[Math.min(path.length-1,i+1)],pp=path[Math.max(0,i-1)],dx=q[0]-pp[0],dy=q[1]-pp[1],len=Math.hypot(dx,dy)||1,nx=Math.abs(-dy/len),w=wAt(p)/2+extra*(.3+(p[2]||0));L.push([p[0]-w*Math.max(.35,nx),p[1]]);R.push([p[0]+w*Math.max(.35,nx),p[1]])});return sceneSmoothLine(L)+' L'+sceneSmoothLine(R.reverse()).slice(1)+' Z'};
let hl='';for(let i=4;i')+''}
/* a valley wall: high at the frame edge, coming down to its foot; its inner edge spreads toward the viewer */
function sceneSlope(r,side,yTop,footX,footY,n){const xs=side<0?-60:1260,pts=[],N=n||12;for(let i=0;i<=N;i++){const t=i/N;pts.push([xs+(footX-xs)*t,yTop+(footY-yTop)*Math.pow(t,1.45)+(i&&i':'';
x+=Math.max(gap*sz,art.w*sc*1.15/Math.max(1,sceneTreeAmt*.8))*sR(r,.55,1.45)}
let defs='';used.forEach(kind=>{const id=pre+kind;defs+=''});
return (defs?''+defs+'':'')+s}
/* big trees standing in the front corners */
function sceneFrame(k,pts,kinds,o){o=o||{};const P=k.P,col=o.color||P.treeF,h=o.h||[54,92];return sceneArtRow(k,pts,{kinds,gap:o.gap||46,hMin:h[0],hMax:h[1],color:col,twoTone:true,x0:-40,x1:o.left==null?150:o.left,sink:4})+sceneArtRow(k,pts,{kinds,gap:o.gap||46,hMin:h[0],hMax:h[1],color:col,twoTone:true,x0:o.right==null?1060:o.right,x1:1240,sink:4})}
/* clusters of leafy trees dotted over open land */
function sceneGroves(k,pts,o){const r=k.r,P=k.P,n=Math.round((o.n||4)*sceneTreeAmt),kinds=o.kinds||[['round',3],['cluster',2],['oval',1.5],['spread',1]],sc=o.scale||1;let s='';for(let i=0;i[p[0],p[1]+drop]);s+=sceneTreeRow(r,line,{gap:(o.gap||14)*sc*1.35*Math.max(1,sceneTreeAmt*.75),hMin:(o.hMin||14)*sc*.75,hMax:(o.hMax||26)*sc*1.15,kinds,color:o.color||P.treeB,clearings:.12,snow:k.snow,sink:(o.hMin||14)*sc*.4,from:cx-w,to:cx+w})}return s}
/* a lake line under a ridge, with the ridge's reflection */
function sceneReflect(pts,col,y,op){return ''}
/* far peaks used behind valleys and rivers */
function sceneFarPeaks(k,y,amp,step){const r=k.r,P=k.P,b0=sceneRidge(r,y,amp*1.15,(step||150)*.75,'peaks'),b=sceneRough(r,b0,4);return scenePeaks(k,b0,b,sceneMix(P.far,P.mid,.12),{dk:.24,lt:.08})+(k.snow?sceneCapsOn(k,b0,b,amp*.5):'')}
/* a tree line in the distance, softened by the haze */
function sceneFarTrees(k,pts,amount){const P=k.P;return sceneTreeRow(k.r,pts,{gap:11,hMin:7,hMax:13,kinds:SCENE_PINE_MIX,color:[sceneMix(P.treeB[0],P.haze,amount==null?.35:amount),sceneMix(P.treeB[1],P.haze,amount==null?.3:amount*.85)],clearings:.06,snow:k.snow,fixed:true})}
/* rock with strata: a cliff face with a lit side, dark seams and a shaded foot */
function sceneBluffFace(k,pts,col){const r=k.r,top=Math.min(...pts.map(p=>p[1])),x0=pts[0][0],x1=pts[pts.length-1][0],face=sceneLine(pts)+' L'+sN(x1)+' 330 L'+sN(x0)+' 330 Z',clip=k.wat+'_c'+(++sceneGradN);k.defs+='';
let seams='';for(let i=0;i<8;i++){const y=top+12+i*sR(r,12,20);seams+='M'+sN(x0)+' '+sN(y);for(let x=x0;x<=x1+40;x+=sR(r,20,44))seams+=' L'+sN(x)+' '+sN(y+sR(r,-2.5,2.5))}let cracks='';for(let i=0;i<9;i++){const x=sR(r,x0+10,x1-10),y=sceneYAt(pts,x)+sR(r,6,30);cracks+='M'+sN(x)+' '+sN(y)+' l'+sN(sR(r,-6,6))+' '+sN(sR(r,14,40))}
return ''}
/* the open water of a lake or the sea, with ripples */
function sceneLake(k,y){return sceneWater(k.r,k.wat,y)+''}
/* reeds along a shore */
function sceneReeds(k,pts,n,col){const r=k.r;let s='';for(let i=0;i[p[0],p[1]+dy]),w=1.6+f*3.4;s+=''}return s}
/* the North Woods tree lines (traced artwork, 600 wide) across the scene */
function sceneTreeLine(key,baseY,sc,fill){const d=LGX_TREE_LINES[key].d,w=600*sc;let s='';for(let i=0,x=-80;x<1280;i++,x+=w)s+='';return s}
/* the landscapes, drawn 1200 wide */
const SCENE_LANDS={
mountains(k){const {r,P}=k,b0=sceneRidge(r,116,64,118,'peaks'),m0=sceneRidge(r,172,44,96,'peaks'),b=sceneRough(r,b0,5),m=sceneRough(r,m0,4),f=sceneRidge(r,222,12,170),g=sceneRidge(r,252,6,240);
return scenePeaks(k,b0,b,sceneDk(P.far,.06),{lt:.04})+(k.snow?sceneCapsOn(k,b0,b,34):'')+scenePeaks(k,m0,m,P.mid)+(k.snow?sceneCapsOn(k,m0,m,22):'')+sceneHill(k,f,P.near)+sceneArtRow(k,f,{kinds:LGX_TREE_SETS.pines,gap:14,hMin:12,hMax:22,color:P.treeB,clear:.12})+sceneHill(k,g,P.land)+sceneMeadow(k,g,P.land,{shrubs:7,grass:14,flowers:8,depth:30})+sceneFrame(k,g,LGX_TREE_SETS.pines,{h:[46,80]})},
jaggedpeaks(k){const {r,P}=k,b0=sceneRidge(r,108,75,110,'peaks'),b=sceneRough(r,b0,9),m0=sceneRidge(r,168,55,90,'peaks'),m=sceneRough(r,m0,7),f=sceneRidge(r,224,10,160),g=sceneRidge(r,254,5,240);
return scenePeaks(k,b0,b,sceneDk(P.far,.06),{lt:.04})+(k.snow?sceneCapsOn(k,b0,b,38):'')+scenePeaks(k,m0,m,P.mid)+(k.snow?sceneCapsOn(k,m0,m,24):'')+sceneHill(k,f,P.near)+sceneArtRow(k,f,{kinds:['f9_2','arbre01','spruce_a'],gap:13,hMin:12,hMax:22,color:P.treeB,clear:.15})+sceneHill(k,g,P.land)+sceneMeadow(k,g,P.land,{shrubs:5,grass:12,flowers:4,depth:28})+''+[0,1,2].map(()=>{const x=sR(r,160,1040);return sceneRock(r,x,sceneYAt(g,x)+18,sR(r,26,60),sR(r,10,22),P.rock[1])}).join('')+''+sceneFrame(k,g,['f9_2','arbre01','spruce_a'],{h:[50,86]})},
snowpeaks(k){const {r,P}=k,b0=sceneRidge(r,112,70,130,'peaks'),m0=sceneRidge(r,172,48,105,'peaks'),b=sceneRough(r,b0,5),m=sceneRough(r,m0,4),f=sceneRidge(r,226,12,170),g=sceneRidge(r,254,5,240);
return scenePeaks(k,b0,b,sceneDk(P.far,.06),{lt:.04})+sceneCapsOn(k,b0,b,46)+scenePeaks(k,m0,m,P.mid)+sceneCapsOn(k,m0,m,30)+sceneHill(k,f,P.near)+sceneArtRow(k,f,{kinds:LGX_TREE_SETS.pines,gap:12,hMin:12,hMax:24,color:P.treeB,clear:.1,})+sceneHill(k,g,P.land)+sceneMeadow(k,g,P.land,{shrubs:6,grass:12,flowers:6,depth:28})+sceneFrame(k,g,LGX_TREE_SETS.pines,{h:[48,84]})},
smokymountains(k){const {r,P}=k;let s='';[[108,40],[136,34],[164,28],[194,22],[226,14]].forEach(([y,amp],i)=>{const col=i<2?sceneMix(P.far,P.mid,i*.5):i<4?sceneMix(P.mid,P.near,(i-2)*.5):P.near,pts=sceneCanopy(r,y,amp,170,3+i*1.2);s+=sceneHill(k,pts,sceneMix(col,P.haze,(4-i)*.13),{rimO:.3})+''});const g=sceneRidge(r,256,5,240);return s+sceneHill(k,g,P.land)+sceneMeadow(k,g,P.land,{shrubs:8,grass:12,flowers:8,depth:28})+sceneFrame(k,g,LGX_TREE_SETS.oak,{h:[40,70],color:P.treeF})},
mountainlake(k){const {r,P}=k,b0=sceneRidge(r,120,60,118,'peaks'),m0=sceneRidge(r,166,36,96,'peaks'),b=sceneRough(r,b0,5),m=sceneRough(r,m0,4),shore=sceneRidge(r,196,4,200);let s=scenePeaks(k,b0,b,sceneDk(P.far,.06),{lt:.04})+(k.snow?sceneCapsOn(k,b0,b,34):'')+scenePeaks(k,m0,m,P.mid)+sceneHill(k,shore,P.near,{rimO:.2})+sceneFarTrees(k,shore,.15)+sceneLake(k,202)+sceneReflect(m,P.mid,202,.2);const f=sceneRidge(r,262,8,150);
return s+sceneHill(k,f,P.land)+sceneMeadow(k,f,P.land,{shrubs:5,grass:10,flowers:5,depth:18})+sceneReeds(k,f,8)+sceneArtRow(k,f,{kinds:LGX_TREE_SETS.pines,gap:30,hMin:30,hMax:56,color:P.treeF,twoTone:true,clear:.35})},
pines(k){const {r,P}=k,b=sceneRidge(r,168,16,160),m=sceneRidge(r,198,8,180),f=sceneRidge(r,236,6,200),g=sceneRidge(r,270,4,220);
return sceneHill(k,b,sceneMix(P.far,P.mid,.3))+sceneTreeRow(r,b,{gap:16,hMin:10,hMax:20,kinds:SCENE_PINE_MIX,color:[sceneMix(P.treeB[0],P.haze,.3),sceneMix(P.treeB[1],P.haze,.25)],clearings:.05,snow:k.snow,fixed:true})+sceneHill(k,m,P.mid)+sceneTreeRow(r,m,{gap:26,hMin:22,hMax:44,kinds:SCENE_PINE_MIX,color:P.treeB,clearings:.06,snow:k.snow})+sceneHill(k,f,P.land)+sceneMeadow(k,f,P.land,{shrubs:4,grass:8,flowers:6,depth:22,bands:2})+sceneArtRow(k,f,{kinds:LGX_TREE_SETS.pines,gap:34,hMin:34,hMax:66,color:P.treeF,twoTone:true,clear:.12})+sceneHill(k,g,sceneDk(P.land,.12))+sceneMeadow(k,g,sceneDk(P.land,.12),{shrubs:3,grass:10,flowers:4,depth:12,bands:1})},
denseforest(k){const {r,P}=k,kinds=[['spruce',4],['rough',3],['fir',2],['pine',1]];let s='';[[160,12,10,22,0],[188,15,24,46,1],[214,18,36,64,2]].forEach(([y,gap,a,b,i])=>{const pts=sceneRidge(r,y,7,180),gc=[sceneMix(P.far,P.mid,.4),P.mid,P.near][i],tc=i===0?[sceneMix(P.treeB[0],P.haze,.3),sceneMix(P.treeB[1],P.haze,.25)]:i===1?P.treeB:[sceneMix(P.treeB[0],P.treeF[0],.5),sceneMix(P.treeB[1],P.treeF[1],.5)];s+=sceneHill(k,pts,gc)+sceneTreeRow(r,pts,{gap,hMin:a,hMax:b,kinds,color:tc,clearings:.02,snow:k.snow,fixed:i===0})});const f=sceneRidge(r,250,5,200);return s+sceneHill(k,f,P.land)+sceneMeadow(k,f,P.land,{shrubs:4,grass:10,flowers:3,depth:20,bands:2})+sceneArtRow(k,f,{kinds:['spruce_a','f9_6','chris6','f9_2'],gap:26,hMin:46,hMax:84,color:P.treeF,twoTone:true,clear:.04})},
mixedforest(k){const {r,P}=k,kinds=[['pine',2],['spruce',2],['fir',2],['round',2],['oval',1.5],['spread',1],['cluster',1.5]];let s='';[[170,16,12,22,0],[200,26,24,44,1]].forEach(([y,gap,a,b,i])=>{const pts=sceneRidge(r,y,9,170),gc=i?P.mid:sceneMix(P.far,P.mid,.4),tc=i?P.treeB:[sceneMix(P.treeB[0],P.haze,.3),sceneMix(P.treeB[1],P.haze,.25)];s+=sceneHill(k,pts,gc)+sceneTreeRow(r,pts,{gap,hMin:a,hMax:b,kinds,color:tc,clearings:.06,snow:k.snow,fixed:!i})});const f=sceneRidge(r,238,8,170);
return s+sceneHill(k,f,P.land)+sceneMeadow(k,f,P.land,{shrubs:6,grass:12,flowers:8,depth:24,bands:2})+sceneArtRow(k,f,{kinds:LGX_TREE_SETS.mixed,gap:34,hMin:36,hMax:66,color:P.treeF,twoTone:true,clear:.1})},
forestridge(k){const {r,P}=k,b=sceneRidge(r,140,34,200),m=sceneRidge(r,185,30,180),f=sceneRidge(r,240,14,220);return sceneHill(k,b,sceneMix(P.far,P.haze,.1))+sceneTreeRow(r,b,{gap:14,hMin:8,hMax:16,kinds:SCENE_PINE_MIX,color:[sceneMix(P.treeB[0],P.haze,.38),sceneMix(P.treeB[1],P.haze,.32)],clearings:.18,snow:k.snow,fixed:true})+sceneHill(k,m,P.mid)+sceneTreeRow(r,m,{gap:20,hMin:16,hMax:32,kinds:SCENE_PINE_MIX,color:P.treeB,clearings:.15,snow:k.snow})+sceneHill(k,f,P.land)+sceneMeadow(k,f,P.land,{shrubs:6,grass:12,flowers:8,depth:26})+sceneArtRow(k,f,{kinds:LGX_TREE_SETS.pines,gap:46,hMin:30,hMax:58,color:P.treeF,twoTone:true,clear:.2})},
fallforest(k){const {r,P}=k,leaves=P.leaves,kinds=[['round',3],['oval',2],['spread',1.5],['cluster',2],['bare',2],['pine',.8],['spruce',.8],['fir',.6]],pine=k.t.pine||P.treeB[0];let s='';[[172,16,12,24,0],[202,24,24,46,1],[240,32,36,70,2]].forEach(([y,gap,a,b,i])=>{const pts=sceneRidge(r,y,9,170),gc=[sceneMix(P.far,P.mid,.4),P.mid,P.land][i];s+=sceneHill(k,pts,gc)+sceneTreeRow(r,pts,{gap,hMin:a,hMax:b,kinds,color:i===0?sceneMix(pine,P.haze,.3):pine,leaves:i===0?leaves.map(c=>sceneMix(c,P.haze,.28)):leaves,bareColor:sceneDk(sceneMix(P.near,'#3a302b',.6),.15),clearings:.05,mass:k.snow?null:sceneDk(sceneMix(leaves[i%leaves.length],gc,.45),.18+i*.05),snow:k.snow});if(i===2)s=s+sceneMeadow(k,pts.map(p=>[p[0],p[1]+6]),P.land,{shrubs:0,grass:10,flowers:0,depth:20,bands:1})});return s},
winterforest(k){const {r,P}=k,kinds=[['pine',2],['spruce',2.5],['fir',2.5],['rough',1],['young',1],['bare',2.5]];let s='';[[172,16,12,22,0],[204,26,24,44,1],[242,34,36,70,2]].forEach(([y,gap,a,b,i])=>{const pts=sceneRidge(r,y,8,170),gc=[P.far,P.mid,P.land][i],tc=i===0?[sceneMix(P.treeB[0],P.haze,.35),sceneMix(P.treeB[1],P.haze,.3)]:i===1?P.treeB:P.treeF;s+=sceneHill(k,pts,gc,{dk:.12})+sceneTreeRow(r,pts,{gap,hMin:a,hMax:b,kinds,color:tc,bareColor:sceneMix(tc[0],'#3a302b',.6),clearings:.05,snow:true,fixed:i===0})});return s},
northwoods(k){const {r,P}=k;let s=sceneTreeLine('line_far',182,.15,sceneMix(P.treeB[0],P.haze,.55))+''+sceneTreeLine('line_mid',190,.2,sceneMix(P.treeB[0],P.haze,.25))+sceneTreeLine('line_near',198,.27,P.treeB[0])+'';
s+=sceneLake(k,199)+''+sceneTreeLine('line_near',199,.27,P.treeB[1])+'';const f=sceneRidge(r,262,6,180);return s+sceneHill(k,f,P.land)+sceneMeadow(k,f,P.land,{shrubs:5,grass:8,flowers:4,depth:16,bands:1})+sceneReeds(k,f,10)+sceneFrame(k,f,LGX_TREE_SETS.pines,{h:[56,96],left:200,right:1000})},
hills(k){const {r,P}=k,b=sceneRidge(r,165,26,260),m=sceneRidge(r,205,24,240),f=sceneRidge(r,243,18,260);
return sceneHill(k,b,sceneMix(P.far,P.haze,.1))+sceneGroves(k,b,{n:6,hMin:6,hMax:11,gap:8,color:[sceneMix(P.treeB[0],P.haze,.35),sceneMix(P.treeB[1],P.haze,.3)]})+sceneHill(k,m,P.mid)+sceneMeadow(k,m,P.mid,{shrubs:4,grass:0,flowers:0,depth:24,bands:2,scale:.6,shrubColor:P.treeB})+sceneGroves(k,m,{n:8,hMin:10,hMax:18,gap:11})+sceneHill(k,f,P.land)+sceneMeadow(k,f,P.land,{shrubs:9,grass:18,flowers:14,depth:36})+sceneGroves(k,f,{n:4,hMin:22,hMax:36,gap:20,color:P.treeF,kinds:[['round',3],['spread',2],['oval',1]]})+sceneFrame(k,f,LGX_TREE_SETS.oak,{h:[40,66]})},
steephills(k){const {r,P}=k,b=sceneRidge(r,150,42,150),m=sceneRidge(r,195,38,140),f=sceneRidge(r,240,26,160);return sceneHill(k,b,sceneMix(P.far,P.haze,.1))+sceneGroves(k,b,{n:4,hMin:6,hMax:10,gap:7,color:[sceneMix(P.treeB[0],P.haze,.35),sceneMix(P.treeB[1],P.haze,.3)],kinds:[['pine',2],['round',2]]})+sceneHill(k,m,P.mid)+sceneGroves(k,m,{n:5,hMin:10,hMax:18,gap:10,kinds:[['round',2],['pine',2],['fir',1]]})+sceneHill(k,f,P.land)+sceneMeadow(k,f,P.land,{shrubs:8,grass:16,flowers:10,depth:34})+sceneFrame(k,f,LGX_TREE_SETS.oak,{h:[38,64]})},
foothills(k){const {r,P}=k;let s='',last=null;[[140,26],[162,22],[186,20],[210,16],[238,12]].forEach(([y,amp],i)=>{const col=i<2?sceneMix(P.far,P.mid,i*.5):i<4?sceneMix(P.mid,P.near,(i-2)*.5):P.land,pts=sceneRidge(r,y,amp,200);s+=sceneHill(k,pts,sceneMix(col,P.haze,(4-i)*.1));if(i>=2&&i<4)s+=sceneGroves(k,pts,{n:3,hMin:6+i*2,hMax:10+i*3,gap:7+i*2,color:[sceneMix(P.treeB[0],P.haze,(4-i)*.12),sceneMix(P.treeB[1],P.haze,(4-i)*.1)]});last=pts});return s+sceneMeadow(k,last,P.land,{shrubs:8,grass:14,flowers:10,depth:34})+sceneFrame(k,last,LGX_TREE_SETS.oak,{h:[36,60]})},
woodedhills(k){const {r,P}=k,kinds=[['round',3],['cluster',2],['oval',1.5],['pine',1.5],['fir',1]];let s='';[[165,26,10,12,18,0],[205,24,16,18,32,1],[245,18,22,28,52,2]].forEach(([y,amp,gap,a,b,i])=>{const pts=sceneRidge(r,y,amp,230),gc=[sceneMix(P.far,P.haze,.1),P.mid,P.land][i],tc=i===0?[sceneMix(P.treeB[0],P.haze,.3),sceneMix(P.treeB[1],P.haze,.25)]:i===1?P.treeB:P.treeF;s+=sceneHill(k,pts,gc)+sceneTreeRow(r,pts,{gap,hMin:a,hMax:b,kinds,color:tc,clearings:.1,sink:a*.35,snow:k.snow,fixed:i===0})});return s},
ozark(k){const {r,P}=k;let s='';const rows=[[132,22],[154,20],[178,18],[202,16],[228,12]];rows.forEach(([y,amp],i)=>{const f=i/(rows.length-1),col=sceneMix(sceneMix(P.far,P.near,f),P.haze,(1-f)*.28),pts=sceneCanopy(r,y,amp,240,2+i*1.6);s+=sceneHill(k,pts,col,{rimO:.25,dk:.22})+(i>=1?sceneTreeRow(r,pts,{gap:7+i*2.5,hMin:5+i*2.2,hMax:9+i*3.2,kinds:[['round',3],['cluster',2],['pine',1.5],['oval',1]],color:[sceneMix(P.treeB[0],P.haze,(1-f)*.4),sceneMix(P.treeB[1],P.haze,(1-f)*.34)],clearings:.06,sink:3+i,snow:k.snow,fixed:true}):'')+''});
const g=sceneRidge(r,258,5,240);return s+sceneHill(k,g,P.land)+sceneMeadow(k,g,P.land,{shrubs:8,grass:12,flowers:8,depth:26})+sceneFrame(k,g,LGX_TREE_SETS.oak,{h:[44,72]})},
vineyard(k){const {r,P}=k,b=sceneRidge(r,168,20,260),m=sceneRidge(r,198,22,300),f=sceneRidge(r,236,14,320);let s=sceneHill(k,b,sceneMix(P.far,P.haze,.1))+sceneGroves(k,b,{n:6,hMin:6,hMax:11,gap:8,color:[sceneMix(P.treeB[0],P.haze,.35),sceneMix(P.treeB[1],P.haze,.3)]});
s+=sceneHill(k,m,P.mid)+sceneTreeRow(r,m,{gap:12,hMin:8,hMax:14,kinds:[['oval',3],['round',2]],color:P.treeB,clearings:.45,sink:2,snow:k.snow})+sceneVines(k,m,9,P.mid)+sceneGroves(k,m,{n:4,hMin:12,hMax:20,gap:12,kinds:[['oval',3],['round',1]]});s+=sceneHill(k,f,P.land)+sceneVines(k,f,7,P.land);const x=sR(r,140,1060);s+=sceneArtRow(k,f,{kinds:LGX_TREE_SETS.oak,gap:36,hMin:40,hMax:56,color:P.treeF,twoTone:true,x0:x-70,x1:x+70})+sceneFrame(k,f,LGX_TREE_SETS.oak,{h:[44,70],left:90,right:1110});
const g=sceneRidge(r,270,3,240);return s+sceneHill(k,g,sceneDk(P.land,.1))+sceneMeadow(k,g,sceneDk(P.land,.1),{shrubs:4,grass:12,flowers:12,depth:12,bands:1})},
valley(k){const {r,P}=k,cx=sR(r,520,680),hy=196;let s=sceneFarPeaks(k,128,44,150);const fh=sceneRidge(r,180,7,200);s+=sceneHill(k,fh,sceneMix(P.far,P.mid,.35))+sceneFarTrees(k,fh,.3);
const floor=[[-60,hy],[1260,hy]];s+=sceneHill(k,floor,P.land,{rim:false})+sceneRiver(k,sceneWind(r,cx,hy+1,cx+sR(r,-160,160),300,60,2.2),{w0:3,w1:200});
const L1=sceneSlope(r,-1,sR(r,86,104),cx-36,hy+5),R1=sceneSlope(r,1,sR(r,92,110),cx+36,hy+5);
s+=sceneHill(k,L1,P.mid,{open:true,innerX:cx-520})+sceneHill(k,R1,sceneDk(P.mid,.14),{open:true,innerX:cx+520});
const row=(pts,side,j,col,two)=>{const sorted=pts.slice().sort((a,b)=>a[0]-b[0]),foot=side<0?sorted[sorted.length-1][0]:sorted[0][0],line=sorted.map(p=>[p[0],p[1]+j*16]),span=Math.abs(foot-(side<0?-60:1260));return sceneArtRow(k,line,{kinds:LGX_TREE_SETS.pines,gap:9,hMin:10,hMax:18,color:col,twoTone:two,x0:side<0?-40:foot+20+j*24,x1:side<0?foot-20-j*24:1240,size:x=>.45+1.1*(Math.abs(x-foot)/span),clear:.04})};
s+=row(L1,-1,0,P.treeB,false)+row(L1,-1,1,P.treeB,false)+row(R1,1,0,[sceneDk(P.treeB[0],.12),P.treeB[1]],false)+row(R1,1,1,[sceneDk(P.treeB[0],.12),P.treeB[1]],false);
const L2=sceneSlope(r,-1,sR(r,168,184),cx-250,262),R2=sceneSlope(r,1,sR(r,172,190),cx+250,264);s+=sceneHill(k,L2,P.near,{open:true,innerX:cx-700})+sceneHill(k,R2,sceneDk(P.near,.12),{open:true,innerX:cx+700})+row(L2,-1,0,P.treeF,true)+row(R2,1,0,P.treeF,true);
return s+sceneMeadow(k,[[-60,250],[1260,250]],P.land,{shrubs:5,grass:10,flowers:8,depth:30,bands:0,x0:cx-230,x1:cx+230})},
sidevalley(k){const {r,P}=k,side=r()<.5?-1:1,hy=198,footX=side<0?sR(r,760,860):sR(r,340,440);let s=sceneFarPeaks(k,140,30,170);const fh=sceneRidge(r,184,8,220);s+=sceneHill(k,fh,sceneMix(P.far,P.mid,.35))+sceneFarTrees(k,fh,.3)+sceneHill(k,[[-60,hy],[1260,hy]],P.land,{rim:false});
const riverX=footX+side*-30,path=sceneWind(r,riverX,hy+1,riverX+side*-(sR(r,120,260)),300,40,1.6);s+=sceneMeadow(k,[[-60,hy+2],[1260,hy+2]],P.land,{shrubs:7,grass:12,flowers:10,depth:80,bands:3,x0:side<0?footX-100:-20,x1:side<0?1220:footX+100})+sceneRiver(k,path,{w0:3,w1:170});
const S=sceneSlope(r,side,sR(r,64,84),footX,hy+4,14);s+=sceneHill(k,S,P.mid,{open:true,innerX:side<0?footX-560:footX+560});const sorted=S.slice().sort((a,b)=>a[0]-b[0]),span=Math.abs(footX-(side<0?-60:1260));
for(let j=0;j<4;j++){const line=sorted.map(p=>[p[0],p[1]+j*18]);s+=sceneArtRow(k,line,{kinds:j<2?LGX_TREE_SETS.pines:LGX_TREE_SETS.mixed,gap:9,hMin:10,hMax:18,color:j<3?P.treeB:P.treeF,twoTone:j===3,x0:side<0?-40:footX+30+j*60,x1:side<0?footX-30-j*60:1240,size:x=>.45+1.2*(Math.abs(x-footX)/span),clear:.05})}
const g=sceneRidge(r,270,4,240);return s+sceneHill(k,g,sceneDk(P.land,.1))+sceneMeadow(k,g,sceneDk(P.land,.1),{shrubs:3,grass:10,flowers:6,depth:12,bands:1})+(side<0?sceneArtRow(k,g,{kinds:LGX_TREE_SETS.oak,gap:60,hMin:52,hMax:76,color:P.treeF,twoTone:true,x0:1080,x1:1240}):sceneArtRow(k,g,{kinds:LGX_TREE_SETS.oak,gap:60,hMin:52,hMax:76,color:P.treeF,twoTone:true,x0:-40,x1:120}))},
river(k){const {r,P}=k,b=sceneRidge(r,166,18,240),m=sceneRidge(r,192,12,240);let s=sceneHill(k,b,sceneMix(P.far,P.haze,.1))+sceneFarTrees(k,b,.32)+sceneHill(k,m,P.mid)+sceneGroves(k,m,{n:8,hMin:9,hMax:16,gap:10});const floor=sceneRidge(r,204,3,300),cx=sR(r,420,780);
s+=sceneHill(k,floor,P.land,{rim:false})+sceneMeadow(k,floor,P.land,{shrubs:10,grass:16,flowers:14,depth:80,bands:4});const path=sceneWind(r,cx,206,cx+sR(r,-220,220),300,170,2.4);s+=sceneRiver(k,path,{w0:4,w1:230});
path.forEach((p,i)=>{if(i<3||i%2)return;const off=(18+i*9)*(r()<.5?-1:1);s+=sceneTreeRow(r,[[p[0]+off-60,p[1]+2],[p[0]+off+60,p[1]+2]],{gap:8+i,hMin:6+i*1.6,hMax:10+i*2.6,kinds:[['round',3],['cluster',2],['pine',1]],color:i>9?P.treeF:P.treeB,clearings:0,snow:k.snow,from:p[0]+off-14-i*2,to:p[0]+off+14+i*2})});
const g=sceneRidge(r,276,3,240);return s+sceneReeds(k,g,10)+sceneFrame(k,g,LGX_TREE_SETS.oak,{h:[50,80],left:110,right:1090})},
threerivers(k){const {r,P}=k,b=sceneRidge(r,168,16,240),m=sceneRidge(r,190,10,240);let s=sceneHill(k,b,sceneMix(P.far,P.haze,.1))+sceneFarTrees(k,b,.32)+sceneHill(k,m,P.mid)+sceneGroves(k,m,{n:8,hMin:9,hMax:15,gap:10});const floor=sceneRidge(r,200,3,300);s+=sceneHill(k,floor,P.land,{rim:false})+sceneMeadow(k,floor,P.land,{shrubs:10,grass:14,flowers:12,depth:80,bands:4});
const jx=sR(r,520,680),jy=246,main=sceneWind(r,jx,jy,jx+sR(r,-120,120),300,40,1);s+=sceneRiver(k,main,{w0:56,w1:230});[[sR(r,160,300),202],[sR(r,560,640),200],[sR(r,900,1040),202]].forEach(([x,y])=>{const p=sceneWind(r,x,y,jx+(x-jx)*.08,jy+4,26,1.4,12);s+=sceneRiver(k,p,{w0:3,w1:44})});
[[jx-150,232],[jx+150,232],[jx-330,214],[jx+330,214],[jx-60,262],[jx+80,264]].forEach(([x,y])=>{s+=sceneTreeRow(r,[[x-80,y],[x+80,y]],{gap:11,hMin:12,hMax:22,kinds:[['round',3],['cluster',2],['pine',1.5]],color:P.treeB,clearings:0,snow:k.snow,from:x-40,to:x+40})});
const g=sceneRidge(r,278,3,240);return s+sceneReeds(k,g,8)+sceneFrame(k,g,LGX_TREE_SETS.mixed,{h:[48,78],left:110,right:1090})},
bluffs(k){const {r,P}=k,b=sceneRidge(r,170,16,240);let s=sceneHill(k,b,sceneMix(P.far,P.haze,.12))+sceneFarTrees(k,b,.32);const floor=[[-60,206],[1260,206]];s+=sceneHill(k,floor,P.land,{rim:false});const cx=sR(r,560,680);s+=sceneRiver(k,sceneWind(r,cx,207,cx+sR(r,-80,80),300,50,1.6),{w0:6,w1:300});
const rock=P.near,L=sR(r,300,420),top=sR(r,92,112),lp=[];for(let x=-60;x<=L;x+=sR(r,26,44))lp.push([x,top+sR(r,-6,6)+Math.pow(Math.max(0,(x-L*.55)/(L*.45)),2)*60]);lp.push([L+10,232]);s+=sceneBluffFace(k,lp,rock)+''+sceneArtRow(k,lp.slice(0,-1),{kinds:LGX_TREE_SETS.mixed,gap:16,hMin:16,hMax:30,color:P.treeF,twoTone:true,x1:L-20,clear:.05});
if(r()<.65){const R=sR(r,260,360),rt=sR(r,124,146),rp=[];for(let x=1260;x>=1200-R;x-=sR(r,26,44))rp.push([x,rt+sR(r,-5,5)+Math.pow(Math.max(0,((1200-x)-R*.5)/(R*.5)),2)*50]);rp.push([1200-R-12,234]);rp.reverse();s+=sceneBluffFace(k,rp,sceneDk(rock,.1))+sceneArtRow(k,rp.slice(1),{kinds:LGX_TREE_SETS.mixed,gap:15,hMin:14,hMax:26,color:P.treeF,twoTone:true,x0:1200-R+20,clear:.05})}
else{const R=sceneSlope(r,1,150,840,214);s+=sceneHill(k,R,P.mid,{open:true,innerX:1180})+sceneArtRow(k,R.slice().sort((a,b)=>a[0]-b[0]),{kinds:LGX_TREE_SETS.mixed,gap:11,hMin:12,hMax:22,color:P.treeB,x0:860,x1:1240,clear:.05})}
const g=sceneRidge(r,272,3,240);return s+sceneHill(k,g,sceneDk(P.land,.06))+sceneMeadow(k,g,sceneDk(P.land,.06),{shrubs:4,grass:10,flowers:6,depth:12,bands:1})+sceneReeds(k,g,8)},
waterfall(k){const {r,P}=k,cx=sR(r,500,700);let s=sceneFarPeaks(k,126,40,160);const top=sR(r,128,140),lip=[[cx-300,top+30],[cx-180,top+4],[cx-40,top],[cx+40,top],[cx+180,top+6],[cx+300,top+34]];
const fh=sceneRidge(r,150,16,200);s+=sceneHill(k,fh,sceneMix(P.far,P.mid,.4))+sceneFarTrees(k,fh,.28);const cliff=sceneRough(r,lip,4);s+='';
let fac='';for(let i=0;i<6;i++){const x=cx+sR(r,-260,260),y=sceneYAt(cliff,x);fac+=''}s+=''+fac+'';
s+=sceneArtRow(k,cliff,{kinds:LGX_TREE_SETS.pines,gap:12,hMin:12,hMax:22,color:P.treeB,x0:cx-300,x1:cx-60,clear:.05})+sceneArtRow(k,cliff,{kinds:LGX_TREE_SETS.pines,gap:12,hMin:12,hMax:22,color:P.treeB,x0:cx+60,x1:cx+300,clear:.05});
const w=sR(r,34,52),fy=top+1;let streaks='';for(let i=0;i<10;i++){const x=cx-w/2+sR(r,2,w-2);streaks+='M'+sN(x)+' '+sN(fy+sR(r,2,20))+' v'+sN(sR(r,30,80))}s+='';
s+=''+sceneRiver(k,sceneWind(r,cx,246,cx+sR(r,-160,160),300,60,1.4),{w0:w*2.2,w1:230});s+='';
const L2=sceneSlope(r,-1,sR(r,150,170),cx-190,250),R2=sceneSlope(r,1,sR(r,150,170),cx+190,252);s+=sceneHill(k,L2,P.mid,{open:true,innerX:cx-560})+sceneHill(k,R2,sceneDk(P.mid,.12),{open:true,innerX:cx+560});const sl=(pts,side)=>{const so=pts.slice().sort((a,b)=>a[0]-b[0]),foot=side<0?so[so.length-1][0]:so[0][0],span=Math.abs(foot-(side<0?-60:1260));return sceneArtRow(k,so,{kinds:LGX_TREE_SETS.pines,gap:10,hMin:14,hMax:24,color:P.treeF,twoTone:true,x0:side<0?-40:foot+10,x1:side<0?foot-10:1240,size:x=>.55+1*(Math.abs(x-foot)/span),clear:.04})};s+=sl(L2,-1)+sl(R2,1);
let rocks='';for(let i=0;i<5;i++){const x=cx+sR(r,-150,150);rocks+=sceneRock(r,x,sR(r,252,280),sR(r,16,40),sR(r,8,18),P.rock[1])}return s+rocks},
lakeshore(k){const {r,P}=k,b=sceneRidge(r,150,18,200),m=sceneRidge(r,180,10,190);let s=sceneHill(k,b,sceneMix(P.far,P.haze,.12))+sceneFarTrees(k,b,.38)+sceneHill(k,m,P.mid)+sceneTreeRow(r,m,{gap:13,hMin:8,hMax:16,kinds:SCENE_PINE_MIX,color:P.treeB,clearings:.1,snow:k.snow})+sceneLake(k,197)+sceneReflect(m,P.mid,197,.2);const f=sceneRidge(r,262,10,220);
return s+sceneHill(k,f,P.land)+sceneMeadow(k,f,P.land,{shrubs:6,grass:8,flowers:8,depth:20,bands:2})+sceneReeds(k,f,12)+sceneArtRow(k,f,{kinds:LGX_TREE_SETS.mixed,gap:60,hMin:40,hMax:62,color:P.treeF,twoTone:true,clear:.15})},
forestlake(k){const {r,P}=k,b=sceneRidge(r,168,10,200),m=sceneRidge(r,196,5,220);let s=sceneHill(k,b,sceneMix(P.far,P.mid,.3))+sceneTreeRow(r,b,{gap:16,hMin:12,hMax:22,kinds:SCENE_PINE_MIX,color:[sceneMix(P.treeB[0],P.haze,.3),sceneMix(P.treeB[1],P.haze,.25)],clearings:.04,snow:k.snow,fixed:true})+sceneHill(k,m,P.mid)+sceneTreeRow(r,m,{gap:20,hMin:18,hMax:34,kinds:SCENE_PINE_MIX,color:P.treeB,clearings:.05,snow:k.snow})+sceneLake(k,210)+sceneReflect(m.map(p=>[p[0],p[1]-26]),P.treeB[1],210,.25);
const f=sceneRidge(r,272,5,220);return s+sceneHill(k,f,P.land,{dk:.2})+sceneReeds(k,f,10)+sceneFrame(k,f,LGX_TREE_SETS.pines,{h:[54,90],left:180,right:1020})},
lakefoothills(k){const {r,P}=k;let s='';[[142,24],[160,20],[178,16],[194,10]].forEach(([y,amp],i)=>{const col=sceneMix(sceneMix(P.far,P.near,i/3),P.haze,(3-i)*.12),pts=sceneRidge(r,y,amp,220);s+=sceneHill(k,pts,col);if(i>=1)s+=sceneGroves(k,pts,{n:6,hMin:5+i*2,hMax:9+i*3,gap:6+i*2,color:[sceneMix(P.treeB[0],P.haze,(3-i)*.12),sceneMix(P.treeB[1],P.haze,(3-i)*.1)]})});s+=sceneLake(k,202);const f=sceneRidge(r,264,8,220);
return s+sceneHill(k,f,P.land)+sceneMeadow(k,f,P.land,{shrubs:6,grass:8,flowers:8,depth:18,bands:2})+sceneReeds(k,f,12)+sceneFrame(k,f,LGX_TREE_SETS.mixed,{h:[48,80]})},
coast(k){const {r,P}=k,b=sceneRidge(r,170,18,220);let s=sceneHill(k,b,sceneMix(P.far,P.haze,.15))+sceneFarTrees(k,b,.4)+sceneGroves(k,b,{n:4,hMin:7,hMax:12,gap:8,color:[sceneMix(P.treeB[0],P.haze,.35),sceneMix(P.treeB[1],P.haze,.3)]})+sceneLake(k,190);const beach=sceneRidge(r,236,6,260);s+=sceneHill(k,beach,P.sand[0],{dk:.12,rimO:.7})+'';
const dune=sceneRidge(r,262,10,200);s+=sceneHill(k,dune,P.land)+sceneMeadow(k,dune,P.land,{shrubs:7,grass:24,flowers:6,depth:20,bands:1,shrubColor:P.treeB})+sceneGroves(k,dune,{n:3,hMin:16,hMax:28,gap:14,color:P.treeF,kinds:[['spread',2],['round',2]]})+sceneFrame(k,dune,LGX_TREE_SETS.pines,{h:[44,74]});return s},
rockycoast(k){const {r,P}=k;let s=sceneLake(k,175);const L=sR(r,190,280),Rw=sR(r,160,250),lp=sceneRough(r,[[-60,128+sR(r,-20,20)],[L*.45,148+sR(r,-15,15)],[L*.8,200],[L,262]],6),rp=sceneRough(r,[[1200-Rw,262],[1200-Rw*.85,212],[1200-Rw*.5,162],[1260,138+sR(r,-20,20)]],6);
s+=sceneBluffFace(k,lp,P.rock[0])+sceneArtRow(k,lp,{kinds:LGX_TREE_SETS.pines,gap:14,hMin:30,hMax:54,color:P.treeF,twoTone:true,x1:L*.85,clear:.04});
s+=sceneBluffFace(k,rp,sceneDk(P.rock[0],.08))+sceneArtRow(k,rp,{kinds:LGX_TREE_SETS.pines,gap:14,hMin:28,hMax:50,color:P.treeF,twoTone:true,x0:1200-Rw*.8,clear:.04});
let st='';for(let i=0;i<3+Math.floor(r()*3);i++){const x=sR(r,L+40,1160-Rw),w=sR(r,30,80),h=sR(r,30,95);st+=sceneRock(r,x,300,w,h,i%2?P.rock[1]:sceneDk(P.rock[0],.15))+''}return s+st},
tropical(k){const {r,P}=k;let s=sceneLake(k,183);const ix=sR(r,200,900);s+=''+sceneArtRow(k,[[ix-120,182],[ix+150,182]],{kinds:LGX_TREE_SETS.palms,gap:26,hMin:14,hMax:22,color:[sceneMix(P.treeB[0],P.haze,.35),sceneMix(P.treeB[1],P.haze,.3)],x0:ix-110,x1:ix+140});
const m=sceneRidge(r,252,10,240);s+=sceneHill(k,m,P.sand[0],{dk:.1,rimO:.7})+'';const f=sceneRidge(r,274,6,260);s+=sceneHill(k,f,P.land)+sceneMeadow(k,f,P.land,{shrubs:8,grass:8,flowers:10,depth:12,bands:1,flowerColor:'#f26a8d'});
return s+sceneArtRow(k,m,{kinds:LGX_TREE_SETS.palms,gap:120,hMin:70,hMax:110,color:P.treeF,twoTone:true,clear:.2,sink:8})},
bayou(k){const {r,P}=k;let s=sceneLake(k,178);const far=sceneRidge(r,184,6,200);s+=sceneTreeRow(r,far,{gap:22,hMin:16,hMax:30,kinds:[['spread',2],['oval',1]],color:[sceneMix(P.treeB[0],P.haze,.3),sceneMix(P.treeB[1],P.haze,.25)],clearings:.1,fixed:true})+'';
let pads='';for(let i=0;i<18;i++){const x=sR(r,0,1200),y=sR(r,214,290),w=4+(y-214)*.12;pads+=''}s+=''+pads+'';
const n=Math.round((4+Math.floor(r()*4))*sceneTreeAmt);for(let i=0;i'}let reeds='';for(let i=0;i<14;i++){const x=sR(r,0,1200);reeds+=sceneGrass(r,x,sR(r,250,296),sR(r,14,28),P.land)}return s+knees+reeds},
prairie(k){const {r,P}=k,b=sceneRidge(r,198,10,260),m=sceneRidge(r,222,8,260);let s=sceneHill(k,b,sceneMix(P.far,P.haze,.1))+sceneGroves(k,b,{n:7,hMin:5,hMax:9,gap:6,color:[sceneMix(P.treeB[0],P.haze,.35),sceneMix(P.treeB[1],P.haze,.3)]})+sceneHill(k,m,P.mid)+sceneMeadow(k,m,P.mid,{shrubs:0,grass:0,flowers:0,depth:20,bands:2})+sceneTreeRow(r,m,{gap:10,hMin:10,hMax:16,kinds:[['round',3],['oval',2]],color:P.treeB,clearings:.55,sink:2,snow:k.snow})+sceneGroves(k,m,{n:6,hMin:12,hMax:20,gap:12,kinds:[['spread',2],['round',2]]});
if(r()<.75){const x=sR(r,120,1080),y=sceneYAt(m,x)+2,col=sceneDk(P.mid,.45);s+=''}const f=sceneRidge(r,246,5,260);s+=sceneHill(k,f,P.land);
let posts='';for(let x=-40;x<1240;x+=sR(r,36,52)){const y=sceneYAt(f,x)+16;posts+='M'+sN(x)+' '+sN(y)+' v-14'}s+='';return s+sceneMeadow(k,f,P.land,{shrubs:6,grass:40,flowers:16,depth:40,shrubColor:P.treeB})},
desert(k){const {r,P}=k,haze=P.haze;let s='',x=-60;while(x<1260){const w=sR(r,80,170),h=sR(r,20,44),top=sR(r,.4,.7)*w,y=196;if(r()<.6){const c=sceneMix(P.far,haze,.3);s+=''}x+=w*sR(r,.9,1.8)}
x=-60;while(x<1260){const w=sR(r,90,220),h=sR(r,40,95),top=sR(r,.35,.7)*w,y=214,lx=x+(w-top)/2;if(r()<.75){const c=P.mid;s+=''}x+=w*sR(r,.8,1.6)}
s+='';const m=sceneRidge(r,226,8,220),f=sceneRidge(r,256,6,240);s+=sceneHill(k,m,P.near)+sceneHill(k,f,P.land)+sceneMeadow(k,f,P.land,{shrubs:10,grass:6,flowers:0,depth:30,bands:2,shrubColor:P.treeB});
for(let i=0,n=Math.round((10+Math.floor(r()*6))*Math.max(.3,sceneTreeAmt));i';const L=sR(r,300,420),Rw=sR(r,300,420),lp=sceneRough(r,[[-60,80+sR(r,-15,15)],[L*.4,95+sR(r,-12,12)],[L*.55,160],[L*.8,180],[L,262]],5),rp=sceneRough(r,[[1200-Rw,262],[1200-Rw*.8,186],[1200-Rw*.55,165],[1200-Rw*.4,100+sR(r,-12,12)],[1260,74+sR(r,-15,15)]],5);
s+=sceneBluffFace(k,lp,P.mid)+sceneBluffFace(k,rp,sceneDk(P.mid,.16));const l2=sceneRough(r,[[-60,165],[L*.5,190],[L*.75,235],[L*.95,300]],4),r2=sceneRough(r,[[1200-Rw*.95,300],[1200-Rw*.75,238],[1200-Rw*.5,192],[1260,160]],4);s+=sceneBluffFace(k,l2,P.near)+sceneBluffFace(k,r2,sceneDk(P.near,.14));
const mid=(L+1200-Rw)/2;s+=sceneRiver(k,sceneWind(r,mid,234,mid+sR(r,-60,60),300,24,1),{w0:24,w1:240,bankColor:P.sand[1]});s+='';
for(let i=0;i<8;i++){const side=i%2?-1:1,x=side<0?sR(r,-20,L*.7):sR(r,1200-Rw*.7,1220);s+=sceneShrub(r,x,sR(r,262,296),sR(r,6,12),P.treeF)}return s},
skyline(k){const {r,P}=k;let s='';const row=(base,scale,cols,lit)=>{let o='',x=-20;while(x<1220){const w=sR(r,24,52)*Math.max(.6,scale),h=sR(r,40,150)*scale,y=base-h,col=sPick(r,cols);o+='';if(r()<.2)o+='';let win='';for(let wy=y+7*scale;wy';o+=''+win+'';x+=w+sR(r,0,8)}return o};
s+=row(222,.7,[sceneMix(P.far,P.haze,.2),sceneMix(P.mid,P.haze,.2)],P.haze)+row(252,1,[P.mid,P.near,sceneMix(P.mid,P.near,.5)],sceneLt(P.mid,.4));const g=sceneRidge(r,256,3,300);s+=sceneHill(k,g,P.land,{rim:false})+sceneTreeRow(r,g,{gap:30,hMin:18,hMax:30,kinds:[['round',3],['oval',2]],color:P.treeF,clearings:.25,snow:k.snow});return s}
};
function profileLandscapeArt(t,wat){const land=SCENE_LANDS[t.landscape]?t.landscape:'mountains',r=sceneRng(t.seed||sceneHash(land)),snow=!!t.snow||land==='winterforest',P=scenePalette(t),c0=[P.far,P.mid,P.near];
if(snow){P.far=sceneMix(P.far,'#dde7f0',.6);P.mid=sceneMix(P.mid,'#e3eaf2',.66);P.near=sceneMix(P.near,'#e8eef4',.72);P.land=sceneMix(P.land,PROFILE_SNOW,.82);P.sand=[sceneMix(P.sand[0],PROFILE_SNOW,.7),sceneMix(P.sand[1],PROFILE_SNOW,.6)];P.rock=[sceneMix(P.rock[0],'#e3eaf2',.5),P.rock[1]]}
sceneTreeAmt=SCENE_TREE_AMOUNTS[profileTreeAmount(t.treeAmount)][1];let body='';const k={t,r,c:[P.far,P.mid,P.near],c0,snow,wat,P,defs:''};try{body=SCENE_LANDS[land](k)}finally{sceneTreeAmt=1}return ''+(k.defs?''+k.defs+'':'')+body+''}
function profileWideLandscapeArt(t,wat){return profileLandscapeArt(t,wat)}
function profileSnowfall(seed){const r=sceneRng((Number(seed)||1)^0x5bd1e995);let s='';for(let i=0;i<110;i++){const x=sR(r,-40,1240),y=sR(r,10,300),size=sR(r,.7,2.6),dur=sR(r,7,16),dx=sR(r,-40,40);s+=''}return ''+s+''}
/* sun and moon: position, size, and moon phase */
function profileCelestialNormalizeBase(value,fallback={type:'sun',x:900,y:120,r:28,rays:true}){const x=value&&typeof value==='object'&&!Array.isArray(value)?value:{};const type=['sun','moon','none'].includes(x.type)?x.type:fallback.type;return{type,x:Number.isFinite(Number(x.x))?Math.max(20,Math.min(1180,Number(x.x))):fallback.x,y:Number.isFinite(Number(x.y))?Math.max(25,Math.min(230,Number(x.y))):fallback.y,r:Number.isFinite(Number(x.r))?Math.max(10,Math.min(60,Number(x.r))):fallback.r,rays:x.rays===true,phase:['full','half','crescent'].includes(x.phase)?x.phase:(fallback.phase||'full')}}
function profileSceneSnowDefaults(key){const p=PROFILE_SCENES[key];return {snow:!!p?.snow,snowing:!!p?.snowing}}
/* Custom Scene editor */
function profileLandscapeOptions(selected){return PROFILE_LANDSCAPE_GROUPS.map(([g,items])=>'').join('')}
function profileSceneOptions(selected){return Object.entries(PROFILE_SCENES).map(([k,v])=>'').join('')+''}
function profileSceneRange(label,id,min,max,val){return ''}
/* Scene tools in Profile Design: Shuffle, Snow Cover, Snowing (any scene) */
/* ===== Scene builder (Ver. B13) =====
Layers: a Custom Scene can be built from four layers (Background, Middle Ground, Foreground, Front Ground), each with a Terrain, a Cover, a
Density and its own color. Weather (Clear, Few Clouds, Cloudy, Foggy, Thunderstorm) and Objects (Lighthouse, Campfire, RV, 5th Wheel, and a
Car / Truck / Van that drives across) work on any scene, placed by the user. Trees vary in color tree to tree. All of it is the user's own
customization (WNSYS.db). */
const SCENE_LAYER_NAMES=['Background','Middle Ground','Foreground','Front Ground'];
const SCENE_LAYER_Y=[118,168,212,246],SCENE_LAYER_AMP=[58,40,26,12],SCENE_LAYER_TREE=[[10,20],[20,38],[32,58],[44,78]],SCENE_LAYER_GAP=[16,24,34,46];
const SCENE_TERRAINS=[['none','None'],['mountains','Mountains'],['jagged','Jagged Peaks'],['snowcapped','Snowcapped Peaks'],['rounded','Rounded Mountains'],['hills','Hills'],['cliffs','Cliffs'],['mesas','Mesas'],['skyline','City Skyline'],['water','Lake / Water'],['beach','Beach'],['flat','Flat Ground'],['valley','Valley'],['sidevalley','One-Sided Valley'],['river','River'],['bluffs','River Bluffs'],['vineyard','Vineyard Rows'],['ozark','Ozark Ridges']];
const SCENE_COVERS=[['none','None'],['pines','Pines'],['leafy','Leafy Trees'],['fall','Fall Trees'],['bare','Bare Trees'],['mixed','Mixed Trees'],['palms','Palms'],['cypress','Cypress'],['cactus','Cactus'],['grass','Grass / Reeds'],['rocks','Rocks']];
const SCENE_DENSITIES=[['sparse','Sparse'],['medium','Medium'],['dense','Dense']];
const SCENE_WEATHER=[['clear','Clear'],['few','Few Clouds'],['cloudy','Cloudy'],['fog','Foggy'],['storm','Thunderstorm']];
const SCENE_OBJECTS=[['lighthouse','Lighthouse'],['campfire','Campfire'],['rv','RV'],['fifthwheel','5th Wheel'],['car','Car (driving)'],['truck','Truck (driving)'],['van','Van (driving)']];
const SCENE_TIMES={day:{sky:['#2f6fb0','#7fb6e0','#d9ecf7'],sun:'#FFF4C2',night:false,type:'sun'},sunset:{sky:['#3a1e52','#c0566b','#f6b25a'],sun:'#FFD59B',night:false,type:'sun'},night:{sky:['#0a1230','#182a5c','#5b6fae'],sun:'#EAF2FF',night:true,type:'moon'}};
PROFILE_LANDSCAPE_GROUPS.unshift(['Build Your Own',[['layers','Layers']]]);PROFILE_LANDSCAPE_LABELS.layers='Layers';
/* each tree gets its own shade, a little warmer or cooler, and stands a step darker than the ground behind it */
function sceneVary(r,col){const d=Math.round(sR(r,-16,8)),t=r(),w=t<.8?sR(r,.04,.2):0,f=c=>{const shade=profileShade(c,d);return t<.4?sceneMix(shade,'#7d9440',w):t<.8?sceneMix(shade,'#1d4a52',w):shade};return Array.isArray(col)?[f(col[0]),f(col[1])]:f(col)}
function sceneLayerNormalizeBase(list,ridge){const rg=Array.isArray(ridge)?ridge:['#666666','#444444','#222222'],def=[{terrain:'mountains',cover:'none',density:'medium',color:rg[0]},{terrain:'hills',cover:'pines',density:'medium',color:rg[1]},{terrain:'hills',cover:'pines',density:'medium',color:rg[2]},{terrain:'flat',cover:'grass',density:'sparse',color:profileShade(rg[2]||'#222222',-10)}],ok=(opts,v,d)=>opts.some(o=>o[0]===v)?v:d;return [0,1,2,3].map(i=>{const x=(Array.isArray(list)&&list[i])||{};return {terrain:ok(SCENE_TERRAINS,x.terrain,def[i].terrain),cover:ok(SCENE_COVERS,x.cover,def[i].cover),density:ok(SCENE_DENSITIES,x.density,def[i].density),color:/^#[0-9a-f]{6}$/i.test(String(x.color||''))?x.color:def[i].color}})}
function sceneCliffs(r,y,amp){const pts=[];let x=-60;while(x<1270){const yy=y+sR(r,-amp,amp*.5),w=sR(r,70,210);pts.push([x,yy],[x+w,yy+sR(r,-3,3)]);x+=w+sR(r,4,16)}return pts}
function sceneSkylineRow(r,base,scale,cols){let s='',x=-20;while(x<1220){const w=sR(r,24,52)*Math.max(.6,scale),h=sR(r,40,150)*scale,y=base-h,col=sPick(r,cols);s+='';if(r()<.2)s+='';let win='';for(let wy=y+7*scale;wy';s+=''+win+'';x+=w+sR(r,0,8)}return s}
function sceneLayerCoverBase(k,i,L,pts){if(!pts||L.cover==='none')return '';const r=k.r,snow=k.snow,f={sparse:3.4,medium:1.4,dense:.72}[L.density]||1.4,gap=SCENE_LAYER_GAP[i]*f,[a,b]=SCENE_LAYER_TREE[i],base=profileShade(L.color,-12),row=(kinds,extra)=>sceneTreeRow(r,pts,Object.assign({gap,hMin:a,hMax:b,kinds,color:L.color,clearings:L.density==='dense'?.03:.1,snow},extra||{}));
if(L.cover==='pines')return row(SCENE_PINE_MIX);if(L.cover==='leafy')return row([['round',3],['oval',2],['spread',1.5],['cluster',2]]);if(L.cover==='mixed')return row([['pine',2],['spruce',2],['fir',2],['round',2],['oval',1.5],['cluster',1.5]]);if(L.cover==='bare')return row([['bare',1]],{bareColor:profileShade(sceneMix(L.color,'#3a302b',.6),-8)});if(L.cover==='fall')return row([['round',3],['oval',2],['spread',1.5],['cluster',2],['bare',2],['pine',.8],['fir',.6]],{leaves:PROFILE_FALL_LEAVES,color:sceneMix(L.color,'#2d4a32',.6),bareColor:profileShade(sceneMix(L.color,'#3a302b',.6),-8),fixed:true});
let s='';const each=(g,fn,tree)=>{if(tree){if(sceneTreeAmt<=0)return;g/=sceneTreeAmt}let x=-20+sR(r,0,g);while(x<1225){fn(x,sceneYAt(pts,x)+sR(r,1,4));x+=g*sR(r,.55,1.45)}},size=(b/78);
if(L.cover==='palms')each(gap*2.2,(x,y)=>{s+=scenePalm(x,y,sR(r,b*1.1,b*1.9),sceneVary(r,base),profileShade(L.color,-26),r)},true);
else if(L.cover==='cypress')each(gap*2.6,(x,y)=>{s+=sceneCypress(x,y,sR(r,b*1.2,b*2),sceneVary(r,base),profileShade(L.color,14),r)},true);
else if(L.cover==='cactus')each(gap*2,(x,y)=>{const kind=sWeighted(r,[['saguaro',3],['pear',2],['barrel',1.5]]);s+=sceneCactus(kind,x,y,kind==='saguaro'?sR(r,b*.6,b*1.05):sR(r,b*.22,b*.4),sceneVary(r,profileShade(L.color,18)),r)},true);
else if(L.cover==='grass')each(gap*.9,(x,y)=>{s+=sceneGrass(r,x,y+2,sR(r,b*.22,b*.5),sceneVary(r,profileShade(L.color,22)))});
else if(L.cover==='rocks')each(gap*1.8,(x,y)=>{s+=sceneRock(r,x,y+3,sR(r,b*.4,b*1.1),sR(r,b*.2,b*.6),sceneVary(r,profileShade(L.color,-14)))});
return s}
function sceneLayerDrawBase(k,i,L){const r=k.r,y=SCENE_LAYER_Y[i],amp=SCENE_LAYER_AMP[i],col=k.snow?sceneMix(L.color,i<2?'#e2eaf2':PROFILE_SNOW,.6+i*.05):L.color,hz=c=>i===0?sceneMix(c,k.P.haze,.15):c;let pts=null,s='';
if(L.terrain==='none')return '';
if(L.terrain==='mountains'){pts=sceneRidge(r,y,amp,150-i*14,'peaks');s=scenePeaks(k,pts,pts,hz(col))+(k.snow&&i<2?sceneCapsOn(k,pts,pts,amp*.55):'')}
else if(L.terrain==='jagged'){const b0=sceneRidge(r,y,amp*1.25,118-i*8,'peaks');pts=sceneRough(r,b0,Math.max(3,9-i*2));s=scenePeaks(k,b0,pts,hz(col))+(k.snow&&i<2?sceneCapsOn(k,b0,pts,amp*.6):'')}
else if(L.terrain==='snowcapped'){const b0=sceneRidge(r,y,amp*1.15,132-i*10,'peaks');pts=sceneRough(r,b0,Math.max(2,5-i));s=scenePeaks(k,b0,pts,hz(col))+sceneCapsOn(k,b0,pts,amp*.75)}
else if(L.terrain==='rounded'){pts=sceneRidge(r,y,amp*.7,172,'peaks');s=sceneHill(k,pts,hz(col))}
else if(L.terrain==='hills'){pts=sceneRidge(r,y+amp*.3,amp*.5,230);s=sceneHill(k,pts,hz(col))}
else if(L.terrain==='cliffs'){pts=sceneCliffs(r,y,amp*.8);s=sceneHill(k,pts,hz(col),{sharp:true})}
else if(L.terrain==='mesas'){const base=y+amp*.55;let x=-60;while(x<1260){const w=sR(r,90,220),h=sR(r,amp*.6,amp*1.5),top=sR(r,.35,.7)*w,lx=x+(w-top)/2;if(r()<.75)s+='';x+=w*sR(r,.8,1.6)}pts=[[-60,base],[1260,base]];s+=sceneHill(k,pts,col,{rim:false})}
else if(L.terrain==='skyline'){const base=y+amp*.6,scale=[.55,.75,1,1.2][i];s=sceneSkylineRow(r,base,scale,[col,profileShade(col,-10),profileShade(col,10)]);pts=[[-60,base],[1260,base]];s+=sceneHill(k,pts,profileShade(col,-12),{rim:false})}
else if(L.terrain==='water'){return sceneWater(r,k.wat,sN(y+amp*.4))}
else if(L.terrain==='beach'){pts=sceneRidge(r,y+amp*.5,amp*.16,260);s=sceneHill(k,pts,col,{rimO:.7,dk:.12})}
else if(L.terrain==='valley'){const cx=sR(r,480,720);pts=[];for(let x=-60;x<=1260;x+=60)pts.push([x,y+amp*.35-amp*1.2*Math.pow(Math.abs(x-cx)/700,1.3)+sR(r,-amp*.08,amp*.08)]);s=sceneHill(k,pts,hz(col))}
else if(L.terrain==='sidevalley'){const left=r()<.5;pts=[];for(let x=-60;x<=1260;x+=60){const t=left?1-(x+60)/1320:(x+60)/1320;pts.push([x,y+amp*.4-amp*1.5*Math.pow(t,1.4)+sR(r,-amp*.06,amp*.06)])}s=sceneHill(k,pts,hz(col))}
else if(L.terrain==='river'){const gy=y+amp*.5;pts=sceneRidge(r,gy,amp*.07,300);const cx=sR(r,380,820);s=sceneHill(k,pts,col)+sceneRiver(k,sceneWind(r,cx,gy+1,cx+sR(r,-160,160),300,[30,50,80,120][i],1.8),{w0:[3,4,8,20][i],w1:[50,90,170,260][i]})}
else if(L.terrain==='bluffs'){pts=[];let x=-60;while(x<1270){const yy=y+sR(r,-amp*.6,amp*.2);pts.push([x,yy]);x+=sR(r,30,60)}s=sceneBluffFace(k,pts,hz(col))}
else if(L.terrain==='vineyard'){pts=sceneRidge(r,y+amp*.3,amp*.5,260);s=sceneHill(k,pts,hz(col))+sceneVines(k,pts,5+i*2,col)}
else if(L.terrain==='ozark'){pts=sceneCanopy(r,y+amp*.2,amp*.5,230,2+i*1.6);s=sceneHill(k,pts,hz(col),{rimO:.25})}
else{pts=sceneRidge(r,y+amp*.5,amp*.07,300);s=sceneHill(k,pts,col)}
return s+sceneLayerCover(k,i,L,pts)}
SCENE_LANDS.layers=function(k){const layers=sceneLayerNormalize(k.t.layers,k.t.ridge);return layers.map((L,i)=>sceneLayerDraw(k,i,L)).join('')};
/* weather: {back: behind the land, front: over everything} */
function sceneCloud(r,x,y,s,fill,op){let g='';const n=4+Math.floor(r()*3);for(let i=0;i';g+='';return ''+g+''}
function profileWeatherSvg(t,uid){const w=t.weather||'clear';if(w==='clear')return {back:'',front:'',defs:''};const r=sceneRng(((Number(t.seed)||1)^0x9e3779b9)>>>0),night=!!t.night;let back='',front='',defs='';
if(w==='few'||w==='cloudy'||w==='storm'){const n=w==='few'?5:13,fill=w==='storm'?(night?'#2a3140':'#4f5866'):(night?'#8f9bb5':'#ffffff'),op=w==='storm'?.92:(night?.5:.86);if(w!=='few')back+='';for(let i=0;i'}const bx=sR(r,200,1000);let bolt='M'+sN(bx)+' 20',cx=bx,cy=20;while(cy<175){cx+=sR(r,-26,26);cy+=sR(r,16,34);bolt+=' L'+sN(cx)+' '+sN(cy)}front+=''+rain+''}
if(w==='fog'){const id='wpf_fog_'+uid;defs='';front+='';for(let i=0;i<6;i++)front+=''}
return {back,front,defs}}
/* objects: drawn with the base at (0,0), then moved and sized by where the user put them (lower = nearer = larger) */
const SCENE_OBJECT_ART={
lighthouse:(a)=>'',
campfire:(a)=>'',
rv:(a)=>'',
fifthwheel:(a)=>'',
car:(a)=>'',
truck:(a)=>'',
van:(a)=>''
};
const SCENE_MOVING=new Set(['car','truck','van']);
function sceneObjectsNormalize(list){return (Array.isArray(list)?list:[]).filter(o=>o&&SCENE_OBJECT_ART[o.type]).slice(0,12).map(o=>({id:String(o.id||tablesUuid()),type:o.type,x:Math.max(0,Math.min(1200,Number.isFinite(Number(o.x))?Number(o.x):600)),y:Math.max(150,Math.min(262,Number.isFinite(Number(o.y))?Number(o.y):236))}))}
/* the scene: sky, stars, sun or moon, clouds, land, objects, then fog / rain / snow over everything */
let profileSceneN=0;
function profileSceneSvg(uid,config){uid=String(uid)+'n'+(++profileSceneN);const t=config||PROFILE_SCENES.dusk,sky='wpf_sky_'+uid,glow='wpf_glow_'+uid,wat='wpf_wat_'+uid,stops=(t.sky||[]).map((c,i,a)=>'').join(''),stars=t.night&&t.weather!=='storm'&&t.weather!=='cloudy'?Array.from({length:44},(_,i)=>'').join(''):'',water=t.water||'#2C8A86',wx=profileWeatherSvg(t,uid);return''}
function profileSceneConfig(model,draft){const key=draft?.scene||model.profileScene||profilePickScene(model,model.kind),base=key==='custom'?profileNormalizeCustomScene(draft?.customScene||model.profileSceneCustom,model.profileScene&&PROFILE_SCENES[model.profileScene]?model.profileScene:'lakeside'):(PROFILE_SCENES[key]||PROFILE_SCENES.dusk),src=draft||model;return Object.assign({},base,{seed:draft?draft.seed:model.sceneSeed,snow:src.snow,snowing:src.snowing,weather:src.weather||'clear',objects:src.objects||[],treeAmount:src.treeAmount})}
function profileNormalizeCustomScene(value,fallbackKey='lakeside'){let x=value;if(typeof x==='string'&&x.trim()){try{x=JSON.parse(x)}catch(_){x={}}}const base=profileCustomSceneFromPreset(fallbackKey);base.layers=sceneLayerNormalize(null,base.ridge);if(!x||typeof x!=='object'||Array.isArray(x))return base;const tone=v=>/^#[0-9a-f]{6}$/i.test(String(v||''))?v:'',landscape=PROFILE_LANDSCAPE_LABELS[x.landscape]?x.landscape:base.landscape,sky=Array.isArray(x.sky)?x.sky:base.sky,ridge=Array.isArray(x.ridge)?x.ridge:base.ridge,rg=[ridge[0]||base.ridge[0],ridge[1]||base.ridge[1],ridge[2]||base.ridge[2]];return{landscape,sky:[sky[0]||base.sky[0],sky[1]||base.sky[1],sky[2]||base.sky[2]],sun:x.sun||base.sun,ridge:rg,land:tone(x.land),treeB:tone(x.treeB),treeF:tone(x.treeF),sand:tone(x.sand),layers:sceneLayerNormalize(x.layers,rg),water:x.water||base.water,accent:x.accent||base.accent,night:x.night===true,celestial:profileCelestialNormalize(x.celestial,base.celestial)}}
/* Custom Scene editor (with Layers) and the scene tools (Shuffle, Weather, Snow, Objects) */
function sceneSel(opts,v,attr){return ''}
function profileReadCustomSceneFromForm(d,card){if(d.scene!=='custom')return;const c=profileNormalizeCustomScene(d.customScene),land=card.querySelector('#wpf-landscape'),cel=card.querySelector('#wpf-celestial');if(land)c.landscape=land.value;if(cel){const next=Object.assign({},c.celestial,{type:cel.value}),ph=card.querySelector('#wpf-moon-phase'),cx=card.querySelector('#wpf-cel-x'),cy=card.querySelector('#wpf-cel-y'),cr=card.querySelector('#wpf-cel-r');if(ph)next.phase=ph.value;const sunStyle=card.querySelector('#wpf-sun-style');if(sunStyle)next.style=sunStyle.value;if(cx)next.x=Number(cx.value);if(cy)next.y=Number(cy.value);if(cr)next.r=Number(cr.value);c.celestial=profileCelestialNormalize(next,c.celestial)}for(const input of card.querySelectorAll('[data-wpf-scene-color]')){const k=input.dataset.wpfSceneColor;if(k==='sky0')c.sky[0]=input.value;else if(k==='sky1')c.sky[1]=input.value;else if(k==='sky2')c.sky[2]=input.value;else if(k==='ridge0')c.ridge[0]=input.value;else if(k==='ridge1')c.ridge[1]=input.value;else if(k==='ridge2')c.ridge[2]=input.value;else c[k]=input.dataset.follow==='1'&&input.value.toLowerCase()===input.dataset.shown?'':input.value}card.querySelectorAll('[data-wpf-layer-field]').forEach(el=>{const L=c.layers[Number(el.dataset.wpfLayer)];if(L)L[el.dataset.wpfLayerField]=el.value});card.querySelectorAll('[data-wpf-layer-color]').forEach(el=>{const L=c.layers[Number(el.dataset.wpfLayerColor)];if(L)L.color=el.value});card.querySelectorAll('[data-wpf-layer-tint]').forEach(el=>{const L=c.layers[Number(el.dataset.wpfLayerTint)];if(L)L[el.dataset.kind]=el.dataset.follow==='1'&&el.value.toLowerCase()===el.dataset.shown?'':el.value});const night=card.querySelector('#wpf-scene-night');if(night)c.night=night.checked===true;const time=card.querySelector('#wpf-time')?.value,T=SCENE_TIMES[time];if(T){c.sky=T.sky.slice();c.sun=T.sun;c.night=T.night;c.celestial=profileCelestialNormalize(c.celestial.type==='none'?{type:T.type,x:900,y:105,r:28,phase:'full'}:Object.assign({},c.celestial,{type:T.type}),c.celestial)}c.layers=sceneLayerNormalize(c.layers,c.ridge);d.customScene=c}
function profileObjectRow(o){return '
'}
function profileReadSceneToolsFromForm(d,card){const w=card.querySelector('#wpf-weather');if(w)d.weather=w.value;const ts=card.querySelector('#wpf-text-shade');if(ts)d.textShade=profileTextShade(ts.value);const ta=card.querySelector('#wpf-tree-amount');if(ta)d.treeAmount=profileTreeAmount(ta.value);if(card.querySelector('.wpf-objects'))d.objects=sceneObjectsNormalize([...card.querySelectorAll('[data-wpf-object]')].map(row=>{const g=k=>row.querySelector('[data-wpf-object-field="'+k+'"]')?.value;return {id:row.dataset.wpfObject,type:g('type'),x:Number(g('x')),y:412-Number(g('y'))}}))}
/* ---- Profile designer (B13 R2): the poster stays in view; controls are grouped into tabs and packed into rows ---- */
function profileSwatch(label,key,val){return ''}
function profileDesignerTabs(m,d){return [['scene','Scene'],d.scene==='custom'?['sky','Sky']:null,['objects','Objects'],['logo','Logo']].filter(Boolean)}
function profileScenePane(m,d){const custom=d.scene==='custom',c=custom?profileNormalizeCustomScene(d.customScene):null,layered=custom&&c.landscape==='layers',person=m.kind==='personnel';
const bg=person?'':''+(d.heroPreview?'':(d.heroFileId?'':''))+''+(d.heroFileId||d.heroFile?'':'')+'';
const bar='
'+profileLogoPlaceHtml(profileParseLogo(d.logo,m.name),m)+ed}
function profileEditBody(m,d,tpl){const tabs=profileDesignerTabs(m,d),cur=tabs.some(x=>x[0]===profileCurrent.tab)?profileCurrent.tab:'scene';profileCurrent.tab=cur;const panes={scene:profileScenePane(m,d),sky:profileSkyPane(d),objects:profileObjectsPane(d),logo:profileLogoPane(m,d)};
return '
'+tabs.map(([k,l])=>'').join('')+'
'+tabs.map(([k])=>'
'+panes[k]+'
').join('')+'
'}
/* Personnel: the profile background photo is set from the Personnel page, next to the photo. It is saved with the profile customization in WNSYS. */
async function peopleSetBackground(id,file){try{const c=Object.assign({},profileCustom('personnel',id));if(file){const url=await profileResizeImage(file,1100),fid='hero-'+tablesUuid();await profileFilePut({id:fid,name:file.name||'background',type:'image/jpeg',size:0,lastModified:Date.now(),blob:dataUrlToBlob(url)});c.heroFileId=fid}else c.heroFileId='';profileKv().setKV('profile.custom.personnel.'+id,c);profileSrc.peopleRenderWork('personnel')}catch(e){console.error(e);profileSrc.peopleSetMessage('personnel','The background photo could not be read.',true)}}
function profileContactHref(kind,value){if(kind==='tel')return'tel:'+digitsOnly(value);if(kind==='mailto')return'mailto:'+value;if(kind==='map')return'https://maps.google.com/?q='+encodeURIComponent(value);return''}
function profileContactRow(icon,label,value,kind=''){if(!value)return'';const href=kind?profileContactHref(kind,value):'',body=''+profileSvgIcon(icon)+''+profileEsc(label)+''+profileEsc(value)+'';return href?''+body+'':'
'+body+'
'}
function profileSection(title,inner){return'
'+profileEsc(title)+'
'+inner+'
'}
function profileViewGrid(left,right){if(!left&&!right)return'';if(!left||!right)return'
'+(left||right)+'
';return'
'+left+'
'+right+'
'}
function profileLogoColorField(label,key,value){const v=/^#[0-9a-f]{6}$/i.test(String(value||''))?String(value):'#FFFFFF';return''}
function profileLogoNumberField(label,key,value,min,max,step=1){return''}
function profileLogoPreviewHtml(m,lg,tagline){const cfg=profileParseLogo(lg,m.name),text=String(tagline||'').trim();return'
'+profileBuildLogo(m,cfg)+(text?'
'+profileEsc(text)+'
':'')+'
'}
function profileEmblemPickerHtml(selected){const groups=[];for(const key of PROFILE_EMBLEM_KEYS){const e=PROFILE_EMBLEMS[key],g=e.group||'Other';let row=groups.find(x=>x.name===g);if(!row){row={name:g,items:[]};groups.push(row)}row.items.push([key,e])}groups.sort((a,b)=>{const order=['Artistic RV & Camping','Artistic Nature','Artistic Water & Recreation','Artistic Wildlife','Artistic Adventure'];const rank=x=>{const i=order.indexOf(x.name);return i>=0?i:x.name==='Text / Minimal'?99:50};return rank(a)-rank(b)||a.name.localeCompare(b.name)});return groups.map(g=>'
'+profileEsc(g.name)+'
'+g.items.map(([k,e])=>'').join('')+'
').join('')}
function profileResizeImage(file,maxDim){return new Promise((resolve,reject)=>{const img=new Image(),url=URL.createObjectURL(file);img.onload=()=>{URL.revokeObjectURL(url);const scale=Math.min(1,maxDim/Math.max(img.naturalWidth,img.naturalHeight||1)),w=Math.max(1,Math.round(img.naturalWidth*scale)),h=Math.max(1,Math.round(img.naturalHeight*scale)),canvas=document.createElement('canvas');canvas.width=w;canvas.height=h;canvas.getContext('2d').drawImage(img,0,0,w,h);try{resolve(canvas.toDataURL('image/jpeg',.84))}catch(e){reject(e)}};img.onerror=e=>{URL.revokeObjectURL(url);reject(e)};img.src=url})}
/* ===== Profiles — Family and Resort profile views =====
The drawing engine above (scenes, emblems, generated identity, ribbon, contact rows) is the Waypoint V20 R4 Profile Viewer / Creator engine, carried over unchanged.
Utility › Profile Layout holds one master template per kind (Family, Resort; Personnel when it is built): structure, field placement, labels, size and spacing.
The master templates save to WNSYS.db while the program is being built and are written into the program when they are locked down.
Customize Profile holds a user's own creative choices for a record (scene, identity, tagline, hero). Customizations are user preferences:
they always save to that user's WNSYS.db and are never built into the program. */
const PROFILE_KIND_LABELS={family:'Family',resort:'Resort',personnel:'Personnel'};
const PROFILE_PLACEMENTS=[['hidden','Hidden'],['hero','Header'],['ribbon','Headline Ribbon'],['contact','Contact'],['info','Information']];
const PROFILE_SECTION_POSITIONS=[['top','Full Width — Above'],['left','Left Column'],['right','Right Column'],['bottom','Full Width — Below']];
const PROFILE_TEMPLATE_DEFAULT={cardWidth:1160,overlayPadding:12,posterHeight:252,contentPadding:24,sectionPadding:10,sectionGap:8,columnGap:18,leftWidth:45,rowPadding:8,detailLabelWidth:150,contactColumns:3,detailColumns:2,ribbonVisible:true,
sections:[{key:'contact',source:'Contact',label:'Contact',visible:true,position:'top',order:0},{key:'info',source:'Information',label:'Information',visible:true,position:'top',order:1}],items:[]};
const PROFILE_TEMPLATE_RANGES={cardWidth:[760,1400],overlayPadding:[0,40],posterHeight:[200,360],contentPadding:[8,40],sectionPadding:[0,24],sectionGap:[0,24],columnGap:[8,40],leftWidth:[30,60],rowPadding:[4,16],detailLabelWidth:[90,200],contactColumns:[1,4],detailColumns:[1,2]};
let profileCurrent=null,profileLastFocus=null,profileAssetUrls=[];
let profileTplKind='family',profileTplDraft=null,profileTplPreviewId='';
const profileTplOpenGroups=new Set(['layout','fields']);
function profileText(v,f){const x=String(v==null?'':v).trim();return x||f}
/* Profile settings (the master templates, and each profile's Customize Profile choices) are kept in the database's settings table,
like every Beta setting (wnSettingGet / wnSettingSet). */
const PROFILE_KV={getKV:(key,fallback)=>wnSettingGet(key,fallback),setKV:(key,value)=>wnSettingSet(key,value)};
function profileKv(){return PROFILE_KV}
/* ---- data adapters ---- */
function profileFieldKind(f){const parts=profileSrc.orgParts(f),masks=parts.map(p=>p.mask||'');if(masks.some(m=>m==='phone'||m==='phone-ext'))return 'tel';if(masks.includes('email'))return 'mailto';if(parts.length>1&&masks.some(m=>m==='state'||m==='zip-us'||m==='postal-ca'))return 'map';return ''}
function profileFieldValue(f,values){const parts=profileSrc.orgParts(f),out=[];parts.forEach(p=>{const v=String(values?.[p.key]??'').trim();if(!v)return;if((p.mask==='zip-us'||p.mask==='postal-ca')&&out.length&&parts.some(q=>q.mask==='state'&&String(values?.[q.key]??'').trim()===out[out.length-1]))out[out.length-1]+=' '+v;else out.push(v)});return out.join(', ')}
function profileItemDefs(kind){if(kind==='personnel')return profilePersonItemDefs();if(profileSrc.ORG_KINDS[kind])return [...orgFields().map(f=>{const k=profileFieldKind(f);return {key:'field:'+f.id,source:f.label||'Field',def:k?'contact':'info'}})];return []}
function profileWireId(key){const b=profileSrc.ORG_BUILTIN_WIRES.find(w=>w.key===key);if(!b)return '';const w=profileSrc.orgRows.wire.find(x=>String(x.id)===b.id)||profileSrc.orgRows.wire.find(x=>profileSrc.orgNameKey(x.wire_name)===profileSrc.orgNameKey(b.name));return w?String(w.id):''}
function profileWiredLogos(rec,key){const wire=profileWireId(key);if(!rec||!wire)return [];return (Array.isArray(rec.logos)?rec.logos:[]).filter(l=>l&&l.active!==false&&!l._pending&&(l.wires||[]).map(String).includes(wire)).map(l=>({id:l.file_id,name:l.name||'Logo'}))}
function profileCustom(kind,id){const x=profileKv()?.getKV('profile.custom.'+kind+'.'+id,null);return x&&typeof x==='object'&&!Array.isArray(x)?x:{}}
function profileModel(kind,id){
let row=null,m=null;
if(kind==='personnel'){row=profileSrc.peopleById('personnel',id);if(!row)return null;m=profilePersonModel(row)}else{
if(!profileSrc.ORG_KINDS[kind])return null;row=profileSrc.orgById(kind,id);if(!row)return null;const values=profileJsonObject(row.field_values),on=orgOnSet(kind,row),items={};profileSrc.orgFields().filter(f=>on.has(String(f.id))).forEach(f=>{const v=profileFieldValue(f,values),k=profileFieldKind(f);items['field:'+f.id]={v,kind:k||(/^https?:\/\//i.test(v)?'link':'')}});
if(kind==='family')m={kind,id:row.id,name:row.family_name||'Family',items,logoFiles:profileWiredLogos(row,'family-logo'),familyBrand:null};
else{const fam=row.family_id?profileSrc.orgFamilyById(row.family_id):null;const amap=new Map(profileSrc.orgAmenRows(row.id).map(a=>[String(a.infrastructure_item_id),a])),amenityGroups=[];for(const root of profileSrc.infraRoots()){const ra=amap.get(String(root.id));if(!ra)continue;const kids=profileSrc.infraChildren(root.id).filter(c=>amap.has(String(c.id)));amenityGroups.push({id:root.id,name:root.item_name,value:profileAmenityValue(root,ra),items:kids.map(c=>({id:c.id,name:c.item_name,value:profileAmenityValue(c,amap.get(String(c.id)))}))})}m={kind,id:row.id,name:row.resort_name||'Resort',items,amenityGroups,logoFiles:profileWiredLogos(row,'resort-use'),familyBrand:fam?{name:fam.family_name||'Family',logo:profileWiredLogos(fam,'resort-use')[0]||null}:null}}}
if(!m)return null;
const c=profileCustom(kind,m.id);const inactive=kind==='personnel'?row.status==='inactive':row.active===false;m.status=inactive?'Inactive':'Active';m.statusClass=inactive?'inactive':'active';m.location='';m.about='';m.amenityText='';
m.profileScene=c.scene&&(c.scene==='custom'||PROFILE_SCENES[c.scene])?c.scene:'';m.profileSceneCustom=c.customScene||null;m.logo=c.logo||null;m.tagline=String(c.tagline||'');m.heroFileId=String(c.heroFileId||'');const sceneKey=m.profileScene||profilePickScene(m,m.kind),snowDef=profileSceneSnowDefaults(sceneKey==='custom'?'':sceneKey);m.sceneSeed=Number(c.seed)>0?Number(c.seed):sceneHash(kind+':'+m.id);m.snow=typeof c.snow==='boolean'?c.snow:snowDef.snow;m.snowing=typeof c.snowing==='boolean'?c.snowing:snowDef.snowing;m.weather=SCENE_WEATHER.some(x=>x[0]===c.weather)?c.weather:'clear';m.objects=sceneObjectsNormalize(c.objects);m.textShade=profileTextShade(c.textShade);m.treeAmount=profileTreeAmount(c.treeAmount);
m.logoFileId=m.logoFiles.some(f=>String(f.id)===String(c.logoFileId))?String(c.logoFileId):(m.logoFiles[0]?.id||'');m.logoMode=c.logoMode==='generated'||!m.logoFiles.length?'generated':'uploaded';
/* Ver. Beta 38 Rev. 15: Personnel use the saved Logo Source only once it was picked on the Logo tab */
if(kind==='personnel'&&!c.logoChosen)m.logoMode=m.logoFiles.length?'uploaded':'generated';
return m}
/* ---- master templates ---- */
function profileTplNum(key,v,f){const r=PROFILE_TEMPLATE_RANGES[key],n=Number(v);return Number.isFinite(n)?Math.max(r[0],Math.min(r[1],n)):f}
function profileTplNormalize(kind,raw){const d=PROFILE_TEMPLATE_DEFAULT,src=raw&&typeof raw==='object'&&!Array.isArray(raw)?raw:{},out={};Object.keys(PROFILE_TEMPLATE_RANGES).forEach(k=>out[k]=profileTplNum(k,src[k],d[k]));out.ribbonVisible=src.ribbonVisible!==false;
const secBy=new Map((Array.isArray(src.sections)?src.sections:[]).map(x=>[String(x?.key||''),x])),pos=new Set(PROFILE_SECTION_POSITIONS.map(x=>x[0]));out.sections=profileTplSectionDefaults(kind).map((s,i)=>{const x=secBy.get(s.key)||{};return {key:s.key,source:s.source,label:profileText(x.label,s.label),visible:x.visible===undefined?s.visible:x.visible!==false,position:pos.has(x.position)?x.position:s.position,order:Number.isFinite(Number(x.order))?Number(x.order):i}}).sort((a,b)=>a.order-b.order).map((x,i)=>({...x,order:i}));
const itemBy=new Map((Array.isArray(src.items)?src.items:[]).map(x=>[String(x?.key||''),x])),places=new Set(PROFILE_PLACEMENTS.map(x=>x[0]));out.items=profileItemDefs(kind).map((dd,i)=>{const x=itemBy.get(dd.key)||{};return {key:dd.key,source:dd.source,label:profileText(x.label,dd.source),placement:places.has(x.placement)?x.placement:dd.def,order:Number.isFinite(Number(x.order))?Number(x.order):1000+i}}).sort((a,b)=>a.order-b.order).map((x,i)=>({...x,order:i}));return out}
function profileTplSectionDefaults(kind){if(kind==='personnel')return [...PROFILE_TEMPLATE_DEFAULT.sections,{key:'about',source:'Bio',label:'About',visible:true,position:'bottom',order:2},{key:'credentials',source:'Degrees & Certifications',label:'Credentials',visible:true,position:'bottom',order:3}];return kind==='resort'?[...PROFILE_TEMPLATE_DEFAULT.sections,{key:'amenities',source:'Amenities',label:'Amenities',visible:true,position:'bottom',order:2}]:PROFILE_TEMPLATE_DEFAULT.sections}
function profileTemplate(kind){return profileTplNormalize(kind,profileKv()?.getKV('profile.template.'+kind,null))}
function profileTplApplyVariables(card,p){if(!card)return;const s=card.style;s.setProperty('--wpf-layout-card-width',p.cardWidth+'px');s.setProperty('--wpf-layout-poster-height',p.posterHeight+'px');s.setProperty('--wpf-layout-content-pad',p.contentPadding+'px');s.setProperty('--wpf-layout-section-pad',p.sectionPadding+'px');s.setProperty('--wpf-layout-section-gap',p.sectionGap+'px');s.setProperty('--wpf-layout-column-gap',p.columnGap+'px');s.setProperty('--wpf-layout-left-width',p.leftWidth+'%');s.setProperty('--wpf-layout-row-pad',p.rowPadding+'px');s.setProperty('--wpf-layout-detail-label-width',p.detailLabelWidth+'px');s.setProperty('--wpf-layout-contact-columns',String(p.contactColumns));s.setProperty('--wpf-layout-detail-columns',String(p.detailColumns))}
function profileItemsAt(m,tpl,placement){return (tpl.items||[]).filter(x=>x.placement===placement&&m.items[x.key]).sort((a,b)=>a.order-b.order).map(x=>({key:x.key,label:x.label,value:m.items[x.key].v,kind:m.items[x.key].kind}))}
/* ---- rendering ---- */
function profileAssetImg(cls,id,alt){return ''}
function profilePosterLogo(m,draft){const mode=draft?draft.logoMode:m.logoMode,chosen=draft?draft.logoFileId:m.logoFileId,file=m.logoFiles.find(f=>String(f.id)===String(chosen))||m.logoFiles[0];if(mode==='uploaded'&&file){const img=profileAssetImg('wpf-official-logo',file.id,m.name+' logo');return m.kind==='personnel'?'
'+img+'
'+profileEsc(m.name)+'
':img}return profileBuildLogo(m,draft?.logo||m.logo)}
function profileFamilyBrandHtml(m,lg){const b=m.familyBrand;if(!b)return '';return '
'}
/* the shade behind the name at the foot of the scene (Text Shade on Customize Profile); 35% unless set */
function profileTextShade(v){const n=typeof v==='number'?v:(typeof v==='string'&&v.trim()!==''?Number(v):NaN);return Number.isFinite(n)?Math.max(0,Math.min(90,Math.round(n))):35}
function profileScrimStyle(v){const a=profileTextShade(v)/100;return 'background:linear-gradient(to top,rgba(10,16,14,'+a.toFixed(2)+'),rgba(10,16,14,'+(a*.45).toFixed(2)+') 30%,rgba(10,16,14,0) 62%)'}
function profilePoster(m,draft,tpl){const cfg=profileSceneConfig(m,draft),uid=m.kind.charAt(0)+String(m.id).replace(/[^a-z0-9]/gi,'').slice(-10),heroId=draft?draft.heroFileId:m.heroFileId,hero=draft?.heroPreview?'':(heroId?profileAssetImg('wpf-hero',heroId,''):''),logoCfg=profileParseLogo(draft?.logo||m.logo,m.name),taglineText=m.kind==='personnel'?'':(draft?draft.tagline:m.tagline),tagline=String(taglineText||'').trim()?'
'}
function profileAmenityValue(item,a){const type=(item&&item.value_type)||'number';if(type==='boolean')return '';if(type==='select'||type==='text')return a&&a.value_text!=null?String(a.value_text):'';return a&&a.value_number!=null&&a.value_number!==''?String(a.value_number):''}
function profileAmenityBadges(m){if(!m.amenityGroups.length)return'
'}return ''}
function profileRibbon(m,tpl){return tpl.ribbonVisible?profileRibbonHtml(profileItemsAt(m,tpl,'ribbon').map(x=>({v:x.value,l:m.kind==='personnel'?'':x.label}))):''}
function profileViewBody(m,tpl){const buckets={top:[],left:[],right:[],bottom:[]};(tpl.sections||[]).filter(x=>x.visible!==false).sort((a,b)=>a.order-b.order).forEach(s=>{const html=profileSectionMarkup(m,s,tpl);if(html)buckets[s.position]?.push(html)});const full=x=>x.length?'
'}
/* ---- Customize Profile (the user's own creative choices) ---- */
function profileDraftFor(m){return {seed:m.sceneSeed,snow:!!m.snow,snowing:!!m.snowing,weather:m.weather||'clear',textShade:profileTextShade(m.textShade),treeAmount:profileTreeAmount(m.treeAmount),objects:sceneObjectsNormalize(m.objects),scene:m.profileScene||profilePickScene(m,m.kind),customScene:profileNormalizeCustomScene(m.profileSceneCustom,m.profileScene&&PROFILE_SCENES[m.profileScene]?m.profileScene:'lakeside'),tagline:m.tagline||'',logoMode:m.logoMode,logoFileId:m.logoFileId||'',logo:profileParseLogo(m.logo,m.name),heroFileId:m.heroFileId||'',heroFile:null,heroPreview:''}}
function profileReadDraftFromForm(){if(!profileCurrent?.editing||!profileCurrent.draft)return;const d=profileCurrent.draft,card=document.getElementById('profile-card'),m=profileModel(profileCurrent.kind,profileCurrent.id),scene=card.querySelector('[data-wpf-scene-select]'),snowCover=card.querySelector('#wpf-snow-cover'),snowing=card.querySelector('#wpf-snowing');if(snowCover)d.snow=snowCover.checked;if(snowing)d.snowing=snowing.checked;profileReadSceneToolsFromForm(d,card);if(scene&&scene.value!==d.scene){const prev=d.scene;d.scene=scene.value;if(d.scene==='custom'){if(PROFILE_SCENES[prev])d.customScene=profileCustomSceneFromPreset(prev)}else Object.assign(d,profileSceneSnowDefaults(d.scene))}else{if(scene)d.scene=scene.value;profileReadCustomSceneFromForm(d,card)}const tag=card.querySelector('#wpf-tagline'),mode=card.querySelector('#wpf-logo-mode'),file=card.querySelector('#wpf-logo-file');if(tag)d.tagline=tag.value;if(mode)d.logoMode=mode.value;if(file)d.logoFileId=file.value;if(card.querySelector('#wpf-logo-line1')){const sel=card.querySelector('.wpf-le-ic.sel'),lg=Object.assign({},d.logo||{}),ringBox=card.querySelector('#wpf-logo-ring');lgxRead(lg,card);lg.icon=sel?sel.dataset.profileIcon:(lg.icon||'art_mountain_sun');lg.ring=ringBox?ringBox.checked:lg.ring!==false;if(lg._font){lg.font=lg._font;delete lg._font}else lg.font=card.querySelector('#wpf-logo-font')?.value||lg.font||'Jost';lg.line1=card.querySelector('#wpf-logo-line1')?.value||'';lg.line2=card.querySelector('#wpf-logo-line2')?.value||'';for(const el of card.querySelectorAll('[data-wpf-logo-number]'))lg[el.dataset.wpfLogoNumber]=Number(el.value);for(const el of card.querySelectorAll('[data-wpf-logo-color-text]')){const v=String(el.value||'').trim();if(/^#[0-9a-f]{6}$/i.test(v))lg[el.dataset.wpfLogoColorText]=v}d.logo=profileParseLogo(lg,m?.name||'')}const place=card.querySelectorAll('[data-wpf-logo-place]');if(place.length){const lg=Object.assign({},d.logo||{});place.forEach(el=>{const k=el.dataset.wpfLogoPlace;lg[k]=k==='posY'||k==='brandY'?-Number(el.value):Number(el.value)});d.logo=profileParseLogo(lg,m?.name||'')}}
function profileRefreshLogoPreview(){const m=profileModel(profileCurrent.kind,profileCurrent.id),box=document.getElementById('wpf-logo-preview');if(!m||!box)return;profileReadDraftFromForm();box.innerHTML=profileLogoPreviewHtml(m,profileCurrent.draft.logo,document.getElementById('wpf-tagline')?.value??profileCurrent.draft.tagline)}
async function profileSave(){if(profileCurrent&&!profileSrc.pmxAllow(profileCurrent.kind==='personnel'?'people-profile':'org-'+profileCurrent.kind,'write'))return;if(!profileCurrent?.editing)return;profileReadDraftFromForm();const {kind,id,draft:d}=profileCurrent;try{let heroFileId=d.heroFileId||'';if(d.heroFile){heroFileId='hero-'+tablesUuid();await profileFilePut({id:heroFileId,name:d.heroFile.name||'hero',type:'image/jpeg',size:0,lastModified:Date.now(),blob:dataUrlToBlob(d.heroPreview)})}profileKv().setKV('profile.custom.'+kind+'.'+id,{seed:Number(d.seed)||0,snow:!!d.snow,snowing:!!d.snowing,weather:d.weather||'clear',textShade:profileTextShade(d.textShade),treeAmount:profileTreeAmount(d.treeAmount),objects:sceneObjectsNormalize(d.objects),scene:d.scene||'',customScene:d.customScene||null,tagline:String(d.tagline||'').trim(),logoMode:d.logoMode==='uploaded'?'uploaded':'generated',logoChosen:true,logoFileId:d.logoFileId||'',logo:d.logo||null,heroFileId});toast('Profile saved');profileOpen(kind,id,false)}catch(e){console.error(e);alert(e.message||String(e))}}
/* ---- the profile popout ---- */
function profileOpen(kind,id,editing=false){if(!profileSrc.pmxAllow(kind==='personnel'?'people-profile':'org-'+kind,editing?'write':'read'))return;const m=profileModel(kind,id);if(!m)return;const overlay=document.getElementById('profile-overlay');if(!overlay?.classList.contains('open'))profileLastFocus=document.activeElement;profileCurrent={kind,id,editing,draft:editing?profileDraftFor(m):null};profileRenderCurrent();overlay?.classList.add('open');if(overlay)overlay.scrollTop=0}
function profileRenderCurrent(){if(!profileCurrent)return;const card=document.getElementById('profile-card'),m=profileModel(profileCurrent.kind,profileCurrent.id),overlay=document.getElementById('profile-overlay');if(!card||!m)return;const d=profileCurrent.draft,tpl=profileTemplate(m.kind),cfg=profileSceneConfig(m,d);card.style.setProperty('--wpf-accent',cfg.accent||'#2C8A86');card.classList.toggle('editing',profileCurrent.editing);card.classList.add('wpf-templated');card.dataset.kind=m.kind;profileTplApplyVariables(card,tpl);if(overlay)overlay.style.padding=tpl.overlayPadding+'px';const keepScroll=card.querySelector('.wpf-edit-scroll')?.scrollTop||0;card.innerHTML=''+profilePoster(m,d,tpl)+(profileCurrent.editing?profileEditBody(m,d,tpl):profileViewBody(m,tpl))+'
'+(profileCurrent.editing?'':'')+'
';profileBindCard();{const sc=card.querySelector('.wpf-edit-scroll');if(sc)sc.scrollTop=keepScroll}profileHydrateAssets(card).catch(console.error);if(!profileCurrent.editing)requestAnimationFrame(()=>{card.classList.add('shine');setTimeout(()=>card.classList.remove('shine'),950)});card.tabIndex=-1;try{card.focus({preventScroll:true})}catch(_){card.focus()}}
function profileBindCard(){const card=document.getElementById('profile-card'),redraw=()=>{profileReadDraftFromForm();profileRenderCurrent()};card.querySelector('#wpf-close-profile')?.addEventListener('click',profileClose);card.querySelector('#wpf-customize-profile')?.addEventListener('click',()=>profileOpen(profileCurrent.kind,profileCurrent.id,true));card.querySelector('#wpf-cancel-profile')?.addEventListener('click',()=>profileOpen(profileCurrent.kind,profileCurrent.id,false));card.querySelector('#wpf-save-profile')?.addEventListener('click',profileSave);if(!profileCurrent?.editing)return;card.querySelectorAll('[data-wpf-tab]').forEach(b=>b.addEventListener('click',()=>{profileCurrent.tab=b.dataset.wpfTab;card.querySelectorAll('[data-wpf-tab]').forEach(x=>x.classList.toggle('on',x===b));card.querySelectorAll('[data-wpf-pane]').forEach(p=>p.hidden=p.dataset.wpfPane!==profileCurrent.tab)}));card.querySelector('#wpf-tagline')?.addEventListener('change',redraw);card.querySelectorAll('[data-lgx],[data-lgx-color],[data-lgxc],[data-wpf-logo-place]').forEach(el=>el.addEventListener('change',redraw));lgxnBindLive(card);card.querySelector('#lgx-colors-reset')?.addEventListener('click',()=>{profileReadDraftFromForm();LGX_COLOR_SLOTS.forEach(s=>{profileCurrent.draft.logo[s[0]]=''});profileRenderCurrent()});
['[data-wpf-scene-select]','#wpf-landscape','#wpf-time','#wpf-weather','#wpf-celestial','#wpf-moon-phase','#wpf-cel-x','#wpf-cel-y','#wpf-cel-r','#wpf-scene-night','#wpf-snow-cover','#wpf-snowing','#wpf-text-shade','#wpf-tree-amount','#wpf-logo-mode','#wpf-logo-file'].forEach(sel=>card.querySelector(sel)?.addEventListener('change',redraw));card.querySelector('#wpf-tree-amount')?.addEventListener('input',e=>{const w=card.querySelector('#wpf-tree-word');if(w)w.textContent=SCENE_TREE_AMOUNTS[profileTreeAmount(e.target.value)][0]});card.querySelectorAll('[data-wpf-layer-field],[data-wpf-layer-color],[data-wpf-layer-tint],[data-wpf-object-field],#wpf-sun-style').forEach(el=>el.addEventListener('change',redraw));card.querySelector('#wpf-object-add')?.addEventListener('click',()=>{profileReadDraftFromForm();const d=profileCurrent.draft;d.objects=sceneObjectsNormalize([...(d.objects||[]),{type:'campfire',x:600,y:236}]);profileRenderCurrent()});card.querySelectorAll('[data-wpf-object-remove]').forEach(b=>b.addEventListener('click',()=>{const id=b.closest('[data-wpf-object]').dataset.wpfObject;profileReadDraftFromForm();profileCurrent.draft.objects=(profileCurrent.draft.objects||[]).filter(o=>o.id!==id);profileRenderCurrent()}));card.querySelector('#wpf-scene-shuffle')?.addEventListener('click',()=>{profileReadDraftFromForm();profileCurrent.draft.seed=sceneNewSeed();profileRenderCurrent()});card.querySelectorAll('[data-wpf-scene-color]').forEach(el=>el.addEventListener('change',redraw));
const heroFile=card.querySelector('#wpf-hero-file');card.querySelector('#wpf-hero-upload')?.addEventListener('click',()=>heroFile?.click());heroFile?.addEventListener('change',async e=>{const f=e.target.files?.[0];if(!f)return;try{profileReadDraftFromForm();profileCurrent.draft.heroFile=f;profileCurrent.draft.heroPreview=await profileResizeImage(f,1100);profileRenderCurrent()}catch(err){console.error(err)}});card.querySelector('#wpf-hero-remove')?.addEventListener('click',()=>{profileReadDraftFromForm();Object.assign(profileCurrent.draft,{heroFileId:'',heroFile:null,heroPreview:''});profileRenderCurrent()});
['#wpf-logo-line1','#wpf-logo-line2','#wpf-logo-font','#wpf-logo-ring','#wpf-tagline'].forEach(sel=>{const el=card.querySelector(sel);if(el){el.addEventListener('input',profileRefreshLogoPreview);el.addEventListener('change',profileRefreshLogoPreview)}});card.querySelectorAll('[data-wpf-logo-number]').forEach(el=>{el.addEventListener('input',profileRefreshLogoPreview);el.addEventListener('change',profileRefreshLogoPreview)});
card.querySelectorAll('[data-wpf-logo-color]').forEach(color=>color.addEventListener('input',()=>{const t=card.querySelector('[data-wpf-logo-color-text="'+color.dataset.wpfLogoColor+'"]');if(t)t.value=color.value;profileRefreshLogoPreview()}));card.querySelectorAll('[data-wpf-logo-color-text]').forEach(inp=>inp.addEventListener('change',()=>{if(/^#[0-9a-f]{6}$/i.test(inp.value)){const p=card.querySelector('[data-wpf-logo-color="'+inp.dataset.wpfLogoColorText+'"]');if(p)p.value=inp.value;profileRefreshLogoPreview()}}));
card.querySelectorAll('.wpf-le-ic').forEach(btn=>btn.addEventListener('click',()=>{card.querySelectorAll('.wpf-le-ic').forEach(x=>x.classList.remove('sel'));btn.classList.add('sel');profileRefreshLogoPreview()}))}
async function profileHydrateAssets(root){for(const img of root.querySelectorAll('[data-profile-asset]')){try{const row=await profileFileGet(img.dataset.profileAsset),blob=row?.blob;if(!blob){img.style.display='none';continue}const url=URL.createObjectURL(blob);profileAssetUrls.push(url);img.src=url;img.style.display='block'}catch(e){console.error(e);img.style.display='none'}}}
function profileClose(){const overlay=document.getElementById('profile-overlay');overlay?.classList.remove('open');for(const url of profileAssetUrls){try{URL.revokeObjectURL(url)}catch(_){}}profileAssetUrls=[];profileCurrent=null;try{profileLastFocus?.focus({preventScroll:true})}catch(_){profileLastFocus?.focus()}profileLastFocus=null}
function profileBind(){const overlay=document.getElementById('profile-overlay');overlay?.addEventListener('click',e=>{if(e.target===overlay)profileClose()});document.addEventListener('keydown',e=>{if(e.key==='Escape'&&overlay?.classList.contains('open'))profileClose()})}
/* ===== Profile Generator additions (brought in with Ver. Beta 7 Rev. 3): logo styles with interchangeable parts, state and resort parts,
real tree, palm and bird silhouettes, the expanded scene builder, logo size and position on the poster ===== */
/* ================= Logo styles with interchangeable parts =================
A logo is a Frame (Round Badge, Shield, Crest) around a small layered scene built from parts that can each be swapped:
Sky, Background, Trees, Ground, Feature, Accents, and a Palette (with Ring, Trim, Lettering and Accent colors that can be changed).
Depth comes from layering: far shapes are lighter, near shapes darker; peaks and trees have a lit side and a shaded side.
Everything is drawn on a 300 x 300 grid with the horizon at y = 186. */
const LGX_STYLES=[['classic','Classic'],['badge','Round Badge'],['shield','Shield'],['crest','Crest']];
const LGX_PARTS={
sky:[['sun','Sun'],['moon','Moon & Stars'],['plain','Plain']],
back:[['mountains','Mountains'],['mesas','Mesas'],['hills','Rolling Hills'],['none','None']],
trees:[['pines','Pines'],['palms','Palms'],['none','None']],
ground:[['lake','Lake'],['meadow','Meadow'],['beach','Beach'],['desert','Desert']],
feature:[['none','None'],['motorhome','Motorhome'],['trailer','Travel Trailer'],['fifthwheel','Fifth Wheel'],['campfire','Campfire'],['tent','Tent'],['cabin','Cabin']],
accent:[['stars','Stars'],['dots','Dots'],['none','None']]
};
const LGX_PART_LABELS={sky:'Sky',back:'Background',trees:'Trees',ground:'Ground',feature:'Feature',accent:'Accents'};
const LGX_PALETTES={
sunset:{label:'Sunset',rim:'#F3E3C3',band:'#1F2E46',line:'#E9A23B',text:'#F3E3C3',accent:'#E9A23B',sky:['#F08A4B','#F6A955','#FBC96B'],sun:'#FFF1C9',far:'#C96A5A',mid:['#8C4A63','#663655'],near:['#4A3160','#32234A'],snow:['#FFF4E0','#E7C9B8'],pineB:['#3C4D63','#2A384D'],pineF:['#1F2E46','#152033'],water:['#2E5E7E','#5D93AE'],land:['#5E6B4A','#454F38'],sand:['#E9C48A','#CFA468'],shore:'#152033'},
evergreen:{label:'Evergreen',rim:'#F1E9D2',band:'#1E3D32',line:'#D9A441',text:'#F1E9D2',accent:'#D9A441',sky:['#BFDCCB','#D8E9D2','#EEF3DC'],sun:'#FBE9A8',far:'#8FB7A7',mid:['#5F9484','#467767'],near:['#3A6B5C','#27503F'],snow:['#FFFFFF','#D5E6DE'],pineB:['#2F6650','#1F4C3A'],pineF:['#1B4031','#102A20'],water:['#2F7C8C','#7FC0C6'],land:['#5C9A63','#437A4C'],sand:['#E6D5A8','#C9B47F'],shore:'#102A20'},
lake:{label:'Lake Blue',rim:'#FFFFFF',band:'#143A5A',line:'#F2B544',text:'#FFFFFF',accent:'#F2B544',sky:['#7EC3E6','#A6D8EE','#CDEAF4'],sun:'#FFE8A3',far:'#8FB4D6',mid:['#5C8FC0','#3F72A6'],near:['#2F5F93','#1E4674'],snow:['#FFFFFF','#D3E4F2'],pineB:['#2A5E73','#1B4558'],pineF:['#143A5A','#0C2740'],water:['#1F6FA3','#7EC3E6'],land:['#4E9A76','#377B5B'],sand:['#F0DDAE','#D6BE85'],shore:'#0C2740'},
desert:{label:'Desert Dusk',rim:'#F6E7CF',band:'#5B2A2A',line:'#F0B45A',text:'#F6E7CF',accent:'#F0B45A',sky:['#F3B27A','#F7C993','#FBE0B4'],sun:'#FFF3D6',far:'#D98C6A',mid:['#C06A4C','#9E4E3A'],near:['#8A3F34','#662B27'],snow:['#FBE0B4','#E3B98F'],pineB:['#7A4A3A','#5B342B'],pineF:['#4A2222','#331616'],water:['#A6584A','#D99A78'],land:['#B5774F','#935B3B'],sand:['#E8B67C','#CC955C'],shore:'#331616'},
night:{label:'Night Sky',rim:'#E8E6F5',band:'#161A3A',line:'#B9A8F0',text:'#E8E6F5',accent:'#F3D37A',sky:['#1B2452','#273070','#34418C'],sun:'#F7F1D0',far:'#4B5596',mid:['#39417F','#2A3166'],near:['#232857','#171B40'],snow:['#E8E6F5','#B9BCE0'],pineB:['#1E2A55','#141C3D'],pineF:['#10152F','#090C1E'],water:['#1C2F66','#5C74C2'],land:['#243A52','#172838'],sand:['#6C6A8A','#514F6E'],shore:'#090C1E'},
autumn:{label:'Autumn',rim:'#F7EBD5',band:'#3B2A1E',line:'#E08A2E',text:'#F7EBD5',accent:'#E08A2E',sky:['#F2C98A','#F6DBA8','#FAEBC8'],sun:'#FFF6DC',far:'#C99A72',mid:['#A9683F','#864D2E'],near:['#6E3B25','#4F2918'],snow:['#FFF6DC','#E6CFA8'],pineB:['#B4551F','#8C3F16'],pineF:['#5A2E16','#3B1D0D'],water:['#3E6F7A','#8FBEC2'],land:['#B98A3A','#96692A'],sand:['#E7C990','#CBA76A'],shore:'#3B1D0D'}
};
const LGX_COLOR_SLOTS=[['bandColor','Ring','band'],['rimColor','Trim','rim'],['letterColor','Lettering','text'],['accentColor','Accent','accent']];
let lgxUid=0;
function lgxHex(v){return /^#[0-9a-f]{6}$/i.test(String(v||''))?String(v):''}
function lgxPick(list,v,f){return list.some(x=>x[0]===v)?v:f}
/* the logo settings that belong to these styles (kept alongside the Classic settings) */
function lgxPalette(lg){const p=Object.assign({},LGX_PALETTES[lg.palette]||LGX_PALETTES.sunset);if(lg.bandColor)p.band=lg.bandColor;if(lg.rimColor)p.rim=lg.rimColor;if(lg.letterColor)p.text=lg.letterColor;if(lg.accentColor){p.accent=lg.accentColor;p.line=lg.accentColor}return p}
function lgxN(v){return Math.round(v*10)/10}
function lgxPt(cx,cy,r,deg){const a=deg*Math.PI/180;return lgxN(cx+r*Math.cos(a))+' '+lgxN(cy-r*Math.sin(a))}
/* ---- parts ---- */
/* a pine with a lit left half and a shaded right half */
function lgxPine(cx,top,base,w,light,dark,trunk){const h=base-top,t1=top+h*.36,t2=top+h*.66,a=w*.48,b=w*.76,c=w,n=lgxN;
const full='M'+cx+' '+top+' L'+n(cx-a/2)+' '+n(t1)+' H'+n(cx-a*.2)+' L'+n(cx-b/2)+' '+n(t2)+' H'+n(cx-b*.18)+' L'+n(cx-c/2)+' '+base+' H'+n(cx+c/2)+' L'+n(cx+b*.18)+' '+n(t2)+' H'+n(cx+b/2)+' L'+n(cx+a*.2)+' '+n(t1)+' H'+n(cx+a/2)+' Z';
const right='M'+cx+' '+top+' V'+base+' H'+n(cx+c/2)+' L'+n(cx+b*.18)+' '+n(t2)+' H'+n(cx+b/2)+' L'+n(cx+a*.2)+' '+n(t1)+' H'+n(cx+a/2)+' Z';
return (trunk?'':'')+''}
/* a palm leaning by `lean`, fronds in two tones */
function lgxPalm(x,base,h,lean,light,dark){const tx=x+lean,ty=base-h,n=lgxN;let s='';
[[-1,-.25,.95],[-.8,.3,.8],[-.25,-.6,.7],[.3,-.6,.7],[.85,.3,.8],[1,-.2,.95]].forEach((f,i)=>{const ex=tx+f[0]*h*.42*f[2],ey=ty+f[1]*h*.3+h*.12,mx=tx+f[0]*h*.24,my=ty-h*.16+f[1]*h*.06;s+=''});return s}
/* a faceted peak: lit left face, shaded right face behind a broken ridge, snow cap in the same light */
function lgxPeak(ax,ay,lx,rx,by,lit,shade,snow,snowShade,cap){const h=by-ay,n=lgxN,r1=[ax+(rx-ax)*.10,ay+h*.30],r2=[ax-(ax-lx)*.10,ay+h*.55],r3=[ax+(rx-ax)*.16,ay+h*.78];
let s='';
if(cap){const cy=ay+h*cap,xl=ax-(ax-lx)*cap,xr=ax+(rx-ax)*cap,w=xr-xl;
s+='';
s+=''}
return s}
function lgxBack(kind,p){if(kind==='none')return '';if(LGX_BACK_MORE[kind])return LGX_BACK_MORE[kind](p);
if(kind==='mountains')return ''+lgxPeak(112,138,70,150,186,p.mid[0],p.mid[1],p.snow[0],p.snow[1],.30)+lgxPeak(188,138,150,230,186,p.mid[0],p.mid[1],p.snow[0],p.snow[1],.30)+lgxPeak(150,106,100,200,186,p.near[0],p.near[1],p.snow[0],p.snow[1],.28);
if(kind==='mesas'){const butte=(x,w,top,lit,shade)=>'';
return ''+butte(98,34,142,p.mid[0],p.mid[1])+butte(212,26,150,p.mid[0],p.mid[1])+butte(154,54,118,p.near[0],p.near[1])}
return ''}
function lgxGround(kind,p){let s='';if(LGX_GROUND_MORE[kind])return LGX_GROUND_MORE[kind](p);
if(kind==='lake'||kind==='beach'){s+='';
[[193,34],[200,26],[207,18],[214,11]].forEach(a=>{s+=''});
[[197,66,104],[204,186,228],[211,84,122],[218,172,206],[225,112,146]].forEach(a=>{s+=''});
if(kind==='lake')s+='';
else s+='';return s}
const c=kind==='desert'?p.sand:p.land;
return ''}
/* the feature sits front and centre, drawn with its base centre at (0,0) */
function lgxFeature(kind,p){if(kind==='none')return '';const body=p.rim,shade=sceneMix(p.rim,p.band,.28),glass=p.band,dark=p.shore,acc=p.accent;
const wheel=(x,r)=>'';
const win=(x,y,w,h)=>'';
const shadow=w=>'';if(LGX_FEATURE_MORE[kind])return LGX_FEATURE_MORE[kind](p,{body,shade,glass,dark,acc,shadow});
if(kind==='motorhome')return shadow(36)+''+win(6,-32,11,8)+win(21,-32,11,8)+''+wheel(14,5.6)+wheel(50,5.6)+'';
if(kind==='trailer')return shadow(32)+''+win(6,-30,15,9)+''+win(35.5,-28,6,6)+''+wheel(22,5.6)+'';
if(kind==='fifthwheel')return shadow(38)+''+win(6,-34,11,8)+win(21,-34,10,8)+win(59,-36,9,7)+''+wheel(16,5)+wheel(29,5)+'';
if(kind==='campfire')return ''+shadow(26)+'';
if(kind==='tent')return shadow(34)+'';
return shadow(34)+''}
function lgxStar(cx,cy,r){const pts=[];for(let i=0;i<10;i++){const rr=i%2===0?r:r*.42,a=(90+i*36)*Math.PI/180;pts.push(lgxN(cx+rr*Math.cos(a))+','+lgxN(cy-rr*Math.sin(a)))}return ''}
/* the largest letter size, up to `max`, that fits `text` in `width` */
function lgxFit(text,font,weight,max,width,track){let s=max;while(s>8&&profileMeasure(text,font,weight,s,track)>width)s-=.5;return s}
/* straight lettering held to a maximum width (so a long name or a different font never runs out of the frame) */
function lgxLine(x,y,text,size,track,fill,maxW,font,weight){const w=Math.min(profileMeasure(text,font,weight,size,track),maxW);return {w,svg:''+profileEsc(text)+''}}
/* ---- frames ---- */
/* ---- Logo tab controls ---- */
function lgxSelect(key,label,list,value){return ''}
function lgxStyleRowBase(lg){return '
';return row(LOGO_PLACE)+(m&&m.familyBrand?row(BRAND_PLACE):'')}
function profileLogoMoveStyle(lg){return lg.posX||lg.posY?' style="transform:translate('+lg.posX+'px,'+lg.posY+'px)"':''}
function profileBrandMoveStyle(lg){return lg.brandX||lg.brandY||lg.brandScale!==100?' style="transform:translate('+lg.brandX+'px,'+lg.brandY+'px) scale('+lg.brandScale/100+')"':''}
function profileLogoZoom(lg,html){return lg.posScale===100?html:'
'+html+'
'}
/* ---- Utility › Profile Layout: master templates ---- */
function profileTplRecords(kind){if(kind==='personnel')return profileSrc.peopleCache.personnel.filter(p=>p.status!=='inactive').sort((a,b)=>profileSrc.peopleFullName(a).localeCompare(profileSrc.peopleFullName(b))).map(p=>[p.id,profileSrc.peopleFullName(p)||'Personnel']);return kind==='family'?profileSrc.orgActive('family').sort((a,b)=>String(a.family_name||'').localeCompare(String(b.family_name||''))).map(r=>[r.id,r.family_name||'Family']):profileSrc.orgActive('resort').sort((a,b)=>String(a.resort_name||'').localeCompare(String(b.resort_name||''))).map(r=>[r.id,r.resort_name||'Resort'])}
function profileTplSlider(label,key,step=1,suffix='px'){const r=PROFILE_TEMPLATE_RANGES[key],v=profileTplDraft[key];return ''}
function profileTplMove(kind,key,i,total){return ''}
function profileTplOpen(key){return profileTplOpenGroups.has(key)?' open':''}
function profileTplControls(){const p=profileTplDraft,sel=(name,opts,val,attr)=>'';
const sections=p.sections.map((x,i)=>'
'}
function profileTplStatus(){const s=document.getElementById('profile-tpl-status');if(s)s.textContent=JSON.stringify(profileTplDraft)===JSON.stringify(profileTemplate(profileTplKind))?'Saved':'Unsaved changes'}
function profileTplPreview(){const host=document.getElementById('profile-tpl-preview');if(!host)return;const m=profileTplPreviewId?profileModel(profileTplKind,profileTplPreviewId):null;if(!m){host.innerHTML='
Add a '+profileEsc(PROFILE_KIND_LABELS[profileTplKind])+' to preview this template.
';const card=host.querySelector('[data-tpl-preview-card]');card.style.setProperty('--wpf-accent',profileSceneConfig(m,null).accent||'#2C8A86');profileTplApplyVariables(card,profileTplDraft);profileHydrateAssets(host).catch(console.error)}
function profileTplRender(){const kindSel=document.getElementById('profile-tpl-kind'),prev=document.getElementById('profile-tpl-preview-record'),recs=profileTplRecords(profileTplKind);if(kindSel)kindSel.value=profileTplKind;if(!recs.some(r=>String(r[0])===String(profileTplPreviewId)))profileTplPreviewId=recs[0]?.[0]||'';if(prev)prev.innerHTML=recs.length?recs.map(([id,n])=>'').join(''):'';const body=document.getElementById('profile-tpl-controls');if(body)body.innerHTML=profileTplControls();profileTplPreview();profileTplStatus()}
function profileTplChanged(redraw){profileTplDraft=profileTplNormalize(profileTplKind,profileTplDraft);if(redraw)profileTplRender();else{profileTplPreview();profileTplStatus()}}
async function profileTplEnter(){await profileSrc.orgLoad();await profileSrc.peopleLoad().catch(e=>console.error('Personnel & Roles could not load',e));if(!profileTplDraft)profileTplDraft=profileTemplate(profileTplKind);profileTplDraft=profileTplNormalize(profileTplKind,profileTplDraft);profileTplRender()}
function profileTplSave(){if(!profileSrc.pmxAllow('util-profile-layout','write'))return;profileTplDraft=profileTplNormalize(profileTplKind,profileTplDraft);profileKv().setKV('profile.template.'+profileTplKind,clone(profileTplDraft));profileTplStatus();if(profileCurrent&&profileCurrent.kind===profileTplKind)profileRenderCurrent();toast(PROFILE_KIND_LABELS[profileTplKind]+' master template saved')}
function profileTplBind(){const pane=document.querySelector('[data-utility-pane="profile-layout"]');if(!pane||pane.dataset.bound)return;pane.dataset.bound='1';
pane.addEventListener('change',e=>{const t=e.target,d=t.dataset;if(t.id==='profile-tpl-kind'){if(JSON.stringify(profileTplDraft)!==JSON.stringify(profileTemplate(profileTplKind))&&!confirm('Discard unsaved changes to the '+PROFILE_KIND_LABELS[profileTplKind]+' template?')){t.value=profileTplKind;return}profileTplKind=t.value;profileTplDraft=profileTemplate(profileTplKind);profileTplPreviewId='';profileTplRender();return}if(t.id==='profile-tpl-preview-record'){profileTplPreviewId=t.value;profileTplPreview();return}const p=profileTplDraft;
if(d.tplSelect){p[d.tplSelect]=Number(t.value);profileTplChanged(false)}else if(d.tplSectionVisible){const s=p.sections.find(x=>x.key===d.tplSectionVisible);if(s)s.visible=t.checked;profileTplChanged(false)}else if(d.tplSectionPosition){const s=p.sections.find(x=>x.key===d.tplSectionPosition);if(s)s.position=t.value;profileTplChanged(false)}else if(d.tplRibbon!==undefined){p.ribbonVisible=t.checked;profileTplChanged(false)}else if(d.tplItemPlacement){const x=p.items.find(i=>i.key===d.tplItemPlacement);if(x)x.placement=t.value;profileTplChanged(false)}});
pane.addEventListener('input',e=>{const t=e.target,d=t.dataset,p=profileTplDraft;if(!p)return;if(d.tplSlider){p[d.tplSlider]=Number(t.value);const out=pane.querySelector('[data-tpl-output="'+d.tplSlider+'"]');if(out)out.textContent=t.value+(d.tplSlider==='leftWidth'?'%':'px');profileTplChanged(false)}else if(d.tplSectionLabel){const s=p.sections.find(x=>x.key===d.tplSectionLabel);if(s)s.label=t.value;profileTplPreview();profileTplStatus()}else if(d.tplItemLabel){const x=p.items.find(i=>i.key===d.tplItemLabel);if(x)x.label=t.value;profileTplPreview();profileTplStatus()}});
pane.addEventListener('click',e=>{const mv=e.target.closest('[data-tpl-move]');if(mv){const rows=mv.dataset.tplMove==='section'?profileTplDraft.sections:profileTplDraft.items,i=rows.findIndex(x=>x.key===mv.dataset.tplKey),j=i+Number(mv.dataset.tplDir);if(i<0||j<0||j>=rows.length)return;[rows[i],rows[j]]=[rows[j],rows[i]];rows.forEach((x,n)=>x.order=n);profileTplChanged(true);return}if(e.target.closest('#profile-tpl-save')){profileTplSave();return}if(e.target.closest('#profile-tpl-reset')){if(!confirm('Reset the '+PROFILE_KIND_LABELS[profileTplKind]+' master template to the program defaults?'))return;profileTplDraft=profileTplNormalize(profileTplKind,null);profileTplRender()}});
pane.addEventListener('toggle',e=>{const g=e.target.closest?.('[data-tpl-group]');if(!g)return;if(g.open)profileTplOpenGroups.add(g.dataset.tplGroup);else profileTplOpenGroups.delete(g.dataset.tplGroup)},true)}
/* ===== Utility › Permissions & Sections =====
+ Add Section creates a menu: a Main on its own, a state of the program with its own menu. On a menu's card, + Add Menu Item adds a
menu item: a Main nested under the menu. A menu item opens its workspace, or links to another menu (⚙, Links To), or has options
nested under it (+ Sub), which are the tabs and drop-downs of its workspace. Parts are what is inside a workspace (cards, buttons);
they show here as they are built into the program. Each menu has a View permission with levels; see Permissions below.
Everything is kept in the database; nothing is deleted, a removed row is marked inactive. */
const PS_KINDS=[['main','Main'],['sub','Sub'],['part','Part']];
const ps={sections:[],perms:[],grants:[],settings:[],links:[],roles:[],access:[],rolesReady:false,shut:new Set(),open:null,loaded:false,focus:'',adding:null,role:'',addingRole:false,onlyOpen:''};
function psLive(r){return r&&r.active!==false}
function psById(id){return ps.sections.find(s=>s.id===id)||null}
function psKids(id){return ps.sections.filter(s=>psLive(s)&&(s.parent_id||null)===(id||null)).sort((a,b)=>(a.sort_order||0)-(b.sort_order||0)||String(a.name).localeCompare(String(b.name)))}
function psRoots(){const ids=new Set(ps.sections.filter(psLive).map(s=>s.id));return ps.sections.filter(s=>psLive(s)&&(!s.parent_id||!ids.has(s.parent_id))).sort((a,b)=>(a.sort_order||0)-(b.sort_order||0)||String(a.name).localeCompare(String(b.name)))}
function psDescendants(id){const out=[];const walk=p=>psKids(p).forEach(k=>{out.push(k);walk(k.id)});walk(id);return out}
function psPermsOf(id){const on=new Set(ps.grants.filter(g=>psLive(g)&&g.section_id===id).map(g=>g.permission_id));return ps.perms.filter(p=>psLive(p)&&on.has(p.id)).sort((a,b)=>(a.sort_order||0)-(b.sort_order||0))}
function psLinksOf(id){const mains=new Set(ps.sections.filter(s=>psLive(s)&&s.kind==='main').map(s=>s.id));return ps.links.filter(l=>psLive(l)&&l.section_id===id&&mains.has(l.main_id)).map(l=>l.main_id)}
function psSettingsOf(id){return ps.settings.filter(x=>psLive(x)&&x.section_id===id).sort((a,b)=>(a.sort_order||0)-(b.sort_order||0))}
async function psQuery(q){const {data,error}=await q;if(error)throw error;return data}
async function psLoad(){const [s,p,g,t,l]=await Promise.all([psQuery(sb.from('sections').select('*')),psQuery(sb.from('permissions').select('*')),psQuery(sb.from('section_permissions').select('*')),psQuery(sb.from('section_settings').select('*')),psQuery(sb.from('section_links').select('*'))]);
ps.sections=s||[];ps.perms=p||[];ps.grants=g||[];ps.settings=t||[];ps.links=l||[];ps.loaded=true;wnNamedPages();
/* the Roles and their access sheets (if their setup file isn't loaded yet, the rest still works) */
try{const [r,x]=await Promise.all([psQuery(sb.from('roles').select('*')),psQuery(sb.from('role_access').select('*'))]);ps.roles=r||[];ps.access=x||[];ps.rolesReady=true}catch(e){console.error(e);ps.roles=[];ps.access=[];ps.rolesReady=false}
/* the Dashboard cards and each Role's access to them (Ver. Beta 13 Rev. 1) */
try{const [dc,da]=await Promise.all([psQuery(sb.from('dashboard_cards').select('*')),psQuery(sb.from('dashboard_card_access').select('*'))]);ps.dashCards=dc||[];ps.cardAccess=da||[]}catch(e){console.error(e);ps.dashCards=[];ps.cardAccess=[]}
/* what each Role is, beyond the screens it opens: Rep (Ver. Beta 24 Rev. 1) */
try{ps.fns=(await psQuery(sb.from('role_functions').select('*')))||[]}catch(e){console.error(e);ps.fns=[]}if(typeof ravMemo!=='undefined')ravMemo.t=0}
async function psEnter(){try{await psLoad();if(typeof peopleLoad==='function')await peopleLoad().catch(e=>console.error('Personnel & Roles could not load',e))}catch(e){console.error(e);toast('Permissions & Sections could not be loaded: '+String(e&&e.message||e))}psRender();if(ps.open&&$('ps-sec-overlay').classList.contains('open'))psSecDraw()}
/* ---- the Visualizer ----
A menu (a Main on its own) has a card, with + Add Menu Item at the bottom of its list. A menu item (a Main nested under a menu)
is a row with its own Main / Sub / Part, ⚙ and + Sub; once it has options under it, it also has its own card, joined to its row.
Subs and Parts have their own cards. */
const PS_HEAD=34,PS_ROW=26,PS_GAP=12,PS_COLGAP=56;
let psMeasure=null;
function psTextWidth(t){if(!psMeasure)psMeasure=document.createElement('canvas').getContext('2d');psMeasure.font='700 13px '+getComputedStyle(document.body).fontFamily;return psMeasure.measureText(String(t||'')).width}
function psLineage(id){const out=new Set([id]);let p=psById(id);while(p&&p.parent_id){out.add(p.parent_id);p=psById(p.parent_id)}psDescendants(id).forEach(d=>out.add(d.id));return out}
function psIsMenu(n){return !!n&&psLive(n)&&n.kind==='main'&&!psLive(psById(n.parent_id))}
function psIsItem(n){return !!n&&psLive(n)&&n.kind==='main'&&psLive(psById(n.parent_id))}
function psHasCard(n){return !psIsItem(n)||psKids(n.id).length>0}
function psKindSwitch(n){return ''+PS_KINDS.map(([v,l])=>'').join('')+''}
function psGear(id){return ''}
function psAddInput(ph){return ''}
function psRender(){psRenderPerms();const stage=$('ps-stage');if(!stage)return;if(ps.focus&&!psLive(psById(ps.focus)||{active:false}))ps.focus='';const lin=ps.focus?psLineage(ps.focus):null,gh=id=>lin&&!lin.has(id)?' ps-ghost':'';
const roots=psRoots(),cols=[],placed=[];
const depthOf=(n,d)=>{(cols[d]=cols[d]||[]).push(n);if(!psShut(n.id))psKids(n.id).filter(psHasCard).forEach(k=>depthOf(k,d+1))};roots.forEach(r=>depthOf(r,0));
const rowW=k=>Math.ceil(psTextWidth(k.name))+60;
const widths=cols.map(list=>Math.max(220,...list.map(n=>Math.ceil(psTextWidth(n.name))+360),...list.flatMap(n=>psKids(n.id).map(rowW))));
const xs=[];widths.reduce((x,w,i)=>{xs[i]=x;return x+w+PS_COLGAP},16);
const rowsOf=n=>psShut(n.id)?0:psKids(n.id).length+(psIsMenu(n)?1:0);
const height=n=>PS_HEAD+(rowsOf(n)?rowsOf(n)*PS_ROW+6:2);
const lay=(n,d,y)=>{const kids=psKids(n.id).filter(psHasCard),h=height(n),hand=n.pos_x!=null&&n.pos_y!=null;placed.push({n,d,x:hand?Number(n.pos_x):xs[d],y:hand?Number(n.pos_y):y,w:widths[d],h});let end=y+h+PS_GAP;if(kids.length&&!psShut(n.id)){let cy=y;kids.forEach(k=>{cy=lay(k,d+1,cy)});end=Math.max(end,cy)}return end};
let y=16;roots.forEach(r=>{y=lay(r,0,y)+8});
const at=new Map(placed.map(p=>[p.n.id,p])),W=Math.max(...placed.map(p=>p.x+p.w),400)+40,H=Math.max(y,...placed.map(p=>p.y+p.h+40),200);
let lines='';placed.forEach(p=>{if(psShut(p.n.id))return;psKids(p.n.id).forEach((k,i)=>{const c=at.get(k.id);if(!c)return;const x1=p.x+p.w,y1=p.y+PS_HEAD+3+i*PS_ROW+PS_ROW/2,x2=c.x,y2=c.y+PS_HEAD/2,mx=(x1+x2)/2;
const g=gh(p.n.id)||gh(k.id);lines+=''})});
/* a link runs from the right side to a Main on the right, or down the left side to a Main that is not to the right */
const link=(p,y1,m,g)=>{let d;if(m.x>p.x+p.w){const x1=p.x+p.w,x2=m.x,y2=m.y+PS_HEAD/2,bend=Math.max(60,Math.abs(x2-x1)/3);d='M'+x1+','+y1+' C'+(x1+bend)+','+y1+' '+(x2-bend)+','+y2+' '+x2+','+y2;lines+=''}
else{const x1=p.x,x2=m.x,y2=m.y+PS_HEAD/2,cx=Math.max(0,Math.min(x1,x2)-40);d='M'+x1+','+y1+' C'+cx+','+y1+' '+cx+','+y2+' '+x2+','+y2;lines+=''}};
const rowAt=new Map();placed.forEach(p=>{if(psShut(p.n.id))return;psKids(p.n.id).forEach((k,i)=>{if(!at.has(k.id))rowAt.set(k.id,{x:p.x,y:p.y+3+i*PS_ROW+PS_ROW/2,w:p.w})})});
const target=mid=>at.get(mid)||rowAt.get(mid);
placed.forEach(p=>psLinksOf(p.n.id).forEach(mid=>{const m=target(mid);if(m)link(p,p.y+PS_HEAD/2,m,gh(p.n.id)||gh(mid))}));
/* a row with no card of its own: its link is drawn from its row */
placed.forEach(p=>{if(psShut(p.n.id))return;psKids(p.n.id).forEach((k,i)=>{if(at.has(k.id))return;psLinksOf(k.id).forEach(mid=>{const m=target(mid);if(m)link(p,p.y+PS_HEAD+3+i*PS_ROW+PS_ROW/2,m,gh(k.id)||gh(mid))})})});
stage.style.width=W+'px';stage.style.height=H+'px';const add=ps.adding;
stage.innerHTML=''+placed.map(p=>{const n=p.n,kids=psKids(n.id),shut=psShut(n.id),menu=psIsMenu(n);
const row=k=>'
'}).join('');
const inp=$('ps-add-input');if(inp)inp.focus()}
/* ---- + Add Menu Item and + Sub: type the name, Enter adds it (Escape, or leaving it empty, adds nothing) ---- */
function psAddStart(parent,kind){ps.adding={parent,kind};psCardFoldSet(parent,true);psRender()}
async function psAddFinish(save){const a=ps.adding,inp=$('ps-add-input');if(!a)return;const name=save&&inp?inp.value.trim().replace(/\s+/g,' '):'';ps.adding=null;
if(!name){psRender();return}
try{const {data,error}=await sb.from('sections').insert({name,kind:a.kind,parent_id:a.parent,sort_order:psNextOrder(a.parent)}).select().single();if(error)throw error;ps.sections.push(data);psRender();toast('Added '+name)}catch(e){wnFail(e);psRender()}}
/* ---- Permissions: every menu has a View permission (Backend View, Hub View, ...), added as soon as the menu exists and renamed with it.
A permission has levels (Permission 1, Permission 2, ...); each level checks what in that menu it sees. Everything is seen unless a level
unchecks it; anything new is seen wherever what it sits under is seen. Roles get a level of each permission once Personnel & Roles is built. ---- */
/* ===== Access by Role (Ver. Beta 8 Rev. 1) =====
The Role is what carries access. Each Role has an access sheet: one row per section of the program (the sections themselves,
as the visualizer draws them, so nothing has to be named piece by piece), with one pick per row: None (hidden), Read (opens,
nothing can be changed) or Read & Write. A menu's row is that menu's gate (Backend Access, Hub Access, ...). Everything starts
at None, so a Role only sees what it has been given. Picking on a row fills everything under it; the odd one is changed by hand.
Only For narrows a row to work states (and divisions, when Personnel & Roles is in); empty means everyone with the Role.
A menu item that links to another menu follows that menu's gate and has no row of its own.
People get their Role in Personnel & Roles (not built yet). Until then nobody is limited, and Preview As on the sheet shows the
program as a Role sees it. Tables: roles, role_access (setup file 20261013020000_roles_access.sql). */
const PS_ACCESS=[['none','None'],['read','Read'],['write','Read & Write']];
const PS_WORK_STATES=['In Person','Remote','Virtual'];
function psRoles(){return ps.roles.filter(psLive).sort((a,b)=>(a.sort_order||0)-(b.sort_order||0)||String(a.name).localeCompare(String(b.name)))}
function psRoleById(id){return ps.roles.find(r=>r.id===id)||null}
function psAccessRow(roleId,secId){return ps.access.find(a=>psLive(a)&&a.role_id===roleId&&a.section_id===secId)||null}
function psAccessOwn(roleId,secId,fit){const a=psAccessRow(roleId,secId);if(!a)return 'none';if(fit&&!psOnlyFits(a,fit))return 'none';return a.access}
/* the signed-in person's own work state and Divisions (their Primary Work Assignment) against a row's Only For; empty Only For fits everyone */
function psOnlyFits(a,fit){const ws=Array.isArray(a.only_work_states)?a.only_work_states:[],ds=(Array.isArray(a.only_divisions)?a.only_divisions:[]).map(String),fd=(fit.divisions||[]).map(String);if(ws.length&&!(fit.preview&&!fit.state)&&!ws.includes(fit.state))return false;if(ds.length&&!(fit.preview&&!fd.length)&&!ds.some(id=>fd.includes(id)||fd.some(f=>divFamily(f).has(String(id)))))return false;return true}
/* a Role's access to a section: its own row, limited by everything above it (a hidden parent hides what is under it; a Read parent
allows no more than Read below it); a link item takes the gate of the menu it links to */
function psAccessOf(roleId,secId,fit){const s=psById(secId);if(!s||!psLive(s))return 'none';const link=wnLinkTo(s);if(link)return psAccessOf(roleId,link,fit);
let level=psAccessOwn(roleId,secId,fit);let p=psById(s.parent_id);while(p&&psLive(p)){const up=psAccessOwn(roleId,p.id,fit);if(up==='none')return 'none';if(up==='read'&&level==='write')level='read';p=psById(p.parent_id)}return level}
function psAccessLabel(a){return (PS_ACCESS.find(x=>x[0]===a)||PS_ACCESS[0])[1]}
/* the sheet's rows: every menu (not the Badge) with everything under it, in order; a link item is shown but has no pick */
function psSheetRows(){const out=[];const walk=(id,d)=>psKids(id).forEach(k=>{out.push({s:k,d,link:wnLinkTo(k)});if(DASH_SECTIONS.includes(k.id))psDashCards().forEach(c=>out.push({card:c,s:k,d:d+1}));if(!wnLinkTo(k))walk(k.id,d+1)});
psRoots().filter(r=>r.kind==='main'&&(r.menu_style||'sidebar')!=='badge').forEach(M=>{out.push({s:M,d:0,menu:true});walk(M.id,1)});return out}
function psTip(id){const who=psRoles().map(r=>[r,psAccessOf(r.id,id)]).filter(x=>x[1]!=='none');return who.length?'
':''}
/* the pill on a visualizer row: the picked Role's access to it */
function psPill(id){if(!ps.role)return '';const a=psAccessOf(ps.role,id);return ''+(a==='write'?'R&W':a==='read'?'Read':'none')+''}
/* ---- the sheet ---- */
/* ---- Access by Role, condensed (Beta 25): only the rows the Role has (Read or Read & Write) show, with what they sit under; each menu
folds (kept for each person); the rows at None open from a row of their own under the menu, and close again. Functions folds the same way. ---- */
let psNoneOpen=new Set(),psOpened=null;
function psFoldList(key){const v=wnSettingGet(memberUserKey(key),[]);return new Set(Array.isArray(v)?v.map(String):[])}
function psFoldSave(key,set){wnSettingSet(memberUserKey(key),[...set])}
function psSheetFolded(id){return psFoldList('ps.sheetFold').has(String(id))}
function psSheetFold(id){const s=psFoldList('ps.sheetFold');id=String(id);if(s.has(id))s.delete(id);else s.add(id);psFoldSave('ps.sheetFold',s);psRenderPerms()}
function psSheetPicked(R,r){if(r.card)return psCardOwn(R.id,r.card.id,r.s.id)!=='none';if(r.link)return psAccessOf(R.id,r.s.id)!=='none';return psAccessOwn(R.id,r.s.id)!=='none'}
function psFoldBtn(id,folded){return ''}
function psSheetRowHtml(R,r,folded){if(r.card)return psCardRowHtml(R,r);const s=r.s,eff=psAccessOf(R.id,s.id),own=psAccessOwn(R.id,s.id),a=psAccessRow(R.id,s.id);
if(r.link){const T=psById(r.link);return '
'+escapeHtml(s.name)+'→ '+escapeHtml(T?T.name:'')+' · follows that menu
'+(r.menu?psFoldBtn(s.id,folded):'')+escapeHtml(s.name)+(r.menu?'menu · its gate':'')+(eff!==own&&own!=='none'?'shows as '+escapeHtml(psAccessLabel(eff))+' (limited above)':'')+'
'+picks+'
'+only+'
'}
function psPickHtml(attr,own,name){return ''}
function psSheetMoreHtml(id,n,open){return n?'
':''}
/* a row shows when the Role has it, or has something under it */
function psSheetBodyHtml(R,rows){let out='',i=0;
while(i{if(psSheetPicked(R,r))return true;for(let x=k+1;xr.d;x++)if(psSheetPicked(R,G[x]))return true;return false}),hid=keep.filter(v=>!v).length;
out+=G.filter((r,k)=>open||keep[k]).map(r=>psSheetRowHtml(R,r)).join('')+psSheetMoreHtml(M.s.id,hid,open)}
return out}
/* ---- the Visualizer, condensed: a card shows only its title bar until it is opened (kept for each person); a row is just its name, and
its Main / Sub / Part, ⚙, + Sub and access show beside it when the row is clicked ---- */
function psOpenedSet(){if(!psOpened)psOpened=psFoldList('ps.cardsOpen');return psOpened}
function psShut(id){return !psOpenedSet().has(String(id))}
function psCardFoldSet(id,open){if(!id)return;const s=psOpenedSet();id=String(id);if(open)s.add(id);else s.delete(id);psFoldSave('ps.cardsOpen',s)}
function psRenderPerms(){if(wn.menuReady)try{wnMenuRender()}catch(e){console.error(e)}const host=$('ps-perm-list');if(!host)return;const roles=psRoles();if(ps.role&&!roles.some(r=>r.id===ps.role))ps.role='';if(!ps.role&&roles.length)ps.role=roles[0].id;const R=psRoleById(ps.role);
const chips=roles.map(r=>'').join('')+(ps.addingRole?'':'');
const preview='';
let body='';
if(!R)body='
One pick per row: type N, R or W for None, Read or Read & Write (N or O for a Function) and Tab goes to the next row. A pick fills everything under it and raises the menus above it to the same pick (if they were lower); change the odd one by hand. Saves as you change it.
':'';
return chips+''+edit}
/* ---- saving ---- */
async function psRoleAdd(name){name=String(name||'').trim().replace(/\s+/g,' ');ps.addingRole=false;if(!name){psRenderPerms();return}
if(psRoles().some(r=>String(r.name).toLowerCase()===name.toLowerCase())){toast('There is already a Role named '+name);psRenderPerms();return}
try{const {data,error}=await sb.from('roles').insert({name,sort_order:Math.max(0,...ps.roles.map(r=>r.sort_order||0))+1}).select().single();if(error)throw error;ps.roles.push(data);ps.role=data.id;psRenderPerms();psRender();toast('Added '+name);peopleRolesChangedElsewhere()}catch(e){wnFail(e);psRenderPerms()}}
async function psRoleRename(name){const R=psRoleById(ps.role);if(!R)return;name=String(name||'').trim().replace(/\s+/g,' ');if(!name||name===R.name){psRenderPerms();return}
if(psRoles().some(r=>r.id!==R.id&&String(r.name).toLowerCase()===name.toLowerCase())){toast('There is already a Role named '+name);psRenderPerms();return}
R.name=name;psRenderPerms();psRender();try{await psSave('roles',{id:R.id},{name});peopleRolesChangedElsewhere()}catch(e){wnFail(e)}}
async function psRoleRemove(){const R=psRoleById(ps.role);if(!R)return;if(await wnAsk('Remove the Role '+R.name+'?')!=='yes')return;
try{await psSave('roles',{id:R.id},{active:false});R.active=false;if(wn.previewRole===R.id)wnPreviewSet('');ps.role='';psRenderPerms();psRender();peopleRolesChangedElsewhere()}catch(e){wnFail(e)}}
async function psAccessPut(roleId,secId,patch){let a=ps.access.find(x=>x.role_id===roleId&&x.section_id===secId);
if(a){Object.assign(a,patch,{active:true});await psSave('role_access',{id:a.id},Object.assign({},patch,{active:true}))}
else{const {data,error}=await sb.from('role_access').insert(Object.assign({role_id:roleId,section_id:secId},patch)).select().single();if(error)throw error;ps.access.push(data)}}
/* a pick on a row fills everything under it (links have no row of their own) */
async function psAccessSet(secId,access){const R=psRoleById(ps.role);if(!R)return;if(String(secId).startsWith('card:'))return psCardSet(secId,access);const ids=[secId,...psDescendants(secId).filter(d=>!wnLinkTo(d)&&!(access!=='none'&&(WN_BUILT[d.id]||{}).page==='tw-assign'&&psFnOn(R.id,'rep'))&&!(access!=='none'&&d.kind==='part'&&/^part:/.test(String(d.screen||'')))).map(d=>d.id)];
try{if(access!=='none'){let p=psById(psById(secId)?.parent_id);while(p){if(DASH_RANK[psAccessOwn(R.id,p.id)]DASH_SECTIONS.includes(x)))for(const c of psDashCards())if(psCardOwn(R.id,c.id,id)!==access)await psCardPut(R.id,c.id,id,{access});psRenderPerms();psRender();if(wn.previewRole===R.id)wnMenuRender()}catch(e){wnFail(e);psRenderPerms()}}
async function psOnlySet(secId,patch){const R=psRoleById(ps.role);if(!R)return;if(String(secId).startsWith('fn:')){try{await psFnPut(R.id,String(secId).slice(3),patch);psRenderPerms()}catch(e){wnFail(e)}return}if(String(secId).startsWith('card:')){const [,c,sid]=String(secId).split(':');try{await psCardPut(R.id,c,sid,patch);psRenderPerms()}catch(e){wnFail(e)}return}try{await psAccessPut(R.id,secId,patch);psRenderPerms()}catch(e){wnFail(e)}}
/* ---- the sheet's events (bound by psBind) ---- */
function psPermsBind(){const host=$('ps-perm-list');if(!host)return;
host.addEventListener('click',e=>{const t=e.target;let b;
if((b=t.closest('[data-ps-role]'))){ps.role=b.dataset.psRole;ps.onlyOpen='';psNoneOpen.clear();psRenderPerms();psRender();return}
if((b=t.closest('[data-ps-fold]'))){psSheetFold(b.dataset.psFold);return}
if((b=t.closest('[data-ps-none]'))){const id=String(b.dataset.psNone);if(psNoneOpen.has(id))psNoneOpen.delete(id);else psNoneOpen.add(id);psRenderPerms();return}
if(t.closest('#ps-role-add')){ps.addingRole=true;psRenderPerms();return}
if(t.closest('#ps-role-remove')){psRoleRemove();return}
if((b=t.closest('[data-ps-only-open]'))){ps.onlyOpen=b.dataset.psOnlyOpen;psRenderPerms();return}
if((b=t.closest('[data-ps-only-done]'))){ps.onlyOpen='';psRenderPerms();return}});
host.addEventListener('change',e=>{const t=e.target;
if(t.dataset.psAccess){if(!t.checked)t.checked=true;psAccessSet(t.dataset.psAccess,t.value);return}
if(t.dataset.psFn){if(!t.checked)t.checked=true;psFnSet(t.dataset.psFn,t.value==='on');return}
if(t.dataset.psOnly){const states=[...host.querySelectorAll('[data-ps-only="'+CSS.escape(t.dataset.psOnly)+'"]:checked')].map(x=>x.value);psOnlySet(t.dataset.psOnly,{only_work_states:states});return}
if(t.dataset.psOnlyDiv){const divs=[...host.querySelectorAll('[data-ps-only-div="'+CSS.escape(t.dataset.psOnlyDiv)+'"]:checked')].map(x=>x.value);psOnlySet(t.dataset.psOnlyDiv,{only_divisions:divs});return}
if(t.id==='ps-preview'){wnPreviewSet(t.value);return}
if(t.id==='ps-role-name'){psRoleRename(t.value);return}});
host.addEventListener('keydown',e=>{if(e.target.id==='ps-role-input'){if(e.key==='Enter'){e.preventDefault();psRoleAdd(e.target.value)}else if(e.key==='Escape'){e.preventDefault();ps.addingRole=false;psRenderPerms()}}
if(e.target.id==='ps-role-name'&&e.key==='Enter'){e.preventDefault();e.target.blur()}});
host.addEventListener('focusout',e=>{if(e.target.id==='ps-role-input'&&ps.addingRole)psRoleAdd(e.target.value)})}
/* ===== What the signed-in person can see and do =====
wnRole() is the Role in force: the one being previewed (Preview As on the access sheet), else the signed-in person's own Role: the
(Only For narrows a row to work states and Divisions; it is checked against the signed-in person's Primary Work Assignment, not in a preview.)
Role of their Primary Work Assignment in Personnel & Roles, matched by Employee Email at sign-in (wnOwnRoleLoad). With no Role in
force (not on file, or no Role yet) nothing is limited. The menus, tabs, links and Badge entries draw only what the
Role can see; a Read section opens with its controls locked; pmxAllow(key, 'write') is what every screen asks before a change. */
function wnRole(){return wn.previewRole||wn.ownRole||''}
function wnAccess(secId){const r=wnRole();return r?psAccessOf(r,secId,wn.previewRole?(wn.previewFit||null):wn.ownFit):'write'}
function wnCanSee(s){return !!s&&wnAccess(s.id)!=='none'}
function wnPreviewSet(roleId){const was=wn.previewRole||'';wn.previewRole=roleId||'';if(!wn.previewRole)wn.previewFit=null;const bar=$('wn-preview');if(bar){const R=psRoleById(wn.previewRole);bar.hidden=!R;if(R)$('wn-preview-name').textContent=R.name}
const sel=$('ps-preview');if(sel&&sel.value!==wn.previewRole)sel.value=wn.previewRole;
wnPreviewBarDraw();
if(wn.previewRole&&!was){wn.prePreview={view:wn.view,sub:wn.sub};const H=wnPreviewHub();if(H)wnViewSet(H.id);else{wnShowPage('none');wnMenuRender()}}
else if(!wn.previewRole&&was&&wn.prePreview){const p=wn.prePreview;wn.prePreview=null;if(p.view&&wnIsState(psById(p.view)))wnViewSet(p.view);if(p.sub&&psById(p.sub))wnOpen(p.sub);else wnMenuRender()}
else if(wn.previewRole&&!wnReach(wn.sub)){const M=wnMain();if(M)wnViewSet(M.id);else{wnShowPage('none');wnMenuRender()}}else wnMenuRender();wnApplyRead();if(dashOnScreen())dashRender();scheduleScreenRender();if(window.twDraw)twDraw();{const cs=psById(wn.sub),cb=cs?WN_BUILT[cs.id]||{}:{};if(cb.page==='tw-assigned'&&!twOnPickList()&&cs.parent_id)wnOpen(cs.parent_id)}}
/* the section a screen belongs to: the open tab's section (Utility's tabs and a section's tabs alike) */
function wnSectionOnScreen(){const s=psById(wn.sub),b=s?WN_BUILT[s.id]||{}:{};if(!s)return null;
if(b.page==='utility'){const key=utilityTabNow();const id=Object.keys(WN_BUILT).find(k=>WN_BUILT[k].pane===key);return id?psById(id):s}
if(b.page)return s;const key=wn.secTab;const id=key.startsWith('sec-')?key.slice(4):Object.keys(WN_BUILT).find(k=>WN_BUILT[k].pane===key&&psById(k)?.parent_id===s.id)||Object.keys(WN_BUILT).find(k=>WN_BUILT[k].pane===key);return id?psById(id):s}
function pmxAllow(key,mode,quiet){const s=wnSectionOnScreen();const a=s?wnAccess(s.id):'write';if(a==='write'||(mode!=='write'&&a==='read'))return true;if(!quiet)toast(a==='none'?'Not permitted':'Read only');return false}
/* a Read section: its controls are locked (data-wn-read on the pane) */
function wnApplyRead(){const s=wnSectionOnScreen();const read=!!s&&wnAccess(s.id)==='read';document.querySelectorAll('#page-utility .utility-pane.active,#page-sec .utility-pane.active').forEach(p=>{if(read)p.dataset.wnRead='1';else delete p.dataset.wnRead})}
/* ---- saving ---- */
async function psSave(table,match,patch){const q=sb.from(table).update(Object.assign({},patch,{updated_at:new Date().toISOString()}));Object.entries(match).forEach(([k,v])=>q.eq(k,v));const {error}=await q;if(error)throw error}
async function psSetKind(id,kind){const s=psById(id);if(!s||s.kind===kind)return;s.kind=kind;psRender();try{await psSave('sections',{id},{kind})}catch(e){wnFail(e)}}
function psNextOrder(parent){return Math.max(0,...ps.sections.filter(s=>(s.parent_id||null)===(parent||null)).map(s=>s.sort_order||0))+1}
/* ---- Add Section: creates a menu (a Main on its own) ---- */
function psUnderOptions(sel,skip){const block=new Set(skip?[skip,...psDescendants(skip).map(d=>d.id)]:[]);const out=[''];
const walk=(list,d)=>list.forEach(n=>{if(block.has(n.id))return;out.push('');walk(psKids(n.id),d+1)});walk(psRoots(),0);return out.join('')}
/* Opens: the screen a section shows. A built-in section's screen is fixed and only shown. */
function psOpensField(s,id){const b=s&&WN_BUILT[s.id];if(b&&!b.named){const lab=b.page?((WN_SCREENS.find(x=>x[0]==='page:'+b.page)||[])[1]||b.page.replace(/^./,c=>c.toUpperCase())):b.signout?'Sign Out':b.log?'Update Log':'';return ''}
const cur=s?String(s.screen||''):'',kept=s?wnScreenOf(s):null,now=cur&&cur!=='-'?cur:kept===null&&s&&WN_BUILT[s.id]&&WN_BUILT[s.id].named?(WN_BUILT[s.id].page?'page:'+WN_BUILT[s.id].page:''):'';
return ''}
function psAddKindFor(parent){const p=psById(parent);return p&&psLive(p)?'sub':'main'}
function psAddDraw(parent){const kind=psAddKindFor(parent);$('ps-add-more').innerHTML=''
+''+psOpensField(null,'ps-add-screen')+'
'+psAlsoHtml(parent)+'
'}
function psAddOpen(under){const parent=typeof under==='string'?under:'';$('ps-add-name').value='';psAddDraw(parent);$('ps-add-overlay').dataset.wnDirty='';openOverlay('ps-add-overlay');setTimeout(()=>$('ps-add-name').focus(),0)}
async function psAddSave(){const name=$('ps-add-name').value.trim().replace(/\s+/g,' ');if(!name){toast('Give the section a name');$('ps-add-name').focus();return false}
const parent_id=($('ps-add-under')&&$('ps-add-under').value)||null,kind=($('ps-add-kind')&&$('ps-add-kind').value)||'main',screen=($('ps-add-screen')&&$('ps-add-screen').value)||'-';
try{const {data,error}=await sb.from('sections').insert({name,kind,parent_id,screen,sort_order:psNextOrder(parent_id)}).select().single();if(error)throw error;ps.sections.push(data);const also=[...document.querySelectorAll('#ps-add-also [data-ps-also]:checked')].map(x=>x.value);
if(also.length){await psSave('sections',{id:data.id},{twin_of:data.id});data.twin_of=data.id;for(const mid of also){const r2=await sb.from('sections').insert({name,kind,parent_id:mid,screen,twin_of:data.id,sort_order:psNextOrder(mid)}).select().single();if(r2.error)throw r2.error;ps.sections.push(r2.data)}}
if(parent_id)psCardFoldSet(parent_id,true);wnNamedPages();psRender();wnMenuRender();$('ps-add-overlay').dataset.wnDirty='';closeOverlay('ps-add-overlay');toast('Added '+name);return true}catch(e){wnFail(e);return false}}
/* ---- the section's popout: name, where it sits, its permissions and its settings (saved as they change) ---- */
/* Links To: each menu, with its menu items listed under it (a Main inside a Sub is listed under the menu item it sits in) */
function psLinkChoices(skip){const out=[];const walk=(id,d)=>psKids(id).forEach(k=>{if(k.kind==='main'){if(k.id!==skip)out.push([k,d]);walk(k.id,d+1)}else walk(k.id,d)});
psRoots().filter(r=>r.kind==='main').forEach(M=>{if(M.id!==skip)out.push([M,0]);walk(M.id,1)});return out}
function psSecOpen(id){ps.open=id;psSecDraw();openOverlay('ps-sec-overlay')}
function psSecDraw(){const s=psById(ps.open);if(!s)return;
$('ps-sec-title').textContent=s.name+' — Settings';
$('ps-sec-body').innerHTML='
'+
''}
async function psSecChange(t){const s=psById(ps.open);if(!s)return;
try{if(t.id==='ps-sec-screen'){const screen=t.value||'-';if(screen===String(s.screen||''))return;s.screen=screen;[s,...psTwins(s)].forEach(x=>{if(WN_BUILT[x.id]&&WN_BUILT[x.id].named)delete WN_BUILT[x.id]});await psTwinSync(s,{screen});wnNamedPages();psRender();wnMenuRender();await psSave('sections',{id:s.id},{screen});return}
if(t.id==='ps-sec-name'){const name=t.value.trim().replace(/\s+/g,' ');if(!name||name===s.name){t.value=s.name;return}s.name=name;$('ps-sec-title').textContent=name+' — Settings';psRender();await psSave('sections',{id:s.id},{name});await psTwinSync(s,{name});psRender();wnMenuRender();return}
if(t.id==='ps-sec-under'){const parent_id=t.value||null;if(parent_id===(s.parent_id||null))return;s.parent_id=parent_id;s.sort_order=psNextOrder(parent_id);if(parent_id)psCardFoldSet(parent_id,true);psRender();await psSave('sections',{id:s.id},{parent_id,sort_order:s.sort_order});return}
if(t.dataset.psGrant){const permission_id=t.dataset.psGrant,active=t.checked;const {error}=await sb.from('section_permissions').upsert({section_id:s.id,permission_id,active,updated_at:new Date().toISOString()},{onConflict:'section_id,permission_id'});if(error)throw error;
const g=ps.grants.find(x=>x.section_id===s.id&&x.permission_id===permission_id);if(g)g.active=active;else ps.grants.push({section_id:s.id,permission_id,active});psRender();return}
if(t.dataset.psStyle&&t.checked){s.menu_style=t.dataset.psStyle;psRender();psSecDraw();await psSave('sections',{id:s.id},{menu_style:s.menu_style});return}
if(t.hasAttribute('data-ps-hubpush')){s.push_to_hub=t.checked;psRender();await psSave('sections',{id:s.id},{push_to_hub:s.push_to_hub});return}
if(t.hasAttribute('data-ps-bottom')){s.at_bottom=t.checked;psRender();await psSave('sections',{id:s.id},{at_bottom:s.at_bottom});return}
if(t.hasAttribute('data-ps-first')){const others=t.checked?ps.sections.filter(x=>x.opens_first&&x.id!==s.id):[];s.opens_first=t.checked;others.forEach(x=>x.opens_first=false);psRender();
for(const x of others)await psSave('sections',{id:x.id},{opens_first:false});await psSave('sections',{id:s.id},{opens_first:s.opens_first});return}
if(t.dataset.psLink){const main_id=t.dataset.psLink,active=t.checked;const {error}=await sb.from('section_links').upsert({section_id:s.id,main_id,active,updated_at:new Date().toISOString()},{onConflict:'section_id,main_id'});if(error)throw error;
const l=ps.links.find(x=>x.section_id===s.id&&x.main_id===main_id);if(l)l.active=active;else ps.links.push({section_id:s.id,main_id,active});psRender()}}
catch(e){wnFail(e)}}
async function psSetAdd(){const s=psById(ps.open),box=$('ps-set-new');if(!s||!box)return;const name=box.value.trim().replace(/\s+/g,' ');if(!name){box.focus();return}
try{const {data,error}=await sb.from('section_settings').insert({section_id:s.id,name,sort_order:Math.max(0,...psSettingsOf(s.id).map(x=>x.sort_order||0))+1}).select().single();if(error)throw error;ps.settings.push(data);psSecDraw();$('ps-set-new')?.focus()}catch(e){wnFail(e)}}
async function psSetRemove(id){const x=ps.settings.find(r=>r.id===id);if(!x)return;try{await psSave('section_settings',{id},{active:false});x.active=false;psSecDraw()}catch(e){wnFail(e)}}
async function psSecRemove(){const s=psById(ps.open);if(!s)return;const under=psDescendants(s.id);
if(await wnAsk('Remove '+s.name+(under.length?' and everything under it':'')+'?')!=='yes')return;
try{for(const r of [s,...under]){await psSave('sections',{id:r.id},{active:false});r.active=false}ps.open=null;$('ps-sec-overlay').dataset.wnDirty='';closeOverlay('ps-sec-overlay');psRender();toast('Removed '+s.name)}catch(e){wnFail(e)}}
/* ---- Export / Import: the whole layout, as a file ---- */
const PS_FILE_TYPE='waypoint-permissions-sections';
const PS_COLS={sections:['id','parent_id','name','kind','stage','menu_style','at_bottom','opens_first','push_to_hub','hub_source','pos_x','pos_y','sort_order','active'],section_links:['section_id','main_id','active'],permissions:['id','name','section_id','sort_order','active'],roles:['id','name','sort_order','active'],role_access:['id','role_id','section_id','access','only_work_states','only_divisions','active'],dashboard_card_access:['id','role_id','card_id','section_id','access','only_work_states','only_divisions','active'],section_permissions:['section_id','permission_id','active'],section_settings:['id','section_id','name','sort_order','active']};
function psPick(row,cols){const o={};cols.forEach(c=>{if(row[c]!==undefined)o[c]=row[c]});return o}
async function psExport(){try{await psLoad()}catch(e){wnFail(e);return}
const name=id=>(psById(id)||{}).name||'',pname=id=>(ps.perms.find(p=>p.id===id)||{}).name||'';
const tree=list=>list.map(n=>({name:n.name,kind:n.kind,showsAs:n.kind==='main'?({sidebar:'Sidebar',topbar:'Topbar',badge:'Badge'})[n.menu_style||'sidebar']:undefined,opensFirst:n.opens_first?true:undefined,pushToHub:n.push_to_hub?true:undefined,bottom:n.at_bottom?true:undefined,seenBy:psRoles().map(r=>[r.name,psAccessOf(r.id,n.id)]).filter(x=>x[1]!=='none').map(x=>x[0]+' · '+psAccessLabel(x[1])),linksTo:psLinksOf(n.id).map(name),settings:psSettingsOf(n.id).map(x=>x.name),under:tree(psKids(n.id))}));
const out={type:PS_FILE_TYPE,formatVersion:2,waypointVersion:WN_VERSION,exportedAt:new Date().toISOString(),exportedBy:(wnUser&&wnUser.email)||'',
layout:tree(psRoots()),roles:psRoles().map(r=>r.name),
sections:ps.sections.map(r=>psPick(r,PS_COLS.sections)),permission_rows:ps.perms.map(r=>psPick(r,PS_COLS.permissions)),section_permissions:ps.grants.map(r=>psPick(r,PS_COLS.section_permissions)),section_settings:ps.settings.map(r=>psPick(r,PS_COLS.section_settings)),section_links:ps.links.map(r=>psPick(r,PS_COLS.section_links)),role_rows:ps.roles.map(r=>psPick(r,PS_COLS.roles)),role_access:ps.access.map(r=>psPick(r,PS_COLS.role_access)),dashboard_card_access:(ps.cardAccess||[]).map(r=>psPick(r,PS_COLS.dashboard_card_access))};
downloadText('Waypoint.PermissionsSections.json',JSON.stringify(out,null,2),'application/json');toast('Permissions & Sections exported')}
async function psImport(file){let d;try{d=JSON.parse(await file.text())}catch(e){toast('That file could not be read');return}
if(!d||d.type!==PS_FILE_TYPE||!Array.isArray(d.sections)){toast('That file is not a Permissions & Sections export');return}
if(await wnAsk('Import the sections, roles and settings in '+file.name+'?')!=='yes')return;
const now=new Date().toISOString(),rows=(list,cols)=>(Array.isArray(list)?list:[]).map(r=>Object.assign(psPick(r,cols),{updated_at:now}));
try{const up=async(table,list,cols,conflict)=>{const r=rows(list,cols);if(!r.length)return;const {error}=await sb.from(table).upsert(r,conflict?{onConflict:conflict}:{});if(error)throw error};
await up('sections',d.sections,PS_COLS.sections);await up('permissions',d.permission_rows,PS_COLS.permissions);await up('roles',d.role_rows,PS_COLS.roles);await up('role_access',d.role_access,PS_COLS.role_access);try{await up('dashboard_card_access',d.dashboard_card_access,PS_COLS.dashboard_card_access)}catch(e){console.error(e);toast('The Dashboard card access was not imported: '+String(e.message||e))}
await up('section_permissions',d.section_permissions,PS_COLS.section_permissions,'section_id,permission_id');await up('section_settings',d.section_settings,PS_COLS.section_settings);await up('section_links',d.section_links,PS_COLS.section_links,'section_id,main_id');
await psLoad();psRender();toast('Permissions & Sections imported')}catch(e){wnFail(e)}}
/* ---- Arrange and Reorganize: drag a card by ⠿ to place it (drop it on another card to put it under that card);
drag a row by ⠿ to change the order. Each drop is saved. ---- */
let psDrag=null;
function psDropCard(x,y,skip){const block=new Set([skip,...psDescendants(skip).map(d=>d.id)]);for(const el of document.elementsFromPoint(x,y)){const c=el.closest&&el.closest('.ps-card');if(c&&!block.has(c.dataset.psCard))return c}return null}
function psDragStart(e){const g=e.target.closest('[data-ps-drag]'),r=e.target.closest('.ps-row-grip');if(!g&&!r)return;e.preventDefault();
const cv=document.querySelector('.ps-canvas');
if(g){const card=g.closest('.ps-card');psDrag={kind:'card',id:g.dataset.psDrag,el:card,x0:e.clientX,y0:e.clientY,l0:parseFloat(card.style.left),t0:parseFloat(card.style.top),cv,sx0:cv.scrollLeft,sy0:cv.scrollTop,moved:false};card.classList.add('ps-dragging');psAutoScroll()}
else{const row=r.closest('.ps-row'),list=row.parentElement;psDrag={kind:'row',id:row.dataset.psRow,el:row,list,parent:list.dataset.psRows,y0:e.clientY,moved:false};row.classList.add('ps-dragging')}
document.addEventListener('pointermove',psDragMove);document.addEventListener('pointerup',psDragEnd,{once:true})}
function psDragMove(e){const d=psDrag;if(!d)return;
if(d.kind==='card'){d.cx=e.clientX;d.cy=e.clientY;psDragPlace();
return}
if(Math.abs(e.clientY-d.y0)>3)d.moved=true;const rows=[...d.list.querySelectorAll('.ps-row[data-ps-row]')];d.list.querySelectorAll('.ps-row').forEach(x=>x.classList.remove('ps-ins-above','ps-ins-below'));
let idx=rows.length;for(let i=0;i3)d.moved=true;
d.el.style.left=Math.max(0,d.l0+dx)+'px';d.el.style.top=Math.max(0,d.t0+dy)+'px';const stage=$('ps-stage');stage.style.width=Math.max(stage.offsetWidth,d.l0+dx+d.el.offsetWidth+40)+'px';stage.style.height=Math.max(stage.offsetHeight,d.t0+dy+d.el.offsetHeight+40)+'px';
document.querySelectorAll('.ps-card.ps-drop').forEach(c=>c.classList.remove('ps-drop'));const t=psDropCard(d.cx,d.cy,d.id);if(t&&t!==d.el)t.classList.add('ps-drop');d.target=t&&t!==d.el?t.dataset.psCard:''}
function psAutoScroll(){const d=psDrag;if(!d||d.kind!=='card')return;if(d.cx!=null){const r=d.cv.getBoundingClientRect(),E=40,sp=14;let vx=0,vy=0;if(d.cxr.right-E)vx=sp;if(d.cyr.bottom-E)vy=sp;
if(vx||vy){d.cv.scrollLeft+=vx;d.cv.scrollTop+=vy;psDragPlace()}}requestAnimationFrame(psAutoScroll)}
async function psDragEnd(){document.removeEventListener('pointermove',psDragMove);const d=psDrag;psDrag=null;if(!d)return;document.querySelectorAll('.ps-drop,.ps-dragging,.ps-ins-above,.ps-ins-below').forEach(x=>x.classList.remove('ps-drop','ps-dragging','ps-ins-above','ps-ins-below'));
if(!d.moved){psRender();return}const s=psById(d.id);if(!s)return;
try{if(d.kind==='card'){if(d.target){const parent_id=d.target;s.parent_id=parent_id;s.sort_order=psNextOrder(parent_id);s.pos_x=null;s.pos_y=null;psCardFoldSet(parent_id,true);psRender();await psSave('sections',{id:s.id},{parent_id,sort_order:s.sort_order,pos_x:null,pos_y:null});toast(s.name+' is now under '+(psById(parent_id)||{}).name)}
else{s.pos_x=Math.round(parseFloat(d.el.style.left));s.pos_y=Math.round(parseFloat(d.el.style.top));psRender();await psSave('sections',{id:s.id},{pos_x:s.pos_x,pos_y:s.pos_y})}return}
const sibs=psKids(d.parent).filter(k=>k.id!==s.id);const before=psKids(d.parent).findIndex(k=>k.id===s.id);let at=d.index==null?before:d.index;if(at>before)at--;sibs.splice(Math.max(0,Math.min(at,sibs.length)),0,s);
const changes=[];sibs.forEach((k,i)=>{if(k.sort_order!==i+1){k.sort_order=i+1;changes.push(k)}});psRender();for(const k of changes)await psSave('sections',{id:k.id},{sort_order:k.sort_order});await psTwinPlace(s)}
catch(e){wnFail(e);try{await psLoad()}catch(_){}psRender()}}
function psBind(){const pane=document.querySelector('[data-utility-pane="permsec"]');if(!pane)return;
$('ps-add').addEventListener('click',()=>psAddOpen());$('ps-stage').addEventListener('click',e=>{const b=e.target.closest('[data-ps-addhere]');if(b){e.stopPropagation();psAddOpen(b.dataset.psAddhere)}},true);
$('ps-add-overlay').addEventListener('change',e=>{if(e.target.id==='ps-add-under'){const k=$('ps-add-kind');if(k)k.value=psAddKindFor(e.target.value);const al=$('ps-add-also');if(al)al.innerHTML=psAlsoHtml(e.target.value)}});$('ps-export').addEventListener('click',psExport);$('ps-import').addEventListener('click',()=>$('ps-import-file').click());
$('ps-import-file').addEventListener('change',e=>{const f=e.target.files&&e.target.files[0];e.target.value='';if(f)psImport(f)});
$('ps-stage').addEventListener('pointerdown',psDragStart);
document.querySelector('.ps-canvas').addEventListener('click',e=>{if(!e.target.closest('.ps-card')&&(ps.focus||ps.rowOn)){ps.focus='';ps.rowOn='';psRender()}});
$('ps-stage').addEventListener('click',e=>{const rw=e.target.closest('.ps-row[data-ps-row]');if(rw&&!e.target.closest('button,input,select,.ps-grip,.ps-row-tools')){const id=rw.dataset.psRow;ps.rowOn=ps.rowOn===id?'':id;psRender();return}
const f=e.target.closest('[data-ps-focus]');if(f){ps.focus=f.dataset.psFocus;psRender();return}
const c=e.target.closest('[data-ps-col]');if(c){const id=c.dataset.psCol;psCardFoldSet(id,psShut(id));psRender();return}
const k=e.target.closest('[data-ps-kind]');if(k){psSetKind(k.dataset.psId,k.dataset.psKind);return}
const g=e.target.closest('[data-ps-gear]');if(g){psSecOpen(g.dataset.psGear);return}
const ai=e.target.closest('[data-ps-additem]');if(ai){psAddStart(ai.dataset.psAdditem,'main');return}
const as=e.target.closest('[data-ps-addsub]');if(as)psAddStart(as.dataset.psAddsub,'sub')});
$('ps-stage').addEventListener('keydown',e=>{if(e.target.id!=='ps-add-input')return;if(e.key==='Enter'){e.preventDefault();psAddFinish(true)}else if(e.key==='Escape'){e.preventDefault();e.stopPropagation();psAddFinish(false)}});
$('ps-stage').addEventListener('focusout',e=>{if(e.target.id==='ps-add-input')psAddFinish(true)});
psPermsBind();
$('ps-add-overlay').wnSave=psAddSave;$('ps-add-save').addEventListener('click',psAddSave);$('ps-add-name').addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();psAddSave()}});
const body=$('ps-sec-body');body.addEventListener('change',e=>{if(e.target.id==='ps-set-new')return;psSecChange(e.target)});
body.addEventListener('click',e=>{if(e.target.closest('#ps-set-add')){psSetAdd();return}const r=e.target.closest('[data-ps-set-remove]');if(r)psSetRemove(r.dataset.psSetRemove)});
body.addEventListener('keydown',e=>{if(e.key==='Enter'&&e.target.id==='ps-set-new'){e.preventDefault();psSetAdd()}});
$('ps-sec-remove').addEventListener('click',psSecRemove)}
/* ===== The program's menus come from the Visualizer (Beta 5) =====
Mains are the menus. A Main shown as a Sidebar or a Topbar is a state of the program; its Subs are its menu (a Sub with Bottom checked
sits at the bottom of the sidebar). A Main shown as the Badge is the menu behind your initials; a state shows the Badges it links to.
Moving between states is by links only: a Sub or Part linked to a state switches to it. A Sub opens its page, where its Parts are tabs
(a Part with Parts under it is a drop-down of them). Screens already built show in their sections; a new section opens an empty page.
The program opens on the Main marked Opens First. Every change on the Visualizer shows in the menus right away. */
const WN_START_MAIN='8cc3c79b-84f3-48c6-ae03-4f7107b1b548';
const WN_BADGE='c32728bd-5c26-4045-b97c-6595c8f45ea5',WN_SIGNOUT='e87fcdb2-d66d-48de-a59c-67bfa8295244',WN_UTILITY='888c7f70-c9f4-4ebb-a4ab-f29d1bc48a35',WN_PERMSEC='10ffd536-077b-425e-bd1c-befd51df2b3e';
const WN_BUILT={'888c7f70-c9f4-4ebb-a4ab-f29d1bc48a35':{page:'utility',icon:'nav-icon-stylekit'},'e87fcdb2-d66d-48de-a59c-67bfa8295244':{signout:true},'7ae8098b-3975-42c8-9e13-4d049df8c5fb':{page:'settings',icon:'nav-icon-settings'},'04b99ace-0b4b-4249-8360-80487b303ac0':{page:'docubuild'},'5b287142-0924-43db-9427-38d4f9257217':{page:'dashboard'},'f9b5c241-2531-4f5c-a48f-f7fc600e600b':{page:'dashboard'},'0b414aea-e42f-446b-bd57-9a3a4ad2ebb7':{page:'schedule'},'25c4739c-c736-40d4-b3fa-88ce47cacdae':{page:'schedule'},'27f0d19d-c957-4e83-af53-06612640663e':{log:true,icon:'nav-icon-updatelog'},
'10ffd536-077b-425e-bd1c-befd51df2b3e':{pane:'permsec'},'04918376-dff5-4f00-b08c-06ca8c92bba0':{pane:'stylekit'},'c930a1bc-dc97-4def-b84b-2df1c82d3086':{pane:'worksheet-naming'},'5737e5cc-70f8-42ed-a5c7-585e66aba083':{pane:'text-tidy'},
'043ef761-1695-432f-af45-2bcac13f4b0d':{pane:'fonts'},'304fe7ec-3dea-49dd-ae18-b096842ab686':{pane:'tour-parser'},'401140f2-fb90-42d5-bd2d-cef5672fe3a7':{pane:'worksheet-parser'},'4b57516b-b0a9-40db-9940-89eef8c30547':{pane:'profile-layout'},'fee504a0-ad5d-4321-8c59-5f5c76e0e597':{pane:'org-family'},'4bc45e19-4563-4649-bbf7-cb92f20b8b14':{pane:'org-resort'},'b1214fdf-c60c-4418-bc32-ec48aff1b638':{pane:'org-infrastructure'},'b8cc2ded-a00a-425d-a1d4-38bd0a82097c':{pane:'people-departments'},'4a76e831-862a-4bdb-bf5a-1a0eb043edb5':{pane:'people-divisions'},'819aa135-cabb-4463-a2a6-11ce95013fe9':{pane:'people-roles'},'b236215c-4a3d-4ac4-9b57-50068056ed5f':{pane:'people-personnel'}};
/* The program as it was set up, used only if the sections can't be read from the database. */
const WN_SEED_SECTIONS=[{"id": "8cc3c79b-84f3-48c6-ae03-4f7107b1b548", "parent_id": null, "name": "Menu", "kind": "main", "sort_order": 1, "stage": "live", "active": true, "menu_style": "sidebar", "placement": "menu", "pos_x": null, "pos_y": null, "opens_first": true, "at_bottom": false}, {"id": "888c7f70-c9f4-4ebb-a4ab-f29d1bc48a35", "parent_id": "8cc3c79b-84f3-48c6-ae03-4f7107b1b548", "name": "Utility", "kind": "main", "sort_order": 1, "stage": "live", "active": true, "menu_style": "sidebar", "placement": "menu", "pos_x": null, "pos_y": null, "opens_first": false, "at_bottom": false}, {"id": "7ae8098b-3975-42c8-9e13-4d049df8c5fb", "parent_id": "8cc3c79b-84f3-48c6-ae03-4f7107b1b548", "name": "Settings", "kind": "main", "sort_order": 2, "stage": "live", "active": true, "menu_style": "sidebar", "placement": "topright", "pos_x": null, "pos_y": null, "opens_first": false, "at_bottom": false}, {"id": "27f0d19d-c957-4e83-af53-06612640663e", "parent_id": "8cc3c79b-84f3-48c6-ae03-4f7107b1b548", "name": "Update Log", "kind": "main", "sort_order": 3, "stage": "live", "active": true, "menu_style": "sidebar", "placement": "bottom", "pos_x": null, "pos_y": null, "opens_first": false, "at_bottom": true}, {"id": "10ffd536-077b-425e-bd1c-befd51df2b3e", "parent_id": "888c7f70-c9f4-4ebb-a4ab-f29d1bc48a35", "name": "Permissions & Sections", "kind": "sub", "sort_order": 1, "stage": "live", "active": true, "menu_style": "sidebar", "placement": "menu", "pos_x": null, "pos_y": null, "opens_first": false, "at_bottom": false}, {"id": "04918376-dff5-4f00-b08c-06ca8c92bba0", "parent_id": "888c7f70-c9f4-4ebb-a4ab-f29d1bc48a35", "name": "Style Kit", "kind": "sub", "sort_order": 2, "stage": "live", "active": true, "menu_style": "sidebar", "placement": "menu", "pos_x": null, "pos_y": null, "opens_first": false, "at_bottom": false}, {"id": "c930a1bc-dc97-4def-b84b-2df1c82d3086", "parent_id": "888c7f70-c9f4-4ebb-a4ab-f29d1bc48a35", "name": "Document Settings", "kind": "sub", "sort_order": 3, "stage": "live", "active": true, "menu_style": "sidebar", "placement": "menu", "pos_x": null, "pos_y": null, "opens_first": false, "at_bottom": false}, {"id": "5737e5cc-70f8-42ed-a5c7-585e66aba083", "parent_id": "888c7f70-c9f4-4ebb-a4ab-f29d1bc48a35", "name": "Text Tidy", "kind": "sub", "sort_order": 4, "stage": "live", "active": true, "menu_style": "sidebar", "placement": "menu", "pos_x": null, "pos_y": null, "opens_first": false, "at_bottom": false}, {"id": "043ef761-1695-432f-af45-2bcac13f4b0d", "parent_id": "888c7f70-c9f4-4ebb-a4ab-f29d1bc48a35", "name": "Fonts", "kind": "sub", "sort_order": 5, "stage": "live", "active": true, "menu_style": "sidebar", "placement": "menu", "pos_x": null, "pos_y": null, "opens_first": false, "at_bottom": false}, {"id": "2033ab71-a850-403d-af99-48bc3d6c5a74", "parent_id": "888c7f70-c9f4-4ebb-a4ab-f29d1bc48a35", "name": "Parsers", "kind": "sub", "sort_order": 6, "stage": "live", "active": true, "menu_style": "sidebar", "placement": "menu", "pos_x": null, "pos_y": null, "opens_first": false, "at_bottom": false}, {"id": "7f77a4d6-d825-491a-96b8-cad9d4e3459e", "parent_id": "04918376-dff5-4f00-b08c-06ca8c92bba0", "name": "Theme & Display", "kind": "sub", "sort_order": 1, "stage": "live", "active": true, "menu_style": "sidebar", "placement": "menu", "pos_x": null, "pos_y": null, "opens_first": false, "at_bottom": false}, {"id": "3ee5a685-f332-4004-bdb9-acf6bdb40ecb", "parent_id": "04918376-dff5-4f00-b08c-06ca8c92bba0", "name": "Formats", "kind": "sub", "sort_order": 2, "stage": "live", "active": true, "menu_style": "sidebar", "placement": "menu", "pos_x": null, "pos_y": null, "opens_first": false, "at_bottom": false}, {"id": "f02fa4a1-3d5e-4918-a914-858e9c23bdfc", "parent_id": "04918376-dff5-4f00-b08c-06ca8c92bba0", "name": "Buttons", "kind": "sub", "sort_order": 3, "stage": "live", "active": true, "menu_style": "sidebar", "placement": "menu", "pos_x": null, "pos_y": null, "opens_first": false, "at_bottom": false}, {"id": "2b59225b-a0b8-4bb3-a4f8-21ee0aa7062f", "parent_id": "04918376-dff5-4f00-b08c-06ca8c92bba0", "name": "States", "kind": "sub", "sort_order": 4, "stage": "live", "active": true, "menu_style": "sidebar", "placement": "menu", "pos_x": null, "pos_y": null, "opens_first": false, "at_bottom": false}, {"id": "7b449515-bf3f-4533-adf2-28b40d8db4d4", "parent_id": "04918376-dff5-4f00-b08c-06ca8c92bba0", "name": "Components", "kind": "sub", "sort_order": 5, "stage": "live", "active": true, "menu_style": "sidebar", "placement": "menu", "pos_x": null, "pos_y": null, "opens_first": false, "at_bottom": false}, {"id": "304fe7ec-3dea-49dd-ae18-b096842ab686", "parent_id": "2033ab71-a850-403d-af99-48bc3d6c5a74", "name": "Tour Parser", "kind": "sub", "sort_order": 1, "stage": "live", "active": true, "menu_style": "sidebar", "placement": "menu", "pos_x": null, "pos_y": null, "opens_first": false, "at_bottom": false}, {"id": "401140f2-fb90-42d5-bd2d-cef5672fe3a7", "parent_id": "2033ab71-a850-403d-af99-48bc3d6c5a74", "name": "Worksheet Parser", "kind": "sub", "sort_order": 2, "stage": "live", "active": true, "menu_style": "sidebar", "placement": "menu", "pos_x": null, "pos_y": null, "opens_first": false, "at_bottom": false}];
const wn={view:'',sub:'',secTab:'',menuReady:false,previewRole:'',ownRole:'',ownFit:null,ownPerson:''};
/* A menu (a Main on its own) is a state of the program. A Main nested under it is a menu item; a Sub is an option (a tab) in a workspace;
Parts are what is inside a workspace and are not on the menus. */
function wnIsState(s){return !!s&&psLive(s)&&s.kind==='main'&&(s.menu_style||'sidebar')!=='badge'&&!psLive(psById(s.parent_id))&&wnCanSee(s)}
function wnStates(){return ps.sections.filter(wnIsState).sort((a,b)=>(a.sort_order||0)-(b.sort_order||0)||String(a.name).localeCompare(String(b.name)))}
function wnMain(){const st=wnStates();return st.find(s=>s.id===wn.view)||st.find(s=>s.opens_first)||st[0]||null}
/* A section's link to a state (a link to a Badge is not a switch). */
function wnLinkTo(s){return s?psLinksOf(s.id).find(id=>wnIsState(psById(id)))||'':''}
function wnEntries(id){return psKids(id).filter(k=>k.kind!=='part'&&wnCanSee(k)&&wnLinkOk(k))}
function wnBadgesOf(m){return psLinksOf(m.id).map(psById).filter(b=>b&&psLive(b)&&b.kind==='main'&&b.menu_style==='badge')}
/* Sign Out and Settings in the Badge are always there */
function wnBadgeEntries(b){return psKids(b.id).filter(k=>k.kind!=='part'&&(WN_BUILT[k.id]&&(WN_BUILT[k.id].signout||WN_BUILT[k.id].page==='settings')||wnCanSee(k)))}
function wnIconId(s){return (WN_BUILT[s.id]&&WN_BUILT[s.id].icon)||('nav-icon-sec-'+s.id)}
function wnMenuRender(){if(!ps.sections.length)return;wnNamedPages();const M=wnMain(),top=!!M&&M.menu_style==='topbar';document.body.classList.toggle('wn-topbar',top);wn.view=M?M.id:'';
const subs=M?wnEntries(M.id):[],main=subs.filter(s=>!s.at_bottom),bottom=subs.filter(s=>s.at_bottom),on=s=>s.id===wn.sub||wnUnderPage(s)?' active':'';
const navBtn=(s,cls)=>'';
$('nav-scroll').innerHTML=(M?'
').join('');
try{renderIcons()}catch(e){console.error(e)}
const cur=psById(wn.sub);if(cur){$('section-indicator-text').textContent=cur.name;const b=WN_BUILT[cur.id];if(b&&b.page==='utility')wnTabsRender($('utility-tabs'),$('utility-stage'),cur.id,'utility');else if(!b||!b.page)wnTabsRender($('sec-tabs'),$('sec-stage'),cur.id,'sec')}
try{wnPushButtons()}catch(e){console.error(e)}try{wnSwitchRender();wnSearchAccess()}catch(e){console.error(e)}}
/* A Sub's Parts as tabs; a Part with Parts under it (and no screen of its own) is a drop-down of them. */
function wnTabKey(p){const b=WN_BUILT[p.id];return b&&b.pane?b.pane:'sec-'+p.id}
function wnTabsRender(bar,stage,subId,kind){if(!bar||!stage)return;const parts=wnEntries(subId),keys=[];const cur=kind==='utility'?utilityTabNow():wn.secTab;
const tab=(p,cls)=>{const k=wnTabKey(p);keys.push(k);const link=wnLinkTo(p);return ''};
bar.innerHTML=parts.map(p=>{const kids=wnEntries(p.id),b=WN_BUILT[p.id];if(kids.length&&!(b&&b.pane)){const any=kids.some(k=>wnTabKey(k)===cur);return '
'}return tab(p,'utility-tab')}).join('');
keys.forEach(k=>{if(!stage.querySelector('[data-utility-pane="'+k+'"]')){const sec=document.createElement('section');sec.className='utility-pane';sec.dataset.utilityPane=k;stage.appendChild(sec)}});return keys}
/* A screen built into a section's tab is drawn the first time the tab opens (Organization › Family, Resort, Infrastructure; Personnel & Roles › Departments, Divisions, Roles, Personnel). */
const WN_SEC_BUILDERS={'org-family':pane=>orgRenderPane('family',pane),'org-resort':pane=>orgRenderPane('resort',pane),'org-infrastructure':pane=>infraRenderPane(pane),'people-departments':pane=>peopleRenderPane('department',pane),'people-divisions':pane=>peopleRenderPane('division',pane),'people-roles':pane=>peopleRenderPane('role',pane),'people-personnel':pane=>peopleRenderPane('personnel',pane),'mship-matrix':pane=>mshipRenderPane('matrix',pane),'mship-frontline':pane=>mshipRenderPane('frontline',pane),'mship-upgrade':pane=>mshipRenderPane('upgrade',pane),'mship-inhouse':pane=>mshipRenderPane('inhouse',pane),'np-time-clock':pane=>{pane.innerHTML=''},'np-roster':pane=>{pane.innerHTML=''},'np-roster-settings':pane=>{pane.innerHTML=''},'np-rep-availability':pane=>{pane.innerHTML=''},'np-wheel':pane=>{pane.innerHTML=''},'np-wheel-admin':pane=>{pane.innerHTML=''},'np-wheel-settings':pane=>{pane.innerHTML=''},'np-inventory-audit':pane=>{pane.innerHTML=''},'np-inventory':pane=>{pane.innerHTML=''},'np-time-date':pane=>{pane.innerHTML=''},'np-notifications':pane=>{pane.innerHTML=''}};
/* a tab read again each time it is shown (what someone else saved shows) */
const WN_SEC_ENTER={'mship-matrix':mshipEnter,'mship-frontline':mshipEnter,'mship-upgrade':mshipEnter,'mship-inhouse':mshipEnter,'np-time-clock':pane=>tclkPageEnter(pane.querySelector('.tclk')),'np-roster':pane=>rstrTabEnter(pane.querySelector('.rstr')),'np-roster-settings':pane=>rstrTabEnter(pane.querySelector('.rstr')),'np-rep-availability':pane=>ravPageEnter(pane.querySelector('.rav')),'np-wheel':pane=>whlPageEnter(pane.querySelector('.rav'),'view'),'np-wheel-admin':pane=>whlPageEnter(pane.querySelector('.rav'),'admin'),'np-wheel-settings':pane=>whlPageEnter(pane.querySelector('.rav'),'settings'),'np-inventory-audit':pane=>invMount(pane.querySelector('.inv'),'setup'),'np-inventory':pane=>invMount(pane.querySelector('.inv'),'admin'),'np-time-date':pane=>tdPageEnter(pane.querySelector('.td')),'np-notifications':pane=>tdnPageEnter(pane.querySelector('.tdn'))};
function wnSecBuild(page,key){const pane=page.querySelector('.utility-pane[data-utility-pane="'+key+'"]'),build=WN_SEC_BUILDERS[key],enter=WN_SEC_ENTER[key];if(pane&&build&&!pane.dataset.built){pane.dataset.built='1';try{build(pane)}catch(e){console.error(e)}}if(pane&&enter)try{enter(pane)}catch(e){console.error(e)}}
function wnSecTab(key){wn.secTab=key;const page=$('page-sec'),bar=$('sec-tabs');bar.querySelectorAll('.utility-tab').forEach(b=>b.classList.toggle('active',b.dataset.utilityTab===key));bar.querySelectorAll('.utility-tour-option').forEach(b=>b.classList.toggle('active',b.dataset.utilityTab===key));
bar.querySelectorAll('.section-menu').forEach(m=>{m.classList.toggle('active',!!m.querySelector('[data-utility-tab="'+key+'"]'));m.classList.remove('open')});page.querySelectorAll('.utility-pane').forEach(p=>p.classList.toggle('active',p.dataset.utilityPane===key));wnSecBuild(page,key);wnApplyRead();wnPlaceSave()}
function wnShowPage(id){document.querySelectorAll('#main .page').forEach(p=>p.classList.toggle('active',p.id==='page-'+id));$('utility-tabs').classList.toggle('wn-off',id!=='utility');$('sec-tabs').classList.toggle('wn-off',id!=='sec');closeDropdowns()}
/* Open a section from a menu: a link switches to that Main; Update Log opens its popout; anything else opens its page. */
/* screens that open from a section by its name (the section can sit anywhere in the menus) */
/* the screens that also open as a tab when their section sits under a Sub */
const WN_TAB_PAGES={'time-clock':'np-time-clock','roster':'np-roster','roster-settings':'np-roster-settings','rep-availability':'np-rep-availability','wheel':'np-wheel','wheel-admin':'np-wheel-admin','wheel-settings':'np-wheel-settings','inventory-audit':'np-inventory-audit','inventory':'np-inventory','time-date':'np-time-date','notifications':'np-notifications'};
const WN_NAMED_PAGES={'member accounts':'members','test & recall':'members','test and recall':'members','roster':'roster','roster settings':'roster-settings','time clock':'time-clock','rep availability':'rep-availability','the wheel':'wheel','wheel':'wheel','wheel admin':'wheel-admin','the wheel admin':'wheel-admin','wheel settings':'wheel-settings','full wheel':'tw-wheel','assignment view':'tw-assign','assigned view':'tw-assigned','the wheel settings':'wheel-settings','inventory audit':'inventory-audit','inventory':'inventory','timeclock':'time-clock','time/date settings':'time-date','time date settings':'time-date','time & date settings':'time-date','time and date settings':'time-date','time/date':'time-date','notifications':'notifications'};
const WN_NAMED_PANES={'upgrade matrix':'mship-matrix','frontline':'mship-frontline','upgrade':'mship-upgrade','upgrades':'mship-upgrade','inhouse':'mship-inhouse','in-house':'mship-inhouse','in house':'mship-inhouse','ih':'mship-inhouse'},WN_NAMED_PANE_PARENTS=new Set(['membership','memberships']);
function wnNamedKey(v){return String(v||'').trim().toLowerCase().replace(/\s+/g,' ')}
/* The screens a section can open (its Settings › Opens). A section keeps the screen it opens in sections.screen, so renaming it keeps it;
one with nothing kept yet is matched once by its name and then remembered. A dash means it opens nothing on its own. */
const WN_SCREENS=[['page:dashboard','Dashboard'],['page:schedule','Tour Schedule'],['page:members','Member Accounts'],['page:docubuild','Docubuild'],['page:roster','Roster'],['page:roster-settings','Roster Settings'],['page:time-clock','Time Clock'],['page:time-date','Time/Date Settings'],['page:notifications','Notifications'],['page:rep-availability','Rep Availability'],['page:wheel','The Wheel'],['page:wheel-admin','Wheel Admin'],['page:wheel-settings','Wheel Settings'],['page:tw-wheel','Full Wheel'],['page:tw-assign','Assignment View'],['page:tw-assigned','Assigned View'],['page:inventory-audit','Inventory Audit'],['page:inventory','Inventory'],['pane:mship-matrix','Memberships › Upgrade Matrix'],['pane:mship-frontline','Memberships › Frontline'],['pane:mship-upgrade','Memberships › Upgrade'],['pane:mship-inhouse','Memberships › InHouse']];
const wnScreenSaving=new Set();
function wnScreenOf(x){const sc=String(x&&x.screen||'');if(sc==='-')return {page:'',pane:''};if(sc.startsWith('pane:'))return {page:'',pane:sc.slice(5)};if(sc.startsWith('page:'))return {page:sc.slice(5),pane:''};return null}
function wnScreenRemember(x,key){if(!key||String(x.screen||'')||wnScreenSaving.has(x.id))return;wnScreenSaving.add(x.id);x.screen=key;sb.from('sections').update({screen:key}).eq('id',x.id).then(({error})=>{wnScreenSaving.delete(x.id);if(error){x.screen='';console.warn('The section could not remember its screen (setup file not applied yet?)',error.message)}})}
function wnNamedPages(){(ps.sections||[]).forEach(x=>{if(WN_BUILT[x.id]&&!WN_BUILT[x.id].named)return;const kept=wnScreenOf(x),par=psById(x.parent_id);let page,pane;
if(kept){page=kept.page;pane=kept.pane}else{page=WN_NAMED_PAGES[wnNamedKey(x.name)]||(wnNamedKey(x.name)==='tour parser'&&par&&wnNamedKey(par.name)==='training'?'training-tour-parser':'');pane=par&&WN_NAMED_PANE_PARENTS.has(wnNamedKey(par.name))?WN_NAMED_PANES[wnNamedKey(x.name)]||'':'';if(page||pane)wnScreenRemember(x,page?'page:'+page:'pane:'+pane)}
const tab=page&&WN_TAB_PAGES[page]&&par&&psLive(psById(par.parent_id))?WN_TAB_PAGES[page]:'';if(tab)WN_BUILT[x.id]={pane:tab,named:true};else if(page)WN_BUILT[x.id]={page,named:true};else if(pane)WN_BUILT[x.id]={pane,named:true};else if(WN_BUILT[x.id]&&WN_BUILT[x.id].named)delete WN_BUILT[x.id]})}
/* Ver. Beta 34 Rev. 5: a person is on the Rep pick list (a Work Assignment with a Rep Role); while previewing, the previewed Role */
function twOnPickList(){if(wn.previewRole)return psFnOn(wn.previewRole,'rep',wn.previewFit||null);if(!wn.ownRole)return true;if(!(peopleCache.personnel||[]).length)return wnFnOk('rep');return repPeople().some(x=>String(x.p.id)===String(wn.ownPerson))}
function wnOpen(id,tab){wnNamedPages();const s=psById(id);if(!s)return;const b=WN_BUILT[s.id]||{};
/* a menu item whose tabs open their own screens (the Tour Schedule) opens its first tab the person may see, so each tab's access is the one in force */
if(b.page&&b.page!=='utility'&&!wnLinkTo(s)&&psKids(s.id).some(k=>k.kind!=='part'&&(WN_BUILT[k.id]||{}).page===b.page)){const kids=wnPageKids(s);if(kids.length){wnOpen(kids[0].id,tab);return}closeDropdowns();wn.sub=s.id;$('section-indicator-text').textContent=s.name;wnShowPage('none');wnMenuRender();wnPlaceSave();return}
if(b.page==='tw-assigned'&&!twOnPickList()){const p=psById(s.parent_id),kids=p?wnPageKids(p):[];if(kids.length){wnOpen(kids[0].id);return}}closeDropdowns();if(b.signout){signOut();return}if(b.page==='settings'){wnSettingsPanel();return}const link=wnLinkTo(s);if(link){wnViewSet(link);return}if(b.log){openLog();return}
wn.sub=s.id;$('section-indicator-text').textContent=s.name;
if(b.page){wnShowPage(b.page);if(b.page==='docubuild')docPageEnter().catch(console.error);if(b.page==='dashboard')dashPageEnter().catch(console.error);if(b.page==='schedule')scheduleScreenEnter().catch(console.error);if(b.page==='members')membersEnter().catch(console.error);if(b.page==='roster')rstrPageEnter('view');if(b.page==='roster-settings')rstrPageEnter('build');if(b.page==='time-clock')tclkPageEnter();if(b.page==='rep-availability')ravPageEnter();if(b.page==='wheel')whlPageEnter(null,'view');if(b.page==='wheel-admin')whlPageEnter(null,'admin');if(b.page==='wheel-settings')whlPageEnter(null,'settings');if(b.page==='tw-wheel')twEnter('wheel');if(b.page==='tw-assign')twEnter('assign');if(b.page==='tw-assigned')twEnter('assigned');if(b.page==='inventory-audit')invMount($('inv-audit-host'),'setup');if(b.page==='inventory')invMount($('inv-hub-host'),'admin');if(b.page==='time-date')tdPageEnter();if(b.page==='gift-docs')gaxPageEnter();if(b.page==='notifications')tdnPageEnter();if(b.page==='utility'){const keys=wnTabsRender($('utility-tabs'),$('utility-stage'),s.id,'utility')||[];const want=tab&&keys.includes(tab)?tab:keys.includes(utilityTabNow())?utilityTabNow():keys[0];if(want)setUtilityTab(want)}}
else{wnShowPage('sec');const keys=wnTabsRender($('sec-tabs'),$('sec-stage'),s.id,'sec')||[];$('sec-stage').querySelectorAll('.utility-pane').forEach(p=>{if(!keys.includes(p.dataset.utilityPane))p.remove()});const want=tab&&keys.includes(tab)?tab:keys.includes(wn.secTab)?wn.secTab:keys[0]||'';wnSecTab(want)}
wnMenuRender();wnPlaceSave()}
/* Switch the program to a Main: its menu, and the first thing on it. */
function wnViewSet(mainId){const M=psById(mainId);if(!wnIsState(M))return;wn.view=M.id;wn.sub='';wnMenuRender();const first=wnEntries(M.id).find(s=>!s.at_bottom&&!wnLinkTo(s)&&!(WN_BUILT[s.id]||{}).log&&!(WN_BUILT[s.id]||{}).signout);
if(first)wnOpen(first.id);else{wnShowPage('none');$('section-indicator-text').textContent=M.name;wnPlaceSave()}}
/* Compatibility: a built page by its name (Reset Style Kit goes back to Utility). */
function showPage(page){const id=Object.keys(WN_BUILT).find(k=>WN_BUILT[k].page===page);if(id)wnOpen(id)}
/* Where you are (view, section and tab) is kept in the address, so a refresh comes back to the same place. */
function wnPlaceSave(){const s=psById(wn.sub),b=s?WN_BUILT[s.id]||{}:{};const tab=!s?'':b.page==='utility'?utilityTabNow():b.page?'':wn.secTab;history.replaceState(null,'','#'+[wn.view,wn.sub,tab].filter((x,i,a)=>x||a.slice(i+1).some(Boolean)).join('/'))}
/* Sign-in opens the Main marked Opens First; a refresh comes back to the same place. If no state can reach Permissions & Sections,
it opens directly, so the layout can always be fixed. */
function wnReach(id){let p=psById(id);if(!p||!psLive(p)||!wnCanSee(p))return false;while(p){if(wnIsState(p))return true;p=psById(p.parent_id);if(p&&(!psLive(p)||!wnCanSee(p)))return false}return false}
function wnMenuStart(fresh){const [view,sub,tab]=fresh?[]:decodeURIComponent(location.hash.slice(1)).split('/');wn.menuReady=true;wn.view='';wn.sub='';
const s=psById(sub);if(view&&wnIsState(psById(view)))wn.view=view;wnMenuRender();
if(s&&psLive(s)&&wnReach(s.id))wnOpen(s.id,tab);else{const M=wnMain();if(M)wnViewSet(M.id);else{wnShowPage('none');wnMenuRender()}}
if(!wnReachByLayout(WN_PERMSEC)&&psLive(psById(WN_UTILITY)||{active:false})&&psLive(psById(WN_PERMSEC)||{active:false})&&wn.sub!==WN_UTILITY)wnOpen(WN_UTILITY,'permsec')}
/* Ver. Beta 38 Rev. 7: the safety catch opens Permissions & Sections only when no menu reaches it at all (the layout itself is broken),
never because the person signing in isn't allowed in (that opened it for every limited Role at sign-in). */
function wnReachByLayout(id){const keep=[wn.previewRole,wn.ownRole,wn.ownFit];wn.previewRole='';wn.ownRole='';wn.ownFit=null;try{return wnReach(id)}finally{[wn.previewRole,wn.ownRole,wn.ownFit]=keep}}
/* ===== Push to Hub and Update (Beta 5 Rev 4) =====
A menu with Push to Hub checked (Hub Test) mirrors into Hub. Inside the program, in that menu, each menu item shows Push to Hub: it sends
the whole item with everything under it, so Hub's copy matches it. Each tab (Sub) shows Update: it sends only that tab. What is sent is the setup (name,
Main / Sub / Part, order, Bottom, settings, what each level sees, and links), never data. Each copy in Hub keeps hub_source, the item it came
from, so the next push or update lands on the same copy. A Part no longer in the Sub is marked removed in Hub, never deleted. */
function wnPushMain(){const M=psById(wn.view);return M&&wnIsState(M)&&M.push_to_hub?M:null}
function wnHubOf(src){return ps.sections.filter(s=>wnIsState(s)&&!s.parent_id&&s.id!==src.id&&String(s.name).trim().toLowerCase()==='hub').sort((a,b)=>(a.sort_order||0)-(b.sort_order||0))[0]||null}
function wnCopyOf(id){return ps.sections.find(s=>psLive(s)&&s.hub_source===id)||null}
/* the Parts under a Sub (or Part) that go with it: not the Mains listed there, which are menu items linking to other states */
function wnPushTree(id){const out=[];const walk=p=>psKids(p).forEach(k=>{if(k.kind==='main')return;out.push(k);walk(k.id)});walk(id);return out}
async function wnCopyOne(src,parentId,T,H){const now=new Date().toISOString(),fields={name:src.name,kind:src.kind,sort_order:src.sort_order||0,at_bottom:!!src.at_bottom,parent_id:parentId,updated_at:now};
let c=wnCopyOf(src.id);
if(c){const {error}=await sb.from('sections').update(fields).eq('id',c.id);if(error)throw error;Object.assign(c,fields)}
else{const {data,error}=await sb.from('sections').insert(Object.assign({hub_source:src.id},fields)).select().single();if(error)throw error;c=data;ps.sections.push(c)}
/* settings: the same names, in the same order; any the item no longer has are marked removed */
const want=psSettingsOf(src.id),have=ps.settings.filter(x=>x.section_id===c.id);
for(const w of want){const h=have.find(x=>x.name===w.name);if(h){if(!psLive(h)||h.sort_order!==w.sort_order){await psSave('section_settings',{id:h.id},{active:true,sort_order:w.sort_order});h.active=true;h.sort_order=w.sort_order}}
else{const {data,error}=await sb.from('section_settings').insert({section_id:c.id,name:w.name,sort_order:w.sort_order}).select().single();if(error)throw error;ps.settings.push(data)}}
for(const h of have)if(psLive(h)&&!want.some(w=>w.name===h.name)){await psSave('section_settings',{id:h.id},{active:false});h.active=false}
/* permissions and links: the same as the item's (a link to Hub Test becomes a link to Hub) */
/* access: each Role's row for the item is copied to its Hub copy */
for(const R of psRoles()){const a=psAccessRow(R.id,src.id);if(!a&&!psAccessRow(R.id,c.id))continue;await psAccessPut(R.id,c.id,{access:a?a.access:'none',only_work_states:a?a.only_work_states||[]:[],only_divisions:a?a.only_divisions||[]:[]})}
const links=new Set(psLinksOf(src.id).map(m=>m===T.id?H.id:m).filter(m=>m!==c.id)),lRows=[...new Set([...links,...ps.links.filter(l=>l.section_id===c.id).map(l=>l.main_id)])].map(main_id=>({section_id:c.id,main_id,active:links.has(main_id),updated_at:now}));
if(lRows.length){const {error}=await sb.from('section_links').upsert(lRows,{onConflict:'section_id,main_id'});if(error)throw error;lRows.forEach(r=>{const l=ps.links.find(x=>x.section_id===r.section_id&&x.main_id===r.main_id);if(l)l.active=r.active;else ps.links.push(r)})}
return c}
async function wnPushSub(id){const T=wnPushMain(),S=psById(id);if(!T||!S)return;const H=wnHubOf(T);if(!H){toast('There is no Hub to push to');return}
if(await wnAsk('Push '+S.name+' to Hub?')!=='yes')return;
try{await psLoad();const T2=psById(T.id),S2=psById(id),H2=psById(H.id);const top=await wnCopyOne(S2,H2.id,T2,H2);
const tree=wnPushTree(S2.id);for(const p of tree){const parent=p.parent_id===S2.id?top:wnCopyOf(p.parent_id);await wnCopyOne(p,parent.id,T2,H2)}
const keep=new Set(tree.map(p=>p.id)),gone=[];const walk=pid=>psKids(pid).forEach(k=>{if(k.kind==='main')return;if(k.hub_source&&!keep.has(k.hub_source))gone.push(k);walk(k.id)});walk(top.id);
for(const g of gone){await psSave('sections',{id:g.id},{active:false});g.active=false}
psRender();toast(S2.name+' is pushed to Hub')}catch(e){wnFail(e);try{await psLoad()}catch(_){}psRender()}}
async function wnUpdatePart(id){const T=wnPushMain(),P=psById(id);if(!T||!P)return;const H=wnHubOf(T);if(!H){toast('There is no Hub to push to');return}
const parent=wnCopyOf(P.parent_id);if(!parent){toast('Push '+((psById(P.parent_id)||{}).name||'its Sub')+' to Hub first');return}
if(await wnAsk('Update '+P.name+' in Hub?')!=='yes')return;
try{await psLoad();const P2=psById(id),parent2=wnCopyOf(P2.parent_id);if(!parent2){toast('Push '+((psById(P2.parent_id)||{}).name||'its Sub')+' to Hub first');return}
await wnCopyOne(P2,parent2.id,psById(T.id),psById(H.id));psRender();toast(P2.name+' is updated in Hub')}catch(e){wnFail(e);try{await psLoad()}catch(_){}psRender()}}
/* the buttons, on the Sub's page in the Main with Push to Hub checked */
function wnPushButtons(){const page=$('page-sec'),T=wnPushMain(),S=psById(wn.sub),bar=$('sec-tabs');if(!page||!bar)return;
bar.querySelectorAll('.wn-push').forEach(b=>b.remove());$('sec-stage').querySelectorAll('.wn-update-bar').forEach(b=>b.remove());
if(!T||!S||S.parent_id!==T.id||(WN_BUILT[S.id]||{}).page||wnLinkTo(S))return;
bar.insertAdjacentHTML('beforeend','');
$('sec-stage').querySelectorAll('.utility-pane').forEach(p=>{const k=p.dataset.utilityPane||'';if(!k.startsWith('sec-'))return;const id=k.slice(4),x=psById(id);if(!x||x.kind!=='sub')return;
p.insertAdjacentHTML('afterbegin','')})}
document.addEventListener('click',e=>{const p=e.target.closest('[data-wn-push]');if(p){wnPushSub(p.dataset.wnPush);return}const u=e.target.closest('[data-wn-update]');if(u)wnUpdatePart(u.dataset.wnUpdate)});
start();
Gifts
Gift Sheet
Gift List
Print Worksheet
Days Off
Missing Membership
Add to Memberships
Put “” under it as
Saved to the Transfer Names list. Accounts with this name are Transfers (set up on the Member Account) and are no longer flagged.