diff --git a/apps/calendar/0.1.2/ui.js b/apps/calendar/0.1.2/ui.js new file mode 100644 index 00000000..0fe6aaca --- /dev/null +++ b/apps/calendar/0.1.2/ui.js @@ -0,0 +1,2 @@ +var qn=Object.create;var Et=Object.defineProperty;var Wn=Object.getOwnPropertyDescriptor;var Zn=Object.getOwnPropertyNames;var Kn=Object.getPrototypeOf,Xn=Object.prototype.hasOwnProperty;var Qn=(e,t)=>()=>{try{return t||e((t={exports:{}}).exports,t),t.exports}catch(n){throw t=0,n}};var er=(e,t,n,r)=>{if(t&&typeof t=="object"||typeof t=="function")for(let o of Zn(t))!Xn.call(e,o)&&o!==n&&Et(e,o,{get:()=>t[o],enumerable:!(r=Wn(t,o))||r.enumerable});return e};var tr=(e,t,n)=>(n=e!=null?qn(Kn(e)):{},er(t||!e||!e.__esModule?Et(n,"default",{value:e,enumerable:!0}):n,e));var St=Qn((Fr,$t)=>{"use strict";$t.exports=function(e,t){t||(t={}),typeof t=="function"&&(t={cmp:t});var n=typeof t.cycles=="boolean"?t.cycles:!1,r=t.cmp&&(function(i){return function(s){return function(l,c){var f={key:l,value:s[l]},d={key:c,value:s[c]};return i(f,d)}}})(t.cmp),o=[];return(function i(s){if(s&&s.toJSON&&typeof s.toJSON=="function"&&(s=s.toJSON()),s!==void 0){if(typeof s=="number")return isFinite(s)?""+s:"null";if(typeof s!="object")return JSON.stringify(s);var l,c;if(Array.isArray(s)){for(c="[",l=0;l(0,Be.default)(e)===(0,Be.default)(t);function Tt(e,t,n){let r={local:{},remote:{},createLocal:!1,createRemote:!1,deleteLocal:!1,deleteRemote:!1,conflicts:[]};if(le(t,n))return t!==void 0&&(r.agreed=structuredClone(t)),r;if(t===null||n===null)return le(t,e)&&n===null?r.deleteLocal=!0:le(n,e)&&t===null?r.deleteRemote=!0:r.conflicts.push({property:"@record",base:e,local:t,remote:n}),r;if(e!==void 0&&(t===void 0||n===void 0))return r;r.createLocal=e===void 0&&t===void 0&&n!==void 0,r.createRemote=e===void 0&&n===void 0&&t!==void 0;let o=new Set([...Object.keys(e??{}),...Object.keys(t??{}),...Object.keys(n??{})]);for(let i of o){let s=e?.[i],l=t?.[i],c=n?.[i];le(l,c)||(le(l,s)?r.local[i]=structuredClone(c):le(c,s)?r.remote[i]=structuredClone(l):r.conflicts.push({property:i,base:s,local:l,remote:c}))}return r}var ce=class extends Error{},we=class extends Error{constructor(n){super("Google event changed after preview; preview again");this.id=n}id},Dt=250,nr=100,Pt={Authorization:"secret:google-calendar","Content-Type":"application/json"};function Ue(e){if(!/^\d{4}-\d{2}-\d{2}$/.test(e))return!1;let t=Date.parse(`${e}T00:00:00Z`);return Number.isFinite(t)&&new Date(t).toISOString().slice(0,10)===e}function rr(e,t){return t!==e?t:new Date(Date.parse(`${e}T00:00:00Z`)+864e5).toISOString().slice(0,10)}function Ye(e){return/^\d{4}-\d{2}-\d{2}T.*(?:Z|[+-]\d{2}:\d{2})$/.test(e)&&Number.isFinite(Date.parse(e))}function Je(e){if(e.recurrence?.length||e.recurringEventId||e.status==="cancelled")return;if(typeof e.id!="string"||!e.id)throw new Error("Google returned an invalid event");let t=typeof e.start?.date=="string",n=o=>JSON.stringify(o?.date??o?.dateTime??null),r=()=>`start ${n(e.start)}, end ${n(e.end)}`;if(!e.start||!e.end)throw new ce(`Calendar event ${e.id} has no start or end`);if(t){if(e.start.dateTime!==void 0||e.end.dateTime!==void 0||!e.start.date||!Ue(e.start.date)||!e.end.date||!Ue(e.end.date)||e.end.datee.allDay?Date.parse(`${r}T00:00:00Z`):Date.parse(r);if(n(e.end)<=n(e.start))throw new Error("Event end must follow its start")}function qe(e){if(!e||/[/?#]/.test(e)||[".",".."].includes(e))throw new Error("Calendar id must not contain path separators");return`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(e)}/events`}function Rt(e){if(e.status<200||e.status>=300)throw new Error(`Google Calendar returned ${e.status}; no checkpoint was advanced. Resolve access/reconnect before retrying.`);return JSON.parse(e.body)}function Ot(e,t,n,r,o,i){return{operation:e,method:t,url:n,id:r,headers:i?{...Pt,"If-Match":i}:Pt,...o===void 0?{}:{body:JSON.stringify(o)}}}async function Lt(e,t,{maxPages:n=nr}={}){let r=qe(t),o=await e.state();if(o.cursor)throw new Error("A saved sync is pending; resume it before previewing another run");let i=new Map,s=new Map,l=new Map,c={recurring:0,cancelled:0,unreadable:0},f=[],d,w=0;do{if(++w>n)throw new Error(`Pilot supports at most ${String(n*Dt).replace(/\B(?=(\d{3})+$)/g,",")} events per scan`);let p=`${r}?singleEvents=false&showDeleted=true&maxResults=${Dt}${d?`&pageToken=${encodeURIComponent(d)}`:""}`,x=Rt(await e.read(Ot("list","GET",p,`page-${w}`)));if(!Array.isArray(x.items))throw new Error("Google Calendar event page must include an items array");for(let v of x.items){let h;try{h=Je(v)}catch(y){if(!(y instanceof ce))throw y;c.unreadable++,f.push({id:v.id,title:v.summary??"",reason:y.message});continue}h?(i.set(v.id,h),typeof v.etag=="string"&&s.set(v.id,v.etag),typeof v.htmlLink=="string"&&/^https:\/\//.test(v.htmlLink)&&l.set(v.id,v.htmlLink)):v.recurrence?.length||v.recurringEventId?c.recurring++:c.cancelled++}d=x.nextPageToken}while(d);let m=await e.cards(),u=new Map;for(let p of m)if(Mt(p.value),p.id!==void 0){if(u.has(p.id))throw new Error(`Duplicate cards for event ${p.id}`);u.set(p.id,p)}let b={calendarId:t,revision:o.revision,changes:[],conflicts:[],skipped:c,unreadable:f};for(let[p,x]of i){let v=o.records[p],h=u.get(p);if(v&&(!h||v.local!==h.subject)){b.conflicts.push({...v.local?{subject:v.local}:{},id:p,fields:["Missing or rebound local card"]});continue}let y=Tt(v?.baseline,h?.value,x);if(y.conflicts.length){b.conflicts.push({subject:h?.subject,id:p,fields:y.conflicts.map(T=>T.property),...h?{local:h.value}:{},remote:x,...v?.baseline?{base:v.baseline}:{},...s.has(p)?{etag:s.get(p)}:{}});continue}let C={...x,...y.remote};Mt(C),b.changes.push({subject:h?.subject,id:p,local:h?.value,remote:x,desired:C,etag:s.get(p),...l.has(p)?{link:l.get(p)}:{}})}for(let p of m)p.id===void 0?b.changes.push({subject:p.subject,local:p.value,desired:p.value}):i.has(p.id)||b.conflicts.push({subject:p.subject,id:p.id,fields:["Event cancelled, recurring or inaccessible; no deletion inferred"]});return b}function jt(e,t){return t?{date:e}:{dateTime:e}}function At(e,t,n,r){let o={};if(t.title!==n.title&&(o.summary=t.title),t.description!==n.description&&(o.description=t.description),t.location!==n.location&&(o.location=t.location),(t.start!==n.start||t.allDay!==n.allDay)&&(o.start=jt(t.start,t.allDay)),(t.end!==n.end||t.allDay!==n.allDay)&&(o.end=jt(t.end,t.allDay)),!!Object.keys(o).length)return{id:e,subject:r,patch:o}}async function Nt(e,t,n,r){let o=await e.read(Ot("update","PATCH",`${t}/${encodeURIComponent(n.id)}?sendUpdates=none`,"write",n.patch,r));if(o.status===412)throw new we(n.id);return Rt(o)}var ve="google-calendar",We="https://www.googleapis.com/calendar/v3",xe=class extends Error{constructor(n,r){super(`Google may or may not have applied this change (${r instanceof Error?r.message:String(r)}). Refresh to see what Google has now.`);this.id=n;this.cause=r}id;cause},Ht=["unknown_connection","not_delegated","capability_scope","platform_mismatch","credential_refresh_failed"],ar=[...Ht,"missing_signature","unsupported_signature_version","invalid_agent","agent_key_mismatch","stale_timestamp","bad_signature","replayed","invalid_capability","capability_expired","capability_too_long","wrong_audience","capability_key_mismatch","not_owner","access_denied"];function or(e){let t=e.body,n=typeof t?.error=="string"?t.error:void 0;if(e.status<400||!n||!ar.includes(n))return;let r=typeof t?.message=="string"?`: ${t.message}`:"";return new Error(Ht.includes(n)?`The integration proxy refused this connection (${n}${r}). Connect again.`:`The integration proxy refused the request (${n}${r}).`)}function ir(e){let t=new URL(e),n=new URL(We);if(t.origin!==n.origin||!t.pathname.startsWith(`${n.pathname}/`)||t.username||t.password||t.hash)throw new Error(`Refusing a request outside ${We}: ${t.href}`);return{path:t.pathname,query:Object.fromEntries(t.searchParams)}}function Ne(e,t){let n={async read(r){let{path:o,query:i}=ir(r.url),s=r.method.toUpperCase();if(s!=="GET"&&s!=="PATCH")throw new Error(`The Calendar app does not send ${s} requests`);let l=r.headers?.["If-Match"];if(s==="PATCH"&&!l)throw new Error("Refusing a Calendar write without If-Match");let c;try{c=await e.request({platform:ve,connectionId:t,path:o,method:s,...Object.keys(i).length?{query:i}:{},...r.body===void 0?{}:{body:r.body},...l?{ifMatch:l}:{}})}catch(d){throw s==="GET"?d:new xe(r.id,d)}n.last=c;let f=or(c);if(f)throw f;return{status:c.status,body:typeof c.body=="string"?c.body:JSON.stringify(c.body??null)}}};return n}async function Ze(e,t){let n=Ne(e,t),r=[],o,i=0;do{if(++i>10)throw new Error("More than 2,500 calendars; the list was not read");let s=new URL(`${We}/users/me/calendarList`);s.searchParams.set("maxResults","250"),o&&s.searchParams.set("pageToken",o);let l=await n.read({id:`calendar-list-${i}`,operation:"calendar-list",method:"GET",url:s.href});if(l.status<200||l.status>=300)throw Object.assign(new Error(`Google Calendar returned ${l.status}`),{status:l.status,...n.last?.headers?.["retry-after"]?{retryAfter:n.last.headers["retry-after"]}:{}});let c=JSON.parse(l.body);if(!Array.isArray(c.items))throw new Error("Google Calendar list page must include an items array");for(let f of c.items)typeof f.id=="string"&&f.id&&r.push({id:f.id,summary:typeof f.summary=="string"?f.summary:f.id,primary:f.primary===!0,accessRole:typeof f.accessRole=="string"?f.accessRole:"reader",...typeof f.backgroundColor=="string"&&/^#[0-9a-f]{6}$/i.test(f.backgroundColor)?{backgroundColor:f.backgroundColor}:{}});o=c.nextPageToken}while(o);return r}var W="https://atomicdata.dev",tt=`${W}/properties/parent`,Zr=`${W}/properties/isA`,re=`${W}/properties/name`,Ke=`${W}/properties/shortname`,sr=`${W}/properties/description`,lr=`${W}/properties/datatype`,zt=`${W}/properties/recommends`,Xe=`${W}/properties/properties`,cr=`${W}/classes/Property`,V=`${W}/datatypes`,nt="atomic-calendar-day",rt="atomic-calendar-all-day",at="atomic-calendar-end-day",ot="atomic-calendar-notes",ke={location:{name:"Location",datatype:`${V}/string`,description:"Where the event takes place, as Google Calendar has it.",column:!0},start:{name:"Start",datatype:`${V}/string`,description:"YYYY-MM-DD for an all-day event, otherwise a date-time with its UTC offset.",column:!0},end:{name:"End",datatype:`${V}/string`,description:"Exclusive: the day after the last day for an all-day event, otherwise a date-time with its UTC offset.",column:!0},[rt]:{name:"All day",datatype:`${V}/boolean`,description:"Whether Start and End are dates rather than date-times.",column:!0},[nt]:{name:"Day",datatype:`${V}/date`,description:"The date of Start, for calendar views. Derived on import; edit Start to move the event.",column:!0},[at]:{name:"End day",datatype:`${V}/date`,description:"For calendar views: the day after the last day of an all-day event (exclusive, as Google has it), or the end date of a timed event that ends on a later day. Derived on import; edit End to change it.",column:!0},[ot]:{name:"Notes",datatype:`${V}/string`,description:"The event\u2019s description, as Google Calendar has it.",column:!0},"google-event-id":{name:"Google event id",datatype:`${V}/string`,description:"The Google Calendar event this row is bound to.",column:!1},"google-etag":{name:"Google ETag",datatype:`${V}/string`,description:"The event version last read from Google Calendar.",column:!1},"google-link":{name:"Google Calendar link",datatype:`${V}/string`,description:"The event\u2019s page in Google Calendar (its htmlLink), as last read. Display only.",column:!1},"sync-baseline":{name:"Sync baseline",datatype:`${V}/string`,description:"JSON of the fields as both sides last agreed; tells local edits from Google edits.",column:!1},"google-calendar-id":{name:"Google calendar id",datatype:`${V}/string`,description:"The one Google calendar this table imports (on the table).",column:!1},"google-calendar-meta":{name:"Google calendar details",datatype:`${V}/string`,description:"JSON of the imported calendar\u2019s name, colour, access role and account, as Google listed them (on the table; display only).",column:!1}},Ce="#4986e7",Ee=e=>e==="reader"||e==="freeBusyReader",Qe=e=>Array.isArray(e)?e.filter(t=>typeof t=="string"):[];async function Y(e){let t=await e.getData();if(!t?.rowClass)throw new Error("This app has no table with a row class to sync into.");let r=(await e.getResource(t.rowClass)).get(tt);if(typeof r!="string")throw new Error("The row class has no parent ontology to add fields to.");return{table:t.table,rowClass:t.rowClass,ontology:r}}async function Q(e,t,n){let r=await e.getResource(t.ontology),o=Qe(r.get(Xe)),i=new Map;for(let u of o){let b=(await e.getResource(u)).get(Ke);typeof b=="string"&&i.set(b,u)}let s=Object.keys(ke).filter(u=>!i.has(u));if(s.length&&!n)return;let l=[];for(let u of s){let b=ke[u],p=await e.newResource({parent:t.ontology,isA:[cr],propVals:{[Ke]:u,[re]:b.name,[sr]:b.description,[lr]:b.datatype}});i.set(u,p.subject),l.push(p.subject)}l.length&&await r.set(Xe,[...o,...l]).save();let c=Object.fromEntries(Object.keys(ke).map(u=>[u,i.get(u)])),f=await e.getResource(t.rowClass),d=Qe(f.get(zt)),w=[re,...Object.keys(ke).filter(u=>ke[u].column).map(u=>c[u])],m=[...d,...w.filter(u=>!d.includes(u))];return(m.length!==d.length||f.get(re)!=="Event")&&await f.set(zt,m).set(re,"Event").save(),c}async function dr(e,t){let n=await e.getResource(t.ontology),r=new Map;for(let o of Qe(n.get(Xe))){let i=(await e.getResource(o)).get(Ke);typeof i=="string"&&r.set(i,o)}return r}function pr(e){if(!(typeof e!="string"||!e))try{let t=JSON.parse(e);return typeof t.summary!="string"?void 0:{summary:t.summary,color:typeof t.color=="string"?t.color:Ce,accessRole:typeof t.accessRole=="string"?t.accessRole:"reader",...typeof t.account=="string"?{account:t.account}:{}}}catch{return}}async function Gt(e){let t=await Y(e),n=await dr(e,t),r=n.get("google-calendar-id");if(!r)return;let o=await e.getResource(t.table),i=o.get(r);if(typeof i!="string"||!i)return;let s=n.get("google-calendar-meta"),l=s?pr(o.get(s)):void 0;return{id:i,...l?{meta:l}:{}}}async function It(e,t,n){let r=await Y(e),o=await Q(e,r,!0),i=await e.getResource(r.table),s=i.get(o["google-calendar-id"]);if(typeof s=="string"&&s&&s!==t.id)throw new Error("This table already imports another calendar. Use a new Calendar app for a second one.");let l={summary:t.summary,color:t.backgroundColor??Ce,accessRole:t.accessRole??"reader",...n?{account:n}:{}};return await i.set(o["google-calendar-id"],t.id).set(o["google-calendar-meta"],JSON.stringify(l)).set(re,t.summary).save(),l}var de=e=>typeof e=="string"?e:"";function it(e,t){let n=e.get(t["google-event-id"]);return{subject:e.subject,...typeof n=="string"&&n?{id:n}:{},value:{title:de(e.get(re)),description:de(e.get(t[ot])),location:de(e.get(t.location)),start:de(e.get(t.start)),end:de(e.get(t.end)),allDay:e.get(t[rt])===!0}}}function et(e,t){let n=e.get(t["sync-baseline"]);if(typeof n!="string"||!n)return null;try{return JSON.parse(n)}catch{return null}}function _t(e){if(!e.title.trim())return"the title is empty";let t=/^\d{4}-\d{2}-\d{2}$/,n=/^\d{4}-\d{2}-\d{2}T.*(?:Z|[+-]\d{2}:\d{2})$/,r=i=>e.allDay?t.test(i)&&Number.isFinite(Date.parse(`${i}T00:00:00Z`)):n.test(i)&&Number.isFinite(Date.parse(i));if(!r(e.start)||!r(e.end))return e.allDay?"all-day Start and End must be YYYY-MM-DD":"Start and End need a date-time with a UTC offset";let o=i=>e.allDay?Date.parse(`${i}T00:00:00Z`):Date.parse(i);if(o(e.end)<=o(e.start))return"End must be after Start"}async function ur(e,t,n){let r={props:n,bound:new Map,localOnly:0,invalid:new Map};for(let o of await e.query({property:tt,value:t.table})){let i=await e.getResource(o);it(i,n).id?r.bound.set(o,i):r.localOnly++}return r}function gr(e,t,n){let r=Ne(e,t),{props:o}=n;return{rows:n,relayed:r,read:i=>r.read(i),async cards(){let i=[];for(let s of n.bound.values()){let l=it(s,o),c=_t(l.value);if(c){n.invalid.set(s.subject,{title:l.value.title,reason:c});let f=et(s,o);f&&i.push({...l,value:f});continue}i.push(l)}return i},async state(){let i={};for(let s of n.bound.values()){let l=s.get(o["google-event-id"]);i[l]={local:s.subject,baseline:et(s,o)}}return{revision:0,records:i,cursor:null}}}}var F={title:"Title",description:"Description",location:"Location",start:"Start",end:"End",allDay:"All day"};function fr(e,t){return Object.keys(F).filter(n=>e[n]!==t[n]).map(n=>({field:F[n],before:String(e[n]),after:String(t[n])}))}function mr(e){let t=e.start.slice(0,10),n=e.end.slice(0,10);return e.allDay||n>t?n:void 0}function Vt(e,t){return{[re]:t.title,[e[ot]]:t.description,[e.location]:t.location,[e.start]:t.start,[e.end]:t.end,[e[rt]]:t.allDay,[e[nt]]:t.start.slice(0,10),[e[at]]:mr(t)}}function hr(e){return Object.fromEntries(Object.entries(e).filter(([,t])=>t!==void 0))}function He(e,t,n){let r=!1;for(let[o,i]of Object.entries(Vt(t,n)))e.get(o)!==i&&(i===void 0?e.remove(o):e.set(o,i),r=!0);return r}async function Ft(e,t,n,r={}){let o=await Y(e),i=await Q(e,o,!0),l=(await e.getResource(o.table)).get(i["google-calendar-id"]);if(typeof l!="string"||!l)throw new Error("Choose a calendar first.");let c=await ur(e,o,i),f=gr(t,n,c),d=0,w=f.read;f.read=async p=>{let x=await w(p);return p.operation==="list"&&r.onPage?.(++d),x};let m=await br(f.relayed,()=>Lt(f,l,{maxPages:r.maxPages})),u=new Map([...c.bound.values()].map(p=>[p.subject,de(p.get(re))])),b={calendarId:l,total:m.changes.length,added:0,updated:0,unchanged:0,skipped:m.skipped,unreadable:m.unreadable,conflicts:m.conflicts.map(p=>{let x=p.remote?"both":/no deletion inferred/.test(p.fields[0]??"")?"missing-remote":"missing-local";return{...p.subject?{subject:p.subject}:{},...p.id?{id:p.id}:{},title:p.subject&&u.get(p.subject)||p.remote?.title||p.id||"(untitled)",fields:p.fields,kind:x,...p.local?{local:p.local}:{},...p.remote?{remote:p.remote}:{},...p.base?{base:p.base}:{},...p.etag?{etag:p.etag}:{}}}),localOnly:c.localOnly,invalid:[...c.invalid.values()],review:[]};for(let p of m.changes){if(!p.id||!p.remote||p.subject&&c.invalid.has(p.subject))continue;let x=JSON.stringify(p.remote);if(!p.subject){await e.newResource({parent:o.table,isA:[o.rowClass],propVals:{...hr(Vt(i,p.desired)),[i["google-event-id"]]:p.id,[i["google-etag"]]:p.etag??"",[i["sync-baseline"]]:x,...p.link?{[i["google-link"]]:p.link}:{}}}),b.added++;continue}let v=c.bound.get(p.subject),h=He(v,i,p.desired),y=p.link??"",C=v.get(i["google-etag"])!==(p.etag??"")||v.get(i["sync-baseline"])!==x||(v.get(i["google-link"])??"")!==y;C&&v.set(i["google-etag"],p.etag??"").set(i["sync-baseline"],x).set(i["google-link"],y),(h||C)&&await v.save(),h?b.updated++:b.unchanged++;let T=At(p.id,p.desired,p.remote,p.subject);T&&p.etag&&b.review.push({edit:T,etag:p.etag,title:p.remote.title,fields:fr(p.remote,p.desired),remote:p.remote,desired:p.desired})}return b}async function Bt(e,t,n,r,o,i){let s=await Y(e),l=await Q(e,s,!1),c=Ne(t,n),f=qe(r),d=[],w=!1;for(let m of o){let{title:u}=m;if(w){d.push({status:"not-sent",title:u}),i?.(d.length-1,d.at(-1));continue}i?.(d.length);try{let b=await Nt({read:c.read,cards:async()=>[],state:async()=>yr()},f,m.edit,m.etag),p=Je(b);m.edit.subject&&p&&await(await e.getResource(m.edit.subject)).set(l["google-etag"],b.etag).set(l["sync-baseline"],JSON.stringify(p)).save(),d.push({status:"sent",title:u})}catch(b){b instanceof we?d.push({status:"stale",title:u}):b instanceof xe?(d.push({status:"uncertain",title:u,message:b.message}),w=!0):d.push({status:"failed",title:u,message:b instanceof Error?b.message:String(b)})}i?.(d.length-1,d.at(-1))}return d}async function br(e,t){try{return await t()}catch(n){let r=e.last;throw n instanceof Error&&/Google Calendar returned \d{3}\b/.test(n.message)&&r&&r.status>=400&&Object.assign(n,{status:r.status,...r.headers?.["retry-after"]?{retryAfter:r.headers["retry-after"]}:{},...typeof r.body=="object"&&r.body?{detail:JSON.stringify(r.body)}:typeof r.body=="string"&&r.body?{detail:r.body}:{}}),n}}async function Ut(e,t,n=[]){let r=await Y(e),o=await Q(e,r,!0),i=new Set(n.map(c=>c.subject)),s=[],l=Ee(t.accessRole);for(let c of await e.query({property:tt,value:r.table})){let f=await e.getResource(c),d=it(f,o),w=et(f,o),m=f.get(o["google-link"]);s.push({...d.value,subject:c,...d.id?{id:d.id}:{},...w?{baseline:w}:{},...typeof m=="string"&&/^https:\/\//.test(m)?{link:m}:{},pending:!!d.id&&!!w&&Object.keys(F).some(u=>w[u]!==d.value[u]),conflict:i.has(c),readOnly:l||!d.id,calendar:{name:t.summary,color:t.color},day:f.get(o[nt]),endDay:f.get(o[at])})}return s}async function Yt(e,t){let n=await Y(e),r=await Q(e,n,!0);await(await e.getResource(n.table)).set(r["google-calendar-meta"],JSON.stringify(t)).save()}async function Jt(e,t,n){let r=await Y(e),o=await Q(e,r,!0),i=await e.getResource(t);He(i,o,n)&&await i.save()}async function qt(e,t){if(!t.edit.subject)return;let n=await Y(e),r=await Q(e,n,!0),o=await e.getResource(t.edit.subject);He(o,r,t.remote),await o.set(r["google-etag"],t.etag).set(r["sync-baseline"],JSON.stringify(t.remote)).save()}async function Wt(e,t,n){let{subject:r,local:o,remote:i,base:s}=t;if(t.kind!=="both"||!r||!o||!i)throw new Error("Only a both-changed conflict can be resolved per field.");let l=Object.keys(F);for(let u of t.fields)if(!n[u])throw new Error(`Choose a value for ${u}.`);let c=Object.fromEntries(l.map(u=>{let b=n[u];if(b)return[u,b==="mine"?o[u]:i[u]];let p=s?s[u]!==o[u]:!1;return[u,p?o[u]:i[u]]})),f=_t(c);if(f)throw new Error(`That combination can\u2019t be saved: ${f}.`);let d=await Y(e),w=await Q(e,d,!0),m=await e.getResource(r);He(m,w,c),m.set(w["sync-baseline"],JSON.stringify(i)).set(w["google-etag"],t.etag??""),await m.save()}async function Zt(e,t){let n=await Y(e),r=await Q(e,n,!0);await(await e.getResource(t)).remove(r["google-event-id"]).remove(r["google-etag"]).remove(r["sync-baseline"]).save()}async function Kt(e){return(await Y(e)).table}async function Xt(e,t){await(await e.getResource(t)).destroy()}function yr(){throw new Error("applyEdit does not read connection state")}function en(){return Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC"}var Qt=new Map;function wr(e){let t=Qt.get(e);return t||(t=new Intl.DateTimeFormat("en-US",{timeZone:e,hourCycle:"h23",year:"numeric",month:"2-digit",day:"2-digit",hour:"2-digit",minute:"2-digit",second:"2-digit"}),Qt.set(e,t)),t}function N(e,t){let n={};for(let r of wr(t).formatToParts(new Date(e)))n[r.type]=r.value;return{date:`${n.year}-${n.month}-${n.day}`,minutes:Number(n.hour)*60+Number(n.minute)}}function pe(e,t){let n=N(e,t),r=Date.parse(`${n.date}T00:00:00Z`)+n.minutes*6e4;return Math.round((r-Math.floor(e/6e4)*6e4)/6e4)}function ze(e,t,n){let r=Date.parse(`${e}T00:00:00Z`)+t*6e4,o=r-pe(r,n)*6e4;return o=r-pe(o,n)*6e4,o}function st(e){let t=e<0?"-":"+",n=Math.abs(e);return`${t}${$e(Math.floor(n/60))}:${$e(n%60)}`}var $e=e=>String(e).padStart(2,"0");function R(e){return`${$e(Math.floor(e/60))}:${$e(e%60)}`}function lt(e){let t=/^(\d{1,2}):(\d{2})$/.exec(e.trim());if(!t)return;let n=Number(t[1]),r=Number(t[2]);if(!(n>23||r>59))return n*60+r}function ct(e,t,n){let r=ze(e,t,n),o=N(r,n);return`${o.date}T${R(o.minutes)}:00${st(pe(r,n))}`}function M(e,t){return new Date(Date.parse(`${e}T00:00:00Z`)+t*864e5).toISOString().slice(0,10)}function Se(e,t){return Math.round((Date.parse(`${t}T00:00:00Z`)-Date.parse(`${e}T00:00:00Z`))/864e5)}function tn(e){return(new Date(`${e}T00:00:00Z`).getUTCDay()+6)%7}function te(e){return M(e,-tn(e))}function ie(e){return/^\d{4}-\d{2}-\d{2}$/.test(e)&&Number.isFinite(Date.parse(`${e}T00:00:00Z`))&&new Date(`${e}T00:00:00Z`).toISOString().slice(0,10)===e}var xr=["Monday","Tuesday","Wednesday","Thursday","Friday","Saturday","Sunday"],vr=["January","February","March","April","May","June","July","August","September","October","November","December"],ae=e=>Number(e.slice(8,10)),ee=e=>vr[Number(e.slice(5,7))-1];function ue(e){return xr[tn(e)]}function Te(e){return ue(e).slice(0,3)}function J(e){return`${Te(e)} ${ae(e)} ${ee(e).slice(0,3)}`}function z(e){return`${ue(e)} ${ae(e)} ${ee(e)}`}function De(e){return`${ee(e)} ${e.slice(0,4)}`}function nn(e,t){if(e===t)return`${z(e)} ${e.slice(0,4)}`;let[n,r]=[e.slice(0,4),t.slice(0,4)],[o,i]=[ae(e),ae(t)];return n!==r?`${o} ${ee(e)} ${n} \u2013 ${i} ${ee(t)} ${r}`:e.slice(5,7)!==t.slice(5,7)?`${o} ${ee(e)} \u2013 ${i} ${ee(t)} ${r}`:`${o} \u2013 ${i} ${ee(t)} ${r}`}function rn(e,t){let n=r=>ee(r).slice(0,3);return e.slice(0,7)===t.slice(0,7)?`${ae(e)} \u2013 ${ae(t)} ${n(t)}`:`${ae(e)} ${n(e)} \u2013 ${ae(t)} ${n(t)}`}function Ge(e,t){let n=pe(e,t);if(n===0)return"GMT";let r=Math.floor(Math.abs(n)/60),o=Math.abs(n)%60;return`GMT${n<0?"\u2212":"+"}${r}${o?`:${$e(o)}`:""}`}function an(e,t,n){return t===e.today?"Today":t===M(e.today,1)?"Tomorrow":t===M(e.today,-1)?"Yesterday":n}function kr(e){return e.untimed?"No time set":e.allDay?"All day":`${R(e.startMin)} to ${dt(e)}`}function dt(e,t=!1){if(!e.until)return R(e.endMin);let n=R(e.until.minutes);return t?`${n} ${J(e.until.date)}`:`${n} on ${z(e.until.date)}`}function Pe(e){let t=e.event,n=[t.title||"(untitled)",kr(e)];return e.dayOf&&n.push(`day ${e.dayOf.n} of ${e.dayOf.total}`),t.location&&n.push(t.location),n.push(`${t.calendar.name} calendar`),t.pending&&n.push("not sent yet"),t.conflict&&n.push("conflict"),n.join(", ")}function on(e,t){let n=typeof e.day=="string"&&ie(e.day.slice(0,10))?z(e.day.slice(0,10)):"No date set";if(e.allDay&&(!ie(e.start)||!ie(e.end)))return{day:n,time:"All day"};if(!e.allDay&&(!e.start||!e.end))return{day:n,time:"No time set"};if(e.allDay){let m=M(e.end,-1);return{day:m===e.start?z(e.start):`${z(e.start)} \u2013 ${z(m)}`,time:"All day"}}let r=Date.parse(e.start),o=Date.parse(e.end);if(!Number.isFinite(r)||!Number.isFinite(o))return{day:`${e.start} \u2013 ${e.end}`};let i=N(r,t),s=N(o,t),l=i.date===s.date?`${R(i.minutes)} \u2013 ${R(s.minutes)}`:`${R(i.minutes)} \u2013 ${J(s.date)} ${R(s.minutes)}`,c=/([+-]\d{2}:\d{2}|Z)$/.exec(e.start)?.[1],f=st(pe(r,t)),d=c==="Z"?"+00:00":c,w=m=>m.slice(11,16);return{day:z(i.date),time:l,...d&&d!==f?{own:`${w(e.start)} \u2013 ${w(e.end)} in the event\u2019s time zone (UTC${d.replace("-","\u2212")})`}:{}}}function ge(e,t,n){let r=t[e];if(e==="allDay")return r?"All day":"Timed";if(e!=="start"&&e!=="end")return String(r)||"(empty)";if(t.allDay){let s=e==="end"?M(String(r),-1):String(r);return J(s)}let o=Date.parse(String(r));if(!Number.isFinite(o))return String(r);let i=N(o,n);return`${J(i.date)} ${R(i.minutes)}`}function sn(e,t){let n=e.baseline;if(!n)return;let r=Object.keys(F).filter(s=>n[s]!==e[s]);if(!r.length)return;if(r.length===1){let[s]=r;return`${F[s]} changed from ${ge(s,n,t)} to ${ge(s,e,t)}.`}let o=r.map(s=>F[s].toLowerCase()),i=`${o.slice(0,-1).join(", ")} and ${o.at(-1)}`;return`${i[0].toUpperCase()}${i.slice(1)} changed.`}function Me(e){if(typeof e!="string"||!/^\d{4}-\d{2}-\d{2}$/.test(e))return!1;let[t,n,r]=e.split("-").map(Number);if(t<1||n<1||n>12||r<1)return!1;let i=[31,t%4===0&&(t%100!==0||t%400===0)?29:28,31,30,31,30,31,31,30,31,30,31];return r<=i[n-1]}function ln(e,t,n){return Me(e)&&Me(t)&&Me(n)&&e<=n&&n1?{dayOf:{n:Se(o,c)+1,total:i}}:{}});continue}let f=s.startMin+s.duration,d=/^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2})/.exec(e.end);r.push({event:e,date:c,allDay:!1,startMin:s.startMin,endMin:Math.min(f,1440),...f>1440&&d?{until:{date:d[1],minutes:Number(d[2])*60+Number(d[3])}}:{}})}return r}var un=(e,t)=>Number(t.allDay)-Number(e.allDay)||e.startMin-t.startMin||e.endMin-t.endMin||e.event.title.localeCompare(t.event.title)||e.event.subject.localeCompare(t.event.subject);function gn(e,t,n){let r=new Map;for(let o=0;o({date:o,items:i.sort(un)}))}function fn(e,t,n){let r=Array.from({length:n},(d,w)=>M(t,w)),o=new Map,i=r.map(()=>[]);for(let d of e)for(let w of ut(d,t,n))if(w.allDay){let m=o.get(d.subject)??[];m.push(w),o.set(d.subject,m)}else i[r.indexOf(w.date)].push(w);let s=[];for(let d of o.values()){let w;for(let m of d){let u=r.indexOf(m.date);w&&w.col+w.span===u?w.span++:(w={event:m.event,col:u,span:1},s.push(w))}}s.sort((d,w)=>d.col-w.col||w.span-d.span||d.event.title.localeCompare(w.event.title));let l=[],c=s.map(d=>{let w=0;for(;;w++){l[w]??=[];let m=l[w],u=!0;for(let b=d.col;bEr(d.sort(un)));return{days:r,bars:c,rows:l.length,columns:f}}function Er(e){let t=[],n=[],r=[],o=-1,i=0,s=()=>{for(let l of n)l.lanes=r.length;t.push(...n),i++,n=[],r=[]};for(let l of e){n.length&&l.startMin>=o&&(s(),o=-1);let c=r.findIndex(f=>f<=l.startMin);c<0&&(c=r.push(0)-1),r[c]=l.endMin,n.push({segment:l,lane:c,lanes:0,cluster:i}),o=Math.max(o,l.endMin)}return n.length&&s(),t}function mn(e){let t=Cr(e);if(t===void 0)return;let n=e.allDay?void 0:dn(e);return{date:t,at:Date.parse(`${t}T00:00:00Z`)+(n?.startMin??0)*6e4}}function hn(e,t){let n;for(let r of e){let o=mn(r);!o||o.date=t||(!n||o.at>n.at)&&(n={event:r,...o})}return n&&{event:n.event,date:n.date}}function Ie(e,t,n){let r=new Set;for(let o of e)for(let i of ut(o,t,n))r.add(i.date);return r}function a(e,t,n={},...r){let o=e.createElement(t);for(let[i,s]of Object.entries(n))s==null||s===!1||(i.startsWith("on")&&typeof s=="function"?o.addEventListener(i.slice(2),s):i==="style"&&typeof s=="string"?o.setAttribute("style",s):s===!0?o.setAttribute(i,""):o.setAttribute(i,String(s)));return t==="button"&&!("type"in n)&&o.setAttribute("type","button"),$r(o,r),o}function $r(e,t){for(let n of t)n!=null&&n!==!1&&e.appendChild(typeof n=="string"?e.ownerDocument.createTextNode(n):n)}function G(e,t){let n=e.createElement("template");n.innerHTML=t.trim();let r=n.content.firstElementChild;return r.setAttribute("aria-hidden","true"),r.setAttribute("focusable","false"),r}var I={calendar:'',more:'',close:'',prev:'',next:'',google:'31'};function gt(e){return e.activeElement?.dataset?.key}function yn(e,t){if(!t)return;let n=e.querySelector(`[data-key="${CSS.escape(t)}"]`);n&&n!==e.ownerDocument.activeElement&&n.focus({preventScroll:!0})}function wn(e,t,n){let{doc:r}=e,o=te(n),i=Ie(t,o,7);return a(r,"div",{class:"strip",role:"group","aria-label":"Days of this week"},...Array.from({length:7},(s,l)=>{let c=M(o,l);return a(r,"button",{class:"st-d","data-key":`strip-${c}`,"aria-current":c===n?"date":void 0,"aria-label":`${ue(c)} ${Number(c.slice(8))}${i.has(c)?", has events":""}${c===e.today?", today":""}`,onclick:()=>e.goTo(c)},a(r,"span",{"aria-hidden":"true"},Te(c)[0]),a(r,"b",{"aria-hidden":"true"},String(Number(c.slice(8)))),a(r,"i",{class:i.has(c)?"":"off"}))}))}function Sr(e,t){let{doc:n}=e,r=t.event,o=t.allDay?[a(n,"span",{},t.untimed?"No time":"All day")]:[a(n,"span",{},R(t.startMin)),a(n,"small",{},R(t.until?.minutes??t.endMin))],i=t.dayOf?`Day ${t.dayOf.n} of ${t.dayOf.total}`:r.location||r.calendar.name,s=a(n,"button",{class:"ag-row","data-key":`ev-${r.subject}-${t.date}`,"data-subject":r.subject,"aria-label":Pe(t),onclick:l=>e.open(r,l.currentTarget)},a(n,"span",{class:"ag-time","aria-hidden":"true"},...o),a(n,"span",{class:"sw",style:`--c:${r.calendar.color}`,"aria-hidden":"true"}),a(n,"span",{class:"ag-main","aria-hidden":"true"},a(n,"b",{},r.title||"(untitled)"),a(n,"span",{},i),r.pending?a(n,"span",{class:"tagline accent"},"Not sent yet"):null,r.conflict?a(n,"span",{class:"tagline warn"},"Conflict"):null));return a(n,"li",{},s)}function xn(e,t,n,r){let{doc:o}=e,i=gn(t,n,Math.max(1,Se(n,M(te(n),7))));return a(o,"div",{class:"agenda"},...i.map(s=>a(o,"section",{class:"ag-day","aria-label":`${ue(s.date)} ${J(s.date).slice(4)}`},a(o,"h3",{class:`ag-dh${s.date===e.today?" is-today":""}`},an(e,s.date,ue(s.date)),a(o,"span",{},J(s.date))),s.items.length?a(o,"ul",{},...s.items.map(l=>Sr(e,l))):a(o,"p",{class:"ag-none"},"No events"))),r?a(o,"p",{class:"ag-note"},r):null)}var vn='.shell{flex:1;min-height:0;display:flex;position:relative}.main{flex:1;min-width:0;display:flex;flex-direction:column;min-height:0}.view{flex:1;min-height:0;overflow:auto}.prov{width:100%;list-style:none;margin:0;padding:0;display:grid;gap:8px;text-align:left}.prov li{display:flex;align-items:center;gap:12px;padding:10px 12px;border:1px solid var(--pl-border);border-radius:10px}.prov li.off{opacity:.62}.prov .pl{width:30px;height:30px;border-radius:8px;display:grid;place-items:center;font-weight:800;font-size:13px;flex:none;color:#fff}.prov .pn{flex:1;display:grid;font-size:13.5px;font-weight:600;min-width:0}.prov .pn span{font-weight:400;font-size:12px;color:var(--pl-muted)}.waiting{display:flex;align-items:center;gap:8px;color:var(--pl-accent);font-weight:550;justify-content:center;flex-wrap:wrap}.panel{padding:16px 16px 14px;display:grid;gap:12px;max-width:560px}.panel h2{margin:0;font-size:17px;font-weight:650}.panel>p{margin:0;color:var(--pl-muted);font-size:13px}.pick{list-style:none;margin:0;padding:0;border:1px solid var(--pl-border);border-radius:10px;overflow:hidden}.pick li+li{border-top:1px solid var(--pl-border)}.pick label{display:flex;align-items:center;gap:10px;padding:10px 12px;cursor:pointer;min-height:48px}.pick input{accent-color:var(--pl-accent);width:16px;height:16px;margin:0;flex:none}.pick .pn{flex:1;display:grid;font-size:13.5px;font-weight:550;min-width:0}.pick .pn span{font-size:12px;color:var(--pl-muted);font-weight:400}.tag{font-style:normal;font-size:10.5px;padding:1px 6px;border-radius:999px;background:var(--pl-surface);color:var(--pl-muted);border:1px solid var(--pl-border);white-space:nowrap}.panel-ft{display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap}.prog{display:grid;gap:8px;padding:14px;border-bottom:1px solid var(--pl-border)}.prog-row{display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:10px;font-size:12.5px}.prog-row b{font-weight:600}.bar{height:4px;border-radius:999px;background:var(--pl-surface);overflow:hidden;position:relative}.bar i{position:absolute;inset:0 auto 0 0;width:40%;background:var(--pl-accent);border-radius:inherit;animation:pl-slide 1.4s ease-in-out infinite}.prog-row .n{color:var(--pl-muted);font-variant-numeric:tabular-nums}.skel{padding:12px;display:grid;gap:10px}.sk{height:44px;border-radius:8px;background:linear-gradient(90deg,var(--pl-surface),color-mix(in srgb,var(--pl-border) 60%,var(--pl-surface)),var(--pl-surface))}.sk.h{height:14px;width:30%}@media(prefers-reduced-motion:reduce){.bar i{animation:none;width:100%;opacity:.4}}.side{width:232px;flex:none;border-right:1px solid var(--pl-border);padding:12px 12px 16px;display:grid;gap:18px;align-content:start;overflow:auto;grid-template-columns:minmax(0,1fr)}.mm-hd{display:flex;align-items:center;justify-content:space-between;font-size:13px;margin-bottom:4px}.mm-hd b{font-weight:650}.mm-g{display:grid;grid-template-columns:repeat(7,1fr);text-align:center;font-size:11.5px;font-variant-numeric:tabular-nums;row-gap:2px}.mm-w{color:var(--pl-muted);font-weight:600;font-size:10.5px;padding:2px 0}.mm-d{padding:3px 0;border-radius:6px;border:0;background:transparent;cursor:pointer;font-size:11.5px}.mm-d{color:var(--pl-text)}.mm-d.out{color:var(--pl-muted)}.mm-d.in-wk{background:var(--pl-surface)}.mm-d.today{background:var(--pl-accent);color:var(--pl-on-accent);font-weight:700}.side-sec h2{margin:0 0 6px;font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--pl-muted)}.cals{list-style:none;margin:0;padding:0;display:grid;gap:2px;grid-template-columns:minmax(0,1fr)}.cals label{display:flex;align-items:center;gap:8px;padding:5px 6px;border-radius:6px;font-size:13px;cursor:pointer}.cals label:hover{background:var(--pl-surface)}.cals input{accent-color:var(--pl-accent);margin:0}.cals span.nm{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.note p{margin:0;font-size:12.5px;color:var(--pl-muted)}.tb{display:flex;align-items:center;gap:8px;padding:10px 14px;flex-wrap:wrap}.tb-nav{display:flex}.tb-title{margin:0 8px 0 2px;font-size:16px;font-weight:620;flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.wk{--row: 44px;--gut: 52px;display:flex;flex-direction:column;border-top:1px solid var(--pl-border);flex:1;min-height:0}.wk-head,.wk-all{display:grid;grid-template-columns:var(--gut) repeat(var(--n),minmax(0,1fr));flex:none}.wk-tz{font-size:10.5px;color:var(--pl-muted);padding:6px 6px 0 0;text-align:right}.wk-dh{display:flex;align-items:baseline;gap:6px;padding:8px 8px 6px;font-size:12px;color:var(--pl-muted);border-left:1px solid var(--pl-border);min-width:0;white-space:nowrap;overflow:hidden}.wk-dh b{font-size:17px;color:var(--pl-text);font-weight:600;font-variant-numeric:tabular-nums}.wk-dh.is-today span{color:var(--pl-accent);font-weight:600}.wk-dh.is-today b{background:var(--pl-accent);color:var(--pl-on-accent);border-radius:999px;min-width:28px;text-align:center;padding:1px 6px}.wk-all{border-bottom:1px solid var(--pl-border);padding-block:4px;row-gap:3px;grid-auto-rows:22px;min-height:30px}.wk-all .wk-tz{grid-row:1 / span 2;padding-top:3px}.ad{border:0;border-radius:5px;margin:0 3px;padding:0 8px;height:22px;min-width:0;text-align:left;font-size:12px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer;color:var(--pl-text);background:color-mix(in srgb,var(--c) calc(var(--pl-tint) + 6%),var(--pl-bg));box-shadow:inset 3px 0 0 var(--c)}.wk-scroll{flex:1;min-height:0;overflow-y:auto;overflow-x:hidden;scrollbar-gutter:stable}.wk-head,.wk-all{overflow-y:hidden;scrollbar-gutter:stable}.wk-body{display:grid;grid-template-columns:var(--gut) repeat(var(--n),minmax(0,1fr));position:relative;background-image:repeating-linear-gradient(to bottom,var(--pl-border) 0 1px,transparent 1px var(--row));background-position:0 -1px}.wk-h{height:var(--row);position:relative}.wk-h span{position:absolute;right:8px;top:-8px;font-size:10.5px;color:var(--pl-muted);font-variant-numeric:tabular-nums}.wk-h:first-child span{visibility:hidden}.wk-col{position:relative;border-left:1px solid var(--pl-border);height:calc(var(--row) * 24);min-width:0}.wk-col.is-today{background:color-mix(in srgb,var(--pl-accent) 4%,transparent)}.wk-col h3{margin:0}.wk-col ul{list-style:none;margin:0;padding:0}.ev{position:absolute;display:flex;flex-direction:column;gap:1px;align-items:flex-start;text-align:left;border:0;border-radius:5px;padding:3px 6px 3px 8px;overflow:hidden;cursor:pointer;color:var(--pl-text);background:color-mix(in srgb,var(--c) var(--pl-tint),var(--pl-surface));box-shadow:inset 3px 0 0 var(--c);font-size:12px;line-height:1.3}.ev:hover{background:color-mix(in srgb,var(--c) calc(var(--pl-tint) + 8%),var(--pl-surface))}.ev-t{font-weight:600;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;max-width:100%}.ev-m{color:color-mix(in srgb,var(--pl-text) 60%,var(--pl-muted));font-size:11px;font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}.ev-1 .ev-t{-webkit-line-clamp:1}.ev-short{flex-direction:row;align-items:center;padding-block:0}.ev-short .ev-t{-webkit-line-clamp:1;font-size:11px}.ev-edited{outline:1.5px dashed color-mix(in srgb,var(--pl-accent) 70%,transparent);outline-offset:-1.5px}.ev-dot{position:absolute;right:5px;top:5px;width:7px;height:7px;border-radius:50%;background:var(--pl-accent)}.ev-badge{position:absolute;right:4px;top:4px;width:15px;height:15px;border-radius:50%;background:var(--pl-warn);color:var(--pl-bg);font-size:10.5px;font-weight:800;display:grid;place-items:center}.ev-more{position:absolute;border:1px solid var(--pl-border);border-radius:5px;background:var(--pl-bg);font-size:11px;font-weight:650;cursor:pointer;color:var(--pl-muted);padding:0}.now{position:absolute;left:-1px;right:0;height:2px;background:var(--pl-neg);z-index:2;pointer-events:none}.now:before{content:"";position:absolute;left:-5px;top:-4px;width:10px;height:10px;border-radius:50%;background:var(--pl-neg)}.strip{display:grid;grid-template-columns:repeat(7,1fr);gap:2px;padding:0 8px 8px;border-bottom:1px solid var(--pl-border)}.st-d{display:grid;justify-items:center;gap:1px;border:0;background:transparent;border-radius:10px;padding:6px 0 5px;cursor:pointer;min-height:48px}.st-d span{font-size:10.5px;color:var(--pl-muted);font-weight:600}.st-d b{font-size:15px;font-variant-numeric:tabular-nums;font-weight:600}.st-d i{width:4px;height:4px;border-radius:50%;background:var(--pl-muted)}.st-d i.off{visibility:hidden}.st-d[aria-current=date]{background:var(--pl-accent)}.st-d[aria-current=date] span,.st-d[aria-current=date] b{color:var(--pl-on-accent)}.st-d[aria-current=date] i{background:var(--pl-on-accent)}.agenda{display:grid}.ag-dh{position:sticky;top:0;z-index:1;margin:0;display:flex;justify-content:space-between;align-items:baseline;padding:10px 12px 4px;font-size:13px;font-weight:650;background:var(--pl-bg)}.ag-dh span{font-size:12px;color:var(--pl-muted);font-weight:500}.ag-dh.is-today{color:var(--pl-accent)}.ag-day ul{list-style:none;margin:0;padding:0 4px 6px}.ag-row{width:100%;display:flex;align-items:flex-start;gap:10px;border:0;background:transparent;text-align:left;padding:8px;border-radius:8px;min-height:48px;cursor:pointer;color:var(--pl-text)}.ag-row:hover{background:var(--pl-surface)}.ag-row .sw{margin-top:5px}.ag-time{width:44px;flex:none;display:grid;font-size:12.5px;font-weight:600;font-variant-numeric:tabular-nums;line-height:1.35}.ag-time small{font-weight:400;color:var(--pl-muted);font-size:11.5px}.ag-main{display:grid;min-width:0;gap:1px}.ag-main b{font-weight:600;font-size:14px;overflow-wrap:anywhere}.ag-main>span{font-size:12.5px;color:var(--pl-muted)}.tagline{font-size:11px;font-weight:650;letter-spacing:.02em}.tagline.accent{color:var(--pl-accent)}.tagline.warn{color:var(--pl-warn)}.ag-note{margin:4px 12px 16px;padding-top:10px;border-top:1px solid var(--pl-border);font-size:12.5px;color:var(--pl-muted)}.ag-none{padding:6px 12px 10px 66px;color:var(--pl-muted);font-size:12.5px;margin:0}.drawer{position:absolute;top:0;right:0;bottom:0;width:380px;max-width:100%;background:var(--pl-bg);border-left:1px solid var(--pl-border);box-shadow:-12px 0 32px var(--pl-shadow);padding:14px 18px 16px;display:flex;flex-direction:column;gap:14px;z-index:10;overflow:auto;animation:pl-in .16s ease-out}@keyframes pl-in{0%{transform:translate(24px);opacity:0}}@media(max-width:899px){.drawer{width:360px}}@media(max-width:719px){.drawer{width:100%;border-left:0;box-shadow:none}}@media(prefers-reduced-motion:reduce){.drawer{animation:none}}.dr-hd{display:flex;align-items:center;gap:8px;font-size:12.5px;color:var(--pl-muted)}.dr-hd .icon-btn{margin-left:auto;font-size:14px}.drawer h2{margin:0;font-size:20px;line-height:1.25;font-weight:650;text-wrap:balance;overflow-wrap:anywhere}.dr-f{margin:0;display:grid;gap:12px}.dr-f div{display:grid;grid-template-columns:64px 1fr;gap:8px}.dr-f dt{font-size:12px;color:var(--pl-muted);padding-top:1px}.dr-f dd{margin:0;display:grid;overflow-wrap:anywhere;white-space:pre-wrap}.dr-f dd b{font-variant-numeric:tabular-nums;font-weight:600}.dr-f .muted{font-size:12.5px}.dr-hint{margin:0;font-size:12.5px;color:var(--pl-muted);padding:8px 10px;background:var(--pl-surface);border-radius:6px}.dr-ft{margin-top:auto;display:flex;justify-content:space-between;align-items:center;gap:8px;padding-top:12px;border-top:1px solid var(--pl-border)}.form{display:flex;flex-direction:column;gap:12px;flex:1}.fld{display:grid;gap:4px;font-size:12px;color:var(--pl-muted)}.fld input,.fld textarea{height:34px;border:1px solid var(--pl-border);border-radius:6px;padding:0 10px;background:var(--pl-bg);font-size:14px;color:var(--pl-text);min-width:0}.fld textarea{height:auto;padding:8px 10px;resize:vertical}.fld input:focus,.fld textarea:focus{outline:2px solid var(--pl-accent);outline-offset:-1px;border-color:transparent}.fld-2{display:grid;grid-template-columns:minmax(0,1fr) 112px;column-gap:6px;row-gap:4px;font-size:12px;color:var(--pl-muted)}.fld-2>span{grid-column:1 / -1}.fld-2.all-day{grid-template-columns:minmax(0,1fr)}.fld-2 input{height:34px;border:1px solid var(--pl-border);border-radius:6px;padding:0 8px;background:var(--pl-bg);font-size:14px;color:var(--pl-text);min-width:0}.fld-2 input.t{font-variant-numeric:tabular-nums;text-align:center}.pl-app input.is-bad{border-color:var(--pl-neg);box-shadow:0 0 0 1px var(--pl-neg)}.err{margin:-6px 0 0;color:var(--pl-neg);font-size:12.5px;font-weight:550}.sw-row{display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer}.sw-row input{appearance:none;width:32px;height:18px;border-radius:999px;background:var(--pl-border);position:relative;margin:0;cursor:pointer;flex:none}.sw-row input:after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:var(--pl-bg);box-shadow:0 1px 2px #00000040;transition:left .12s}.sw-row input:checked{background:var(--pl-accent)}.sw-row input:checked:after{left:16px}.saved{display:flex;align-items:center;gap:10px;padding:10px 12px;border-radius:8px;flex-wrap:wrap;background:color-mix(in srgb,var(--pl-accent) 8%,var(--pl-bg));border:1px solid color-mix(in srgb,var(--pl-accent) 25%,transparent)}.saved div{display:grid;flex:1;min-width:12ch;font-size:12.5px;color:var(--pl-muted)}.saved b{color:var(--pl-text);font-size:13px}.dot-accent{width:8px;height:8px;border-radius:50%;background:var(--pl-accent);flex:none}.conflict-note{display:flex;gap:10px;align-items:center;padding:10px 12px;border-radius:8px;flex-wrap:wrap;background:color-mix(in srgb,var(--pl-warn) 8%,var(--pl-bg));border:1px solid color-mix(in srgb,var(--pl-warn) 30%,transparent);font-size:12.5px}.conflict-note span{flex:1;min-width:12ch}.chg{list-style:none;margin:0;padding:0;display:grid;gap:10px}.chg>li{border:1px solid var(--pl-border);border-radius:10px;padding:10px 12px;display:grid;gap:8px}.chg-hd{display:flex;align-items:center;gap:8px;font-size:13.5px;flex-wrap:wrap}.chg-hd b{flex:1;font-weight:600;min-width:10ch;overflow-wrap:anywhere}.chg-hd .when{font-size:12px;color:var(--pl-muted);font-variant-numeric:tabular-nums}.diff{display:grid;grid-template-columns:78px minmax(0,1fr);gap:4px 10px;font-size:12.5px;margin:0}.diff dt{color:var(--pl-muted)}.diff dd{margin:0;display:flex;flex-wrap:wrap;gap:4px 6px;align-items:baseline;overflow-wrap:anywhere;min-width:0}.diff del{color:var(--pl-muted);text-decoration-color:color-mix(in srgb,var(--pl-neg) 70%,transparent)}.diff ins{text-decoration:none;font-weight:600;background:color-mix(in srgb,var(--pl-accent) 10%,transparent);padding:0 4px;border-radius:3px}.rs{font-size:12px;font-weight:600;display:inline-flex;align-items:center;gap:5px;white-space:nowrap}.rs.ok{color:var(--pl-pos)}.rs.fail{color:var(--pl-warn)}.rs.wait{color:var(--pl-muted)}.chg-ft{display:flex;gap:8px;justify-content:flex-end;flex-wrap:wrap;align-items:center}.cf{display:grid;gap:6px}.cf-row{display:grid;grid-template-columns:78px minmax(0,1fr) minmax(0,1fr);gap:6px;align-items:stretch;font-size:12.5px}.cf-row>span{color:var(--pl-muted);padding-top:9px}.opt{display:grid;gap:1px;border:1px solid var(--pl-border);border-radius:8px;padding:7px 10px 7px 30px;position:relative;cursor:pointer;overflow-wrap:anywhere}.opt input{position:absolute;left:9px;top:10px;margin:0;accent-color:var(--pl-accent)}.opt small{font-size:11px;color:var(--pl-muted);font-weight:600;letter-spacing:.02em;text-transform:uppercase}.opt:has(input:checked){border-color:var(--pl-accent);background:color-mix(in srgb,var(--pl-accent) 6%,var(--pl-bg))}.cf-lost{font-size:12.5px;color:var(--pl-muted);margin:0}.confirm{display:flex;gap:8px;align-items:center;flex-wrap:wrap;font-size:12.5px}@media(max-width:559px){.cf-row{grid-template-columns:minmax(0,1fr) minmax(0,1fr)}.cf-row>span{grid-column:1 / -1;padding-top:0}}@media(max-width:719px){.hd{padding:10px 12px;gap:8px}.hd-act{gap:6px}.hd .pill{padding:0 8px}.hd-chips{flex:none;order:3;width:100%}.cbar{padding-inline:12px}.cbar-last{display:none}.tb{padding:10px 12px 6px;gap:4px}.tb .icon-btn{width:26px}.tb-title{font-size:15px;margin-right:4px}}';function je(e,t,n={},r=globalThis.__USERTEST_REPORT__){if(typeof r=="function")try{r({source:"calendar",level:e,message:t,...n})}catch{}}var _e=e=>e instanceof Error?e.stack:void 0;function kn(e){e.addEventListener("error",t=>je("error",t.message,{kind:"uncaught",stack:_e(t.error)})),e.addEventListener("unhandledrejection",t=>je("error",t.reason instanceof Error?t.reason.message:String(t.reason),{kind:"unhandled-rejection",stack:_e(t.reason)}))}var Tr=(e,t,n=`${t}s`)=>`${e} ${e===1?t:n}`;var Ve=e=>e instanceof Error?e.message:String(e),ft=e=>typeof e?.status!="number"&&/connect again|reconnect/i.test(Ve(e));function Dr(e){let t=Ve(e),n=e??{},r=typeof n.status=="number"?n.status:Number(/returned (\d{3})\b/.exec(t)?.[1])||void 0,o={message:t,...r?{status:r}:{}};if(/may or may not have applied/.test(t))return{kind:"uncertain",...o};if(n.reconnect===!0||ft(e)||r===401||/refused the request \((capability_expired|unsupported_authorization)\b/.test(t))return{kind:"reauth",...o};if(/The integration proxy refused/.test(t))return{kind:"refused",...o};if(r===403)return{kind:"forbidden",...o};if(r===404||r===410)return{kind:"not-found",...o};if(r===429){let i=Number(n.retryAfter);return{kind:"rate-limited",...o,...Number.isFinite(i)&&i>0?{retryAfter:Math.ceil(i)}:{}}}return/at most [\d,]+ events per scan/.test(t)?{kind:"too-many-events",...o}:r&&r>=500||/fetch|network|timed? ?out|did not answer/i.test(t)?{kind:"network",...o}:{kind:"other",...o}}var oe="Nothing here was changed.";function Cn(e,t="this calendar",n=e.retryAfter){switch(e.kind){case"reauth":return{tone:"neg",role:"alert",title:"Google access has expired.",body:oe,action:{label:"Reconnect",does:"reconnect"}};case"forbidden":return{tone:"neg",role:"alert",title:`Google refused access to ${t}.`,body:`You may have lost access to this calendar. ${oe}`,action:{label:"Retry",does:"retry"}};case"not-found":return{tone:"neg",role:"status",title:`Calendar ${t} no longer exists or isn\u2019t shared with you.`,body:oe,action:{label:"Retry",does:"retry"}};case"rate-limited":return{tone:"warn",role:"status",title:n?`Google is limiting requests. Retrying in ${n} s.`:"Google is limiting requests.",body:oe,action:{label:"Retry now",does:"retry"}};case"network":return{tone:"info",role:"status",title:"Couldn\u2019t reach Google.",body:oe,action:{label:"Retry",does:"retry"}};case"too-many-events":return{tone:"warn",role:"status",title:`${t} has more than 25,000 events, so the import stopped.`,body:oe};case"uncertain":return{tone:"warn",role:"alert",title:"Google may or may not have applied the last change.",body:"The rest were not sent. Sync to see what Google has now.",action:{label:"Sync now",does:"retry"}};case"refused":return{tone:"neg",role:"alert",title:"The integration proxy refused this request.",body:`Google was not asked. ${oe}`,action:{label:"Retry",does:"retry"}};case"other":return{tone:"neg",role:"status",title:"Something went wrong while syncing.",body:oe,action:{label:"Retry",does:"retry"}}}}var Pr=e=>e.toLocaleTimeString("en-GB",{hour:"2-digit",minute:"2-digit"});function mt(e,t=new Date){let n=Math.floor((t.getTime()-e.getTime())/6e4);return n<1?"just now":n<60?`${n} min ago`:`at ${Pr(e)}`}function ht(e){let t=e.stale?0:e.summary?.review.length??0;return Math.max(t,e.pending)}function En(e,t=new Date){let{state:n,summary:r,at:o}=e;if(n.kind==="refreshing"||n.kind==="loading")return{text:"Syncing\u2026",tone:"accent",busy:!0};if(n.kind==="sending")return{text:"Sending\u2026",tone:"accent",busy:!0};if(n.kind==="error")return n.problem.kind==="reauth"||n.reconnect?{text:"Reconnect needed",tone:"neg",opens:"details"}:{text:"Error",tone:"neg",opens:"details"};let i=r?.conflicts.length??0;if(i)return{text:Tr(i,"conflict"),tone:"warn",opens:"conflicts"};let s=ht(e);return s?{text:`${s} to review`,tone:"accent",opens:"review"}:{text:o?`Synced ${mt(o,t)}`:"Not synced yet",tone:"muted"}}function $n(e,t,n={}){let r={kind:"loading"},o=[],i,s,l=[],c,f=!1,d=h=>{r=h,t(r)},w=()=>t(r);async function m(h){for(;;){let y=o.at(-1);if(!y)throw Object.assign(new Error("Reconnect Google Calendar."),{reconnect:!0});try{return await h(y.connectionId)}catch(C){if(!ft(C))throw C;o=o.slice(0,-1)}}}let u=(h,y)=>{let C=Dr(h);je("error",Ve(h),{problem:C.kind,...C.status?{status:C.status}:{},stack:_e(h)}),d({kind:"error",message:Ve(h),reconnect:o.length===0||h.reconnect===!0||ft(h),problem:C,...y?{outcomes:y}:{},...c?{summary:c.summary,at:c.at}:{}})},b=async()=>{s&&(l=await Ut(e,s,c?.summary.conflicts??[]))},p=async h=>{try{let y=await m(D=>Ze(h,D)),C=y.find(D=>D.id===i);if(!C)return;let T=y.find(D=>D.primary)?.id;s={summary:C.summary,color:C.backgroundColor??Ce,accessRole:C.accessRole,...T?{account:T}:{}},await Yt(e,s),await b(),w()}catch{}},x=async h=>{if(f=!0,c){let y={...c.summary,conflicts:c.summary.conflicts.filter(C=>C!==h)};c={...c,summary:y},(r.kind==="ready"||r.kind==="error")&&(r={...r,summary:y})}await b(),w()},v={state:()=>r,snapshot(){return{state:r,...s?{meta:s}:{},events:l,...c?{summary:c.summary,at:c.at}:{},pending:l.filter(h=>h.pending).length,stale:f,can:{openExternal:typeof e.openExternal=="function",openResource:typeof e.openResource=="function",disconnect:typeof e.proxy?.disconnect=="function"}}},async load(){let h=e.proxy;if(!h)return d({kind:"no-relay"}),{};if(o=await h.connections({platform:ve}),!o.length)return d({kind:"disconnected"}),{};let y=await Gt(e);if(!y)return await v.listCalendars(),{};i=y.id,s=y.meta??{summary:"Google Calendar",color:Ce,accessRole:"owner"},await b();let C=v.refresh();return{refreshing:y.meta?C:C.then(()=>p(h))}},async listCalendars(){let h=e.proxy;if(!h)return d({kind:"no-relay"});d({kind:"refreshing"});try{let y=await m(C=>Ze(h,C));d({kind:"choosing",calendars:y})}catch(y){u(y)}},async choose(h){if(r.kind!=="choosing")return;let y=r.calendars.find(T=>T.id===h);if(!y)return;let C=r.calendars.find(T=>T.primary)?.id;try{s=await It(e,y,C),i=y.id}catch(T){return u(T)}await v.refresh()},async connect(){let h=e.proxy;if(!h)return d({kind:"no-relay"});d({kind:"connecting"});try{(await h.connect({platform:ve}))?.status==="connected"?await v.load():d({kind:"disconnected"})}catch(y){u(y)}},cancelConnect(){r.kind==="connecting"&&d({kind:"disconnected"})},async refresh(){let h=e.proxy;if(!h)return d({kind:"no-relay"});if(!(r.kind==="refreshing"||r.kind==="sending")){if(!i)return v.listCalendars();d({kind:"refreshing",...c?{summary:c.summary}:{}});try{let y=await m(C=>Ft(e,h,C,{...n,onPage:T=>{r.kind==="refreshing"&&d({...r,pages:T})}}));f=!1,c={summary:y,at:new Date},je(y.unreadable.length?"warn":"info","Sync finished",{total:y.total,added:y.added,updated:y.updated,unchanged:y.unchanged,skipped:y.skipped,conflicts:y.conflicts.length,review:y.review.length,unreadable:y.unreadable.map(({id:C,reason:T})=>({id:C,reason:T}))}),await b(),d({kind:"ready",at:c.at,summary:y,outcomes:[]})}catch(y){await b().catch(()=>{}),u(y)}}},async prepareReview(){(f||!c||r.kind==="error")&&await v.refresh()},async send(){let h=e.proxy;if(!h||r.kind!=="ready"||!r.summary.review.length)return;let{summary:y,at:C}=r,T=y.review.map(()=>{});d({kind:"sending",summary:y,progress:[...T]});let D;try{D=await m(_=>Bt(e,h,_,y.calendarId,y.review,(Z,B)=>{T[Z]=B??"sending",r.kind==="sending"&&d({...r,progress:[...T]})}))}catch(_){return await b().catch(()=>{}),u(_)}c={at:C,summary:{...y,review:[]}},await b();let j=D.find(_=>_.status==="uncertain");if(j&&j.status==="uncertain")return u(new Error(j.message),D);d({kind:"ready",at:C,summary:c.summary,outcomes:D})},async saveEvent(h,y){await Jt(e,h,y),f=!0,await b(),w()},async discard(h){if(await qt(e,h),c){let y={...c.summary,review:c.summary.review.filter(C=>C!==h)};c={...c,summary:y},r.kind==="ready"&&(r={...r,summary:y})}await b(),w()},async openLink(h){return!h.link||!e.openExternal?"unavailable":(await e.openExternal(h.link)).status},async openInHost(h){return e.openResource?(await e.openResource(h??await Kt(e)),!0):!1},async disconnect(){let h=e.proxy;if(h?.disconnect){try{await h.disconnect({platform:ve})}catch(y){return u(y)}o=[],d({kind:"disconnected"})}},async resolve(h,y){await Wt(e,h,y),await x(h)},async keepAsLocal(h){h.subject&&(await Zt(e,h.subject),await x(h))},async removeLocal(h){h.subject&&(await Xt(e,h.subject),await x(h))}};return v}var bt="Guests, reminders and video links are edited in Google Calendar.";function Tn(e,t,n,r,...o){let{doc:i}=e;return a(i,"section",{class:"drawer",role:"dialog","aria-modal":r?"true":void 0,"aria-labelledby":"drawer-title"},a(i,"div",{class:"dr-hd"},a(i,"span",{class:"sw",style:`--c:${t.calendar.color}`,"aria-hidden":"true"}),t.calendar.name,a(i,"button",{class:"icon-btn","aria-label":"Close","data-key":"drawer-close",onclick:n},G(i,I.close))),...o)}function Dn(e,t,{narrow:n,onClose:r,onEdit:o,onReview:i,onConflicts:s,onOpenLink:l}){let{doc:c}=e,f=on(t,e.zone),d=t.pending?sn(t,e.zone):void 0,w=[["When",[a(c,"span",{},f.day),f.time?a(c,"b",{},f.time):null,f.own?a(c,"span",{class:"muted"},f.own):null].filter(m=>!!m)]];return t.location&&w.push(["Where",[c.createTextNode(t.location)]]),t.description&&w.push(["Notes",[c.createTextNode(t.description)]]),Tn(e,t,r,n,a(c,"h2",{id:"drawer-title",tabindex:"-1"},t.title||"(untitled)"),t.pending?a(c,"div",{class:"saved",role:"status"},a(c,"span",{class:"dot-accent","aria-hidden":"true"}),a(c,"div",{},a(c,"b",{},"Saved here \xB7 not sent to Google yet"),d?a(c,"span",{},d):null),a(c,"button",{class:"btn btn-primary btn-sm","data-key":"drawer-review",onclick:i},"Review changes")):null,t.conflict?a(c,"div",{class:"conflict-note"},a(c,"span",{},"Changed here and in Google since the last sync."),a(c,"button",{class:"btn btn-sm","data-key":"drawer-conflicts",onclick:s},"Decide")):null,a(c,"dl",{class:"dr-f"},...w.map(([m,u])=>a(c,"div",{},a(c,"dt",{},m),a(c,"dd",{},...u)))),a(c,"p",{class:"dr-hint"},bt),a(c,"div",{class:"dr-ft"},l?a(c,"button",{class:"btn btn-ghost","data-key":"drawer-open-google",onclick:l},"Open in Google Calendar \u2197"):null,t.readOnly?a(c,"span",{class:"fine"},t.id?"This calendar is read-only for you: edit this event in Google Calendar.":"Made in this table: it is not sent to Google (creating events isn\u2019t supported)."):l?null:a(c,"span",{}),t.readOnly?null:a(c,"button",{class:"btn btn-primary","data-key":"drawer-edit",onclick:o},"Edit")))}function Mr(e,t){if(e.allDay)return{title:e.title,allDay:!0,startDate:e.start,startTime:"09:00",endDate:M(e.end,-1),endTime:"10:00",location:e.location,description:e.description};let n=N(Date.parse(e.start),t),r=N(Date.parse(e.end),t);return{title:e.title,allDay:!1,startDate:n.date,startTime:R(n.minutes),endDate:r.date,endTime:R(r.minutes),location:e.location,description:e.description}}function Sn(e,t){let n={};e.title.trim()||(n.title="Add a title"),ie(e.startDate)||(n.start="Enter a start date"),ie(e.endDate)||(n.end="Enter an end date");let r="",o="";if(!n.start&&!n.end)if(e.allDay)r=e.startDate,o=M(e.endDate,1),o<=r&&(n.end="End must be after start");else{let i=lt(e.startTime),s=lt(e.endTime);i===void 0&&(n.start="Enter a start time"),s===void 0&&(n.end="Enter an end time"),i!==void 0&&s!==void 0&&(r=ct(e.startDate,i,t),o=ct(e.endDate,s,t),Date.parse(o)<=Date.parse(r)&&(n.end="End must be after start"))}return Object.keys(n).length?{errors:n}:{value:{title:e.title,description:e.description,location:e.location,start:r,end:o,allDay:e.allDay},errors:n}}function Pn(e,t,{narrow:n,onClose:r,onCancel:o,onSave:i}){let{doc:s,zone:l}=e,c=Mr(t,l),f=!1,d=!1,w,m=Tn(e,t,r,n),u=a(s,"div",{class:"form"});m.append(u);let b=(O,H)=>a(s,"input",{...H,value:String(c[O]),"data-key":`f-${O}`,oninput:U=>{c[O]=U.target.value,f=!0,se()}}),p=b("title",{type:"text",id:"f-title"}),x=a(s,"input",{type:"checkbox",role:"switch",checked:c.allDay,"data-key":"f-allDay",onchange:O=>{c.allDay=O.target.checked,f=!0,Oe()}}),v=b("startDate",{type:"date","aria-label":"Start date"}),h=b("startTime",{type:"time",class:"t","aria-label":"Start time"}),y=b("endDate",{type:"date","aria-label":"End date"}),C=b("endTime",{type:"time",class:"t","aria-label":"End time"}),T=b("location",{type:"text",id:"f-location"}),D=a(s,"textarea",{id:"f-description",rows:3,"data-key":"f-description",oninput:O=>{c.description=O.target.value}});D.value=c.description;let j=a(s,"p",{class:"err",id:"err-title",role:"alert"}),_=a(s,"p",{class:"err",id:"err-start",role:"alert"}),Z=a(s,"p",{class:"err",id:"err-end",role:"alert"}),B=a(s,"p",{class:"err",role:"alert"}),K=a(s,"button",{class:"btn btn-primary","data-key":"drawer-save",onclick:async()=>{f=!0;let{value:O}=Sn(c,l);if(se(),!(!O||d)){d=!0,K.setAttribute("disabled","");try{await i(O)}catch(H){w=H instanceof Error?H.message:String(H),d=!1,K.removeAttribute("disabled"),se()}}}},"Save here"),X=(O,H,U)=>{O.textContent=U??"",O.hidden=!U;for(let q of H)q.classList.toggle("is-bad",!!U),U?q.setAttribute("aria-invalid","true"):q.removeAttribute("aria-invalid")};function se(){let{errors:O}=Sn(c,l),H=f?O:{};X(j,[p],H.title),X(_,[v,h],H.start),X(Z,c.allDay?[y]:[y,C],H.end),c.allDay&&(h.classList.remove("is-bad"),C.classList.remove("is-bad")),X(B,[],w),Object.keys(O).length&&f?K.setAttribute("disabled",""):d||K.removeAttribute("disabled")}function Oe(){let O=`${l.split("/").at(-1)?.replace(/_/g," ")}, ${Ge(e.now,l)}`,H=(U,q,Le)=>a(s,"div",{class:`fld-2${c.allDay?" all-day":""}`,role:"group","aria-label":U},a(s,"span",{},U),q,c.allDay?null:Le);u.replaceChildren(a(s,"h2",{id:"drawer-title",class:"sr-only",tabindex:"-1"},`Edit ${t.title}`),a(s,"label",{class:"fld",for:"f-title"},"Title",p),j,a(s,"label",{class:"sw-row"},x,"All day"),H("Starts",v,h),_,H(c.allDay?"Ends (last day)":"Ends",y,C),Z,a(s,"label",{class:"fld",for:"f-location"},"Location",T),a(s,"label",{class:"fld",for:"f-description"},"Description",D),a(s,"p",{class:"dr-hint"},c.allDay?bt:`Times are in your zone (${O}). ${bt}`),B,a(s,"div",{class:"dr-ft"},a(s,"button",{class:"btn btn-ghost","data-key":"drawer-cancel",onclick:o},"Cancel"),K)),se()}return Oe(),m}function Mn(e,t){let n=t.tone==="muted"?"":` pill-${t.tone}`,r=[t.busy?a(e,"span",{class:"spin","aria-hidden":"true"}):null,a(e,"span",{role:"status"},t.text)];return t.onClick?a(e,"button",{class:`pill${n}`,"data-key":"pill",onclick:t.onClick},...r):a(e,"span",{class:`pill${n}`},...r)}function jn(e,{title:t,chips:n,status:r,action:o}){return a(e,"div",{class:"hd"},a(e,"div",{class:"hd-id"},a(e,"span",{class:"mark"},G(e,I.calendar)),a(e,"h1",{},t)),n?a(e,"div",{class:"hd-chips"},n):null,a(e,"div",{class:"hd-act"},r,o))}function yt(e,{provider:t,account:n,detail:r,busy:o,menu:i,menuOpen:s,onMenu:l}){let c=i??[];return a(e,"div",{class:`cbar${o?" is-busy":""}`},a(e,"span",{class:"gmark"},G(e,I.google)),a(e,"span",{class:"cbar-acc"},t,n?" \xB7 ":null,n?a(e,"b",{},n):null,r?a(e,"span",{class:"cbar-last"},` \xB7 ${r}`):null),c.length?a(e,"span",{class:"cbar-menu"},a(e,"button",{class:"icon-btn","aria-label":"Connection menu","aria-haspopup":"menu","aria-expanded":s?"true":"false","data-key":"cbar-menu",onclick:()=>l?.(!s)},G(e,I.more)),s?a(e,"div",{class:"menu",role:"menu"},...c.map((f,d)=>a(e,"button",{role:"menuitem","data-key":`menu-${d}`,disabled:f.disabled,onclick:()=>{l?.(!1),f.onSelect()}},f.label,f.hint?a(e,"small",{},f.hint):null))):null):null)}function wt(e,t){return a(e,"div",{class:`banner b-${t.tone}`,role:t.role},a(e,"span",{class:"bi","aria-hidden":"true"},t.tone==="info"?"i":"!"),a(e,"div",{class:"bt"},a(e,"b",{},t.title),a(e,"span",{},t.body),t.details?a(e,"details",{},a(e,"summary",{},"Details"),a(e,"code",{},t.details)):null),t.action?a(e,"button",{class:`btn btn-sm${t.role==="alert"&&t.tone==="neg"?" btn-primary":""}`,"data-key":`banner-${t.action.label}`,"aria-label":t.action.name,onclick:t.action.onClick},t.action.label):null)}function Re(e,{title:t,text:n,muted:r,children:o}){return a(e,"div",{class:"center"},a(e,"div",{class:"card"},a(e,"div",{class:`big-ic${r?" muted-ic":""}`},G(e,I.calendar)),a(e,"h2",{},t),n?a(e,"p",{},n):null,...o??[]))}function Rn(e,t,n,r,o){return a(e,"div",{class:"segs",role:"group","aria-label":t},...n.map(i=>a(e,"button",{class:"seg","aria-pressed":i.value===r?"true":"false","data-key":`seg-${i.value}`,onclick:()=>o(i.value)},i.label)))}function fe(e,{id:t,title:n,subtitle:r,onClose:o,body:i,footer:s}){let l=`${t}-title`;return a(e,"div",{class:"scrim",onclick:c=>{c.target===c.currentTarget&&o()}},a(e,"section",{class:"sheet",role:"dialog","aria-modal":"true","aria-labelledby":l,"data-sheet":t},a(e,"div",{class:"sheet-hd"},a(e,"div",{},a(e,"h2",{id:l,tabindex:"-1"},n),r?a(e,"p",{},r):null),a(e,"button",{class:"icon-btn","aria-label":"Close","data-key":`${t}-close`,onclick:o},G(e,I.close))),...i,s?a(e,"div",{class:"sheet-ft"},...s):null))}function On(e){return Re(e,{muted:!0,title:"Calendars can\u2019t be reached from this server yet",text:"This Atomic Server can\u2019t reach calendar providers on your behalf, so nothing was fetched. Ask the server\u2019s administrator to enable the integration proxy."})}var jr=[{name:"Outlook Calendar",mark:"O",color:"#0078d4"},{name:"Apple Calendar",mark:"",color:"#8e8e93"}];function Ln(e,{connecting:t,onConnect:n,onCancel:r}){let o=a(e,"li",{},a(e,"span",{class:"pl",style:"background:#4285f4","aria-hidden":"true"},"G"),a(e,"span",{class:"pn"},"Google Calendar",a(e,"span",{},"Read events, send reviewed edits back")),t?a(e,"span",{class:"spin",style:"color:var(--pl-accent)","aria-hidden":"true"}):a(e,"button",{class:"btn btn-primary btn-sm","data-key":"connect","aria-label":"Connect Google Calendar",onclick:n},"Connect"));return Re(e,{title:"Bring your calendar into Atomic",text:"Events are copied into this table. Nothing is sent to Google until you review it.",children:[a(e,"ul",{class:"prov","aria-label":"Calendar providers"},o,...jr.map(i=>a(e,"li",{class:"off","aria-disabled":"true"},a(e,"span",{class:"pl",style:`background:${i.color}`,"aria-hidden":"true"},i.mark),a(e,"span",{class:"pn"},i.name,a(e,"span",{},"Not available yet"))))),t?a(e,"p",{class:"waiting",role:"status"},"Confirm the connection in the bar above\u2026",a(e,"button",{class:"link","data-key":"cancel-connect",onclick:r},"Cancel")):a(e,"p",{class:"fine"},"Recurring events aren\u2019t imported yet.")]})}var Rr={owner:"Owner",writer:"Can edit",reader:"View only",freeBusyReader:"Free/busy only"};function An(e,{calendars:t,onImport:n}){let r=t.find(s=>s.primary)?.id??t[0]?.id??void 0,o=a(e,"ul",{class:"pick"},...t.map(s=>{let l=Ee(s.accessRole),c=[s.primary?s.id:void 0,Rr[s.accessRole]??s.accessRole].filter(Boolean).join(" \xB7 "),f=a(e,"input",{type:"radio",name:"calendar",value:s.id,checked:s.id===r,"data-key":`pick-${s.id}`,"aria-describedby":l?`ro-${s.id}`:void 0});return a(e,"li",{},a(e,"label",{},f,a(e,"span",{class:"sw",style:`--c:${s.backgroundColor??"#4986e7"}`,"aria-hidden":"true"}),a(e,"span",{class:"pn"},s.summary,a(e,"span",{},c)),l?a(e,"em",{class:"tag",id:`ro-${s.id}`},"Read-only"):null))}));return a(e,"div",{class:"panel",role:"form","aria-label":"Choose a calendar"},a(e,"h2",{},"Which calendar should come in?"),a(e,"p",{},"This app keeps one calendar. For a second calendar, add another Calendar app. Read-only calendars are imported, but their events can\u2019t be edited here."),t.length?o:a(e,"p",{class:"fine"},"Google listed no calendars for this account."),a(e,"div",{class:"panel-ft"},a(e,"span",{class:"fine"},"Recurring events aren\u2019t imported yet."),a(e,"button",{class:"btn btn-primary","data-key":"import",disabled:!t.length,onclick:()=>{let s=o.querySelector('input[name="calendar"]:checked');s&&n(s.value)}},"Import this calendar")))}function Nn(e,{calendar:t,color:n,pages:r}){return a(e,"div",{"aria-busy":"true"},a(e,"div",{class:"prog"},a(e,"div",{class:"prog-row"},a(e,"span",{class:"sw",style:`--c:${n}`,"aria-hidden":"true"}),a(e,"span",{class:"bar","aria-hidden":"true"},a(e,"i",{})),a(e,"span",{class:"n"},a(e,"b",{},t),r?` \xB7 ${r} ${r===1?"page":"pages"} of events read`:" \xB7 reading events\u2026"))),a(e,"div",{class:"skel","aria-hidden":"true"},a(e,"div",{class:"sk h"}),a(e,"div",{class:"sk"}),a(e,"div",{class:"sk"}),a(e,"div",{class:"sk h"}),a(e,"div",{class:"sk"})))}var me=(e,t,n=`${t}s`)=>`${e} ${e===1?t:n}`,Or=Object.fromEntries(Object.entries(F).map(([e,t])=>[t,e]));function zn(e,t){let n=pn(e,t);if(!n)return"";if(e.allDay)return J(e.start);let r=N(n.start,t);return`${J(r.date)} \xB7 ${R(r.minutes)}`}function Hn(e,t){if(!t)return null;if(t==="sending")return a(e,"span",{class:"rs wait"},a(e,"span",{class:"spin","aria-hidden":"true"}),"Sending\u2026");switch(t.status){case"sent":return a(e,"span",{class:"rs ok"},"\u2713 Sent");case"stale":return a(e,"span",{class:"rs fail"},"! Changed in Google");case"uncertain":return a(e,"span",{class:"rs fail"},"! Unknown whether Google applied it");case"failed":return a(e,"span",{class:"rs fail"},"! Google refused it");case"not-sent":return a(e,"span",{class:"rs wait"},"Not sent")}}var Lr={sent:"Sent",stale:"Changed in Google since this preview; not sent",uncertain:"Unknown whether Google applied it",failed:"Google refused it","not-sent":"Not sent, because an earlier change\u2019s outcome is unknown"};function Gn(e,{summary:t,progress:n,outcomes:r,color:o,busy:i,onClose:s,onDiscard:l,onSend:c,onReviewAgain:f}){let{doc:d,zone:w}=e,m=t.review,u=!!n,b=[];if(t.invalid.length&&b.push(a(d,"p",{class:"fine"},`Not sent: ${t.invalid.map(p=>`${p.title.trim()||"(untitled)"} (${p.reason})`).join("; ")}.`)),t.localOnly&&b.push(a(d,"p",{class:"fine"},`${me(t.localOnly,"row")} made in this table won\u2019t be sent: creating events isn\u2019t supported.`)),!m.length&&r.length){let p=r.filter(v=>v.status==="sent").length,x=r.filter(v=>v.status==="stale");return fe(d,{id:"review",title:`${p} of ${me(r.length,"change")} sent`,...x.length?{subtitle:`${x.map(v=>v.title).join(", ")} ${x.length===1?"was":"were"} changed in Google after you reviewed ${x.length===1?"it":"them"}, so ${x.length===1?"it was":"they were"} not overwritten.`}:{},onClose:s,body:[a(d,"ul",{class:"chg","aria-label":"Sent changes"},...r.map(v=>a(d,"li",{},a(d,"div",{class:"chg-hd"},a(d,"span",{class:"sw",style:`--c:${o}`,"aria-hidden":"true"}),a(d,"b",{},v.title),Hn(d,v),a(d,"span",{class:"sr-only"},`${v.title}: ${Lr[v.status]}`)),v.status==="stale"?a(d,"div",{class:"chg-ft"},a(d,"button",{class:"btn btn-primary btn-sm","data-key":`again-${v.title}`,onclick:f},"Review again")):v.status==="failed"||v.status==="uncertain"?a(d,"p",{class:"cf-lost"},v.message):null)))],footer:[a(d,"p",{},"Only these fields were sent."),a(d,"button",{class:"btn","data-key":"review-done",onclick:s},"Done")]})}return m.length?fe(d,{id:"review",title:`Send ${me(m.length,"change")} to Google Calendar`,subtitle:"Only the fields shown change. Everything else on these events is left as it is in Google.",onClose:s,body:[a(d,"ul",{class:"chg","aria-label":"Review changes"},...m.map((p,x)=>a(d,"li",{},a(d,"div",{class:"chg-hd"},a(d,"span",{class:"sw",style:`--c:${o}`,"aria-hidden":"true"}),a(d,"b",{},p.title),n?.[x]?Hn(d,n[x]):a(d,"span",{class:"when"},zn(p.remote,w))),a(d,"dl",{class:"diff"},...p.fields.flatMap(v=>{let h=Or[v.field],y=h?ge(h,p.remote,w):v.before,C=h?ge(h,p.desired,w):v.after;return[a(d,"dt",{},v.field),a(d,"dd",{},a(d,"del",{},y),a(d,"span",{"aria-hidden":"true"},"\u2192"),a(d,"span",{class:"sr-only"}," becomes "),a(d,"ins",{},C))]})),u?null:a(d,"div",{class:"chg-ft"},a(d,"button",{class:"btn btn-sm","data-key":`discard-${x}`,"aria-label":`Discard the change to ${p.title}`,onclick:()=>l(p)},"Discard"))))),...b],footer:[a(d,"p",{},"Guests on these events are not emailed about these changes."),a(d,"button",{class:"btn btn-primary","data-key":"send",disabled:u||i,"aria-label":`Send ${me(m.length,"change")} to Google`,onclick:c},u?"Sending\u2026":`Send ${me(m.length,"change")}`)]}):fe(d,{id:"review",title:i?"Checking your changes\u2026":"Nothing to send",subtitle:i?"Reading your calendar to see what changed here\u2026":"No event here differs from Google Calendar.",onClose:s,body:b})}function In(e,{list:t,color:n,choices:r,confirming:o,errors:i,onClose:s,onChoose:l,onResolve:c,onKeep:f,onRemove:d,onConfirm:w,onOpenRow:m}){let{doc:u,zone:b}=e,p=(x,v)=>{let h=x.local??x.remote,y=a(u,"div",{class:"chg-hd"},a(u,"span",{class:"sw",style:`--c:${n}`,"aria-hidden":"true"}),a(u,"b",{},x.title),h?a(u,"span",{class:"when"},zn(h,b).split(" \xB7 ")[0]):null);if(x.kind==="both"&&x.local&&x.remote){let C=r.get(x)??{},T=x.fields,D=T.every(j=>C[j]);return a(u,"li",{},y,a(u,"div",{class:"cf"},...T.map(j=>{let _=`cf-${v}-${j}`,Z=(B,K,X)=>a(u,"label",{class:"opt"},a(u,"input",{type:"radio",name:_,checked:C[j]===B,"data-key":`${_}-${B}`,onchange:()=>l(x,j,B)}),a(u,"small",{},K),ge(j,X,b));return a(u,"div",{class:"cf-row",role:"radiogroup","aria-label":F[j]??j},a(u,"span",{},F[j]??j),Z("mine","Keep mine",x.local),Z("google","Use Google\u2019s",x.remote))})),i.get(x)?a(u,"p",{class:"err",role:"alert"},i.get(x)):null,a(u,"div",{class:"chg-ft"},a(u,"button",{class:"btn btn-primary btn-sm",disabled:!D,"data-key":`resolve-${v}`,onclick:()=>c(x)},"Resolve")))}return x.kind==="missing-remote"?a(u,"li",{},y,a(u,"p",{class:"cf-lost"},"This event is no longer in Google Calendar \u2014 it was cancelled, became recurring, or you lost access. Your copy here was not deleted."),o===x?a(u,"div",{class:"chg-ft confirm",role:"group","aria-label":"Confirm removal"},a(u,"span",{},"Remove the copy in this table? This can\u2019t be undone."),a(u,"button",{class:"btn btn-sm","data-key":`cancel-remove-${v}`,onclick:()=>w(void 0)},"Cancel"),a(u,"button",{class:"btn btn-sm btn-neg","data-key":`confirm-remove-${v}`,onclick:()=>d(x)},"Remove")):a(u,"div",{class:"chg-ft"},a(u,"button",{class:"btn btn-sm","data-key":`remove-${v}`,onclick:()=>w(x)},"Remove local copy"),a(u,"button",{class:"btn btn-sm","data-key":`keep-${v}`,onclick:()=>f(x)},"Keep as local event"))):a(u,"li",{},y,a(u,"p",{class:"cf-lost"},`The row this Google event was imported into is missing or bound to another event${x.id?` (Google event id ${x.id})`:""}. Nothing was changed automatically; fix the row in the table, then sync again.`),m?a(u,"div",{class:"chg-ft"},a(u,"button",{class:"btn btn-sm","data-key":`open-row-${v}`,onclick:()=>m(x)},x.subject?"Open row in table":"Open the table")):null)};return fe(u,{id:"conflicts",title:t.length?`${me(t.length,"event")} ${t.length===1?"needs":"need"} a decision`:"No conflicts",subtitle:t.length?"These changed both here and in Google since the last sync, or are gone from Google. Nothing is overwritten until you choose.":"Everything here agrees with Google, or is waiting for review.",onClose:s,body:[a(u,"ul",{class:"chg","aria-label":"Conflicts"},...t.map(p))]})}var Ar=[[["t"],"Today"],[["\u2190","\u2192"],"Previous or next period"],[["k","j"],"Previous or next period"],[["a"],"Agenda"],[["w"],"Week"],[["m"],"Month, in the table\u2019s Calendar view (opens in Atomic)"],[["Enter"],"Open the focused event"],[["e"],"Edit the open or focused event"],[["Esc"],"Close the drawer, sheet or menu"],[["?"],"This list"]];function _n(e,t){let{doc:n}=e;return fe(n,{id:"shortcuts",title:"Keyboard shortcuts",subtitle:"They work when focus is in the calendar and not in a text field.",onClose:t,body:[a(n,"dl",{class:"kbd"},...Ar.flatMap(([r,o])=>[a(n,"dt",{},...r.flatMap((i,s)=>[s?" ":null,a(n,"kbd",{},i)])),a(n,"dd",{},o)]))]})}function Vn(e,{month:t,selected:n,weekFrom:r,weekDays:o,meta:i,visible:s,summary:l,whyOpen:c,onMonth:f,onVisible:d,onWhy:w}){let{doc:m}=e,u=`${t.slice(0,7)}-01`,b=te(u),p=M(u,32).slice(0,7),x=M(u,-1).slice(0,7),v=M(r,o),h=["M","T","W","T","F","S","S"].map(T=>a(m,"span",{class:"mm-w","aria-hidden":"true"},T));for(let T=0;T<42;T++){let D=M(b,T);if(T>=35&&D.slice(0,7)!==t.slice(0,7))break;let j=["mm-d",D.slice(0,7)!==t.slice(0,7)?"out":"",D>=r&&De.goTo(D)},String(Number(D.slice(8)))))}let y=l?.skipped,C=Ee(i.accessRole);return a(m,"aside",{class:"side","aria-label":"Calendars"},a(m,"div",{},a(m,"div",{class:"mm-hd"},a(m,"b",{},De(u)),a(m,"span",{},a(m,"button",{class:"icon-btn",style:"width:24px;height:24px","aria-label":"Previous month","data-key":"mm-prev",onclick:()=>f(`${x}-01`)},G(m,I.prev)),a(m,"button",{class:"icon-btn",style:"width:24px;height:24px","aria-label":"Next month","data-key":"mm-next",onclick:()=>f(`${p}-01`)},G(m,I.next)))),a(m,"div",{class:"mm-g",role:"group","aria-label":De(u)},...h)),a(m,"section",{class:"side-sec"},a(m,"h2",{},"My calendars"),a(m,"ul",{class:"cals"},a(m,"li",{},a(m,"label",{},a(m,"input",{type:"checkbox",checked:s,"data-key":"cal-visible",onchange:T=>d(T.target.checked)}),a(m,"span",{class:"sw",style:`--c:${i.color}`,"aria-hidden":"true"}),a(m,"span",{class:"nm"},i.summary),C?a(m,"em",{class:"tag"},"Read-only"):null)))),y&&xt(y)?a(m,"section",{class:"side-sec note"},a(m,"h2",{},"Not shown"),a(m,"p",{},vt(y,l?.unreadable)," ",a(m,"button",{class:"link","aria-expanded":c?"true":"false","data-key":"why",onclick:w},"Why?")),c?a(m,"p",{},"Recurring events aren\u2019t imported yet, so a series is never mapped in part. Cancelled events, and events whose start or end this app can\u2019t read, are counted, never treated as a deletion here."):null):null)}function xt(e){return!!(e.recurring||e.cancelled||e.unreadable)}function vt(e,t=[]){let n=[];if(e.recurring&&n.push(`${e.recurring} recurring ${e.recurring===1?"event":"events"}`),e.cancelled&&n.push(`${e.cancelled} cancelled ${e.cancelled===1?"event":"events"}`),e.unreadable){let i=t.slice(0,3).map(s=>`\u201C${s.title.trim()||"(untitled)"}\u201D`).join(", ");n.push(`${e.unreadable} ${e.unreadable===1?"event":"events"} with dates this app can\u2019t read${i?` (${i}${t.length>3?", \u2026":""})`:""}`)}let o=e.recurring+e.cancelled+(e.unreadable??0)===1?"isn\u2019t":"aren\u2019t";return`${n.join(" and ")} ${o} imported yet.`}var Fn=':root{--pl-bg: var(--t-color-bg-body, #ffffff);--pl-surface: var(--t-color-bg-1, #f5f6f8);--pl-border: var(--t-color-bg-2, #e3e6ea);--pl-text: var(--t-color-text, #1b1e22);--pl-muted: var(--t-color-text-light, #626a73);--pl-accent: var(--t-color-main, #1a6ef5);--pl-neg: var(--t-color-alert, #c62828);--pl-warn: var(--t-color-warning, #9a6200);--pl-pos: var(--t-color-success, #1f7a45);--pl-on-accent: #ffffff;--pl-tint: 14%;--pl-shadow: rgb(0 0 0 / .12);color-scheme:light;--pl-radius: var(--t-radius, 8px);--pl-font: var(--t-font-family, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif)}@media(prefers-color-scheme:dark){:root:not([data-pl-theme=light]){--pl-bg: var(--t-color-bg-body, #15171a);--pl-surface: var(--t-color-bg-1, #1d2024);--pl-border: var(--t-color-bg-2, #2c3036);--pl-text: var(--t-color-text, #e8eaed);--pl-muted: var(--t-color-text-light, #9aa2ab);--pl-accent: var(--t-color-main, #6ea3ff);--pl-neg: var(--t-color-alert, #ff7b72);--pl-warn: var(--t-color-warning, #e3b341);--pl-pos: var(--t-color-success, #56c98a);--pl-on-accent: #0c1320;--pl-tint: 22%;--pl-shadow: rgb(0 0 0 / .4);color-scheme:dark}}:root[data-pl-theme=dark]{--pl-bg: var(--t-color-bg-body, #15171a);--pl-surface: var(--t-color-bg-1, #1d2024);--pl-border: var(--t-color-bg-2, #2c3036);--pl-text: var(--t-color-text, #e8eaed);--pl-muted: var(--t-color-text-light, #9aa2ab);--pl-accent: var(--t-color-main, #6ea3ff);--pl-neg: var(--t-color-alert, #ff7b72);--pl-warn: var(--t-color-warning, #e3b341);--pl-pos: var(--t-color-success, #56c98a);--pl-on-accent: #0c1320;--pl-tint: 22%;--pl-shadow: rgb(0 0 0 / .4);color-scheme:dark}html,body{height:100%;margin:0;background:var(--pl-bg)}.pl-app{font-family:var(--pl-font);font-size:14px;line-height:1.45;background:var(--pl-bg);color:var(--pl-text);height:100vh;display:flex;flex-direction:column;min-width:0;overflow:hidden;position:relative}.pl-app *,.pl-app *:before,.pl-app *:after{box-sizing:border-box}.pl-app>header{flex:none}.pl-main{flex:1;min-height:0;display:flex;flex-direction:column}:where(.pl-app) :where(button,input,textarea,select){font:inherit;color:inherit}.pl-app :focus-visible{outline:2px solid var(--pl-accent);outline-offset:2px}.pl-app a{color:var(--pl-accent)}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}.sr-only-focusable:focus{position:static;width:auto;height:auto;clip:auto;margin:4px 14px}.muted{color:var(--pl-muted)}.pl-scroll{flex:1;min-height:0;overflow:auto}.hd{display:flex;align-items:center;gap:12px;padding:10px 14px;flex-wrap:wrap}.hd-id{display:flex;align-items:center;gap:8px}.hd-id h1{margin:0;font-size:16px;font-weight:650;letter-spacing:-.005em}.mark{display:grid;place-items:center;width:30px;height:30px;border-radius:8px;background:color-mix(in srgb,var(--pl-accent) 12%,transparent);color:var(--pl-accent)}.hd-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0}.hd-act{margin-left:auto;display:flex;gap:8px;align-items:center}.chip{display:inline-flex;align-items:center;gap:6px;height:28px;padding:0 10px 0 8px;border:1px solid var(--pl-border);border-radius:999px;font-size:12.5px;background:var(--pl-bg);cursor:pointer;white-space:nowrap}.chip[aria-pressed=false]{color:var(--pl-muted)}.chip[aria-pressed=false] .sw{background:transparent;box-shadow:inset 0 0 0 1.5px var(--c)}.sw{display:inline-block;width:10px;height:10px;border-radius:3px;background:var(--c);flex:none}.sw-stack{display:inline-flex}.sw-stack i{width:10px;height:10px;border-radius:50%;background:var(--c);box-shadow:0 0 0 2px var(--pl-bg)}.sw-stack i+i{margin-left:-3px}.pill{display:inline-flex;align-items:center;gap:6px;height:26px;padding:0 10px;border-radius:999px;font-size:12.5px;color:var(--pl-muted);background:var(--pl-surface);border:1px solid transparent;white-space:nowrap;cursor:default}button.pill{cursor:pointer}.pill-accent{color:color-mix(in srgb,var(--pl-accent) 75%,var(--pl-text));background:color-mix(in srgb,var(--pl-accent) 10%,var(--pl-bg))}.pill-warn{color:color-mix(in srgb,var(--pl-warn) 75%,var(--pl-text));background:color-mix(in srgb,var(--pl-warn) 12%,var(--pl-bg));border-color:color-mix(in srgb,var(--pl-warn) 30%,transparent)}.pill-warn:before{content:"!";font-weight:800}.pill-neg{color:var(--pl-neg);background:color-mix(in srgb,var(--pl-neg) 10%,var(--pl-bg))}.spin{width:10px;height:10px;border-radius:50%;border:2px solid currentColor;border-right-color:transparent;animation:pl-spin 1s linear infinite;flex:none}@keyframes pl-spin{to{transform:rotate(360deg)}}.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;height:32px;padding:0 14px;border-radius:var(--pl-radius);border:1px solid var(--pl-border);background:var(--pl-bg);cursor:pointer;font-weight:550;white-space:nowrap;text-decoration:none;color:var(--pl-text)}.btn:hover{background:var(--pl-surface)}.btn-primary{background:var(--pl-accent);border-color:var(--pl-accent);color:var(--pl-on-accent)}.btn-primary:hover{background:color-mix(in srgb,var(--pl-accent) 88%,#000)}.btn[disabled]{opacity:.45;cursor:not-allowed}.btn-ghost{border-color:transparent;background:transparent;color:var(--pl-accent)}.btn-sm{height:28px;padding:0 10px;font-size:13px}.btn-lg{height:40px;padding:0 18px;font-size:14.5px}.btn-neg{color:var(--pl-neg)}.link{background:none;border:0;padding:0;color:var(--pl-accent);text-decoration:underline;cursor:pointer;font-weight:550}.icon-btn{display:inline-grid;place-items:center;width:30px;height:30px;border-radius:6px;border:0;background:transparent;color:var(--pl-muted);cursor:pointer;font-size:18px;line-height:1;flex:none}.icon-btn:hover{background:var(--pl-surface);color:var(--pl-text)}.cbar{position:relative;display:flex;align-items:center;gap:8px;padding:6px 14px;border-block:1px solid var(--pl-border);background:var(--pl-surface);font-size:12.5px;color:var(--pl-muted);min-width:0}.cbar b{color:var(--pl-text);font-weight:550}.cbar-acc{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.cbar-menu{margin-left:auto;position:relative}.cbar .icon-btn{width:26px;height:26px}.cbar.is-busy:after{content:"";position:absolute;left:0;bottom:-1px;height:2px;width:35%;background:var(--pl-accent);animation:pl-slide 1.4s ease-in-out infinite}@keyframes pl-slide{0%{left:-35%}to{left:100%}}.menu{position:absolute;right:0;top:calc(100% + 4px);z-index:20;min-width:200px;padding:4px;background:var(--pl-bg);border:1px solid var(--pl-border);border-radius:8px;box-shadow:0 8px 24px var(--pl-shadow);display:grid}.menu button{text-align:left;border:0;background:transparent;padding:8px 10px;border-radius:6px;cursor:pointer;font-size:13px;color:var(--pl-text)}.menu button:hover,.menu button:focus-visible{background:var(--pl-surface)}.menu button[disabled]{color:var(--pl-muted);cursor:not-allowed}.menu small{display:block;color:var(--pl-muted);font-size:11.5px}.segs{display:inline-flex;padding:2px;background:var(--pl-surface);border-radius:8px;border:1px solid var(--pl-border)}.seg{border:0;background:transparent;height:26px;padding:0 10px;border-radius:6px;font-size:12.5px;color:var(--pl-muted);cursor:pointer}.seg[aria-pressed=true]{background:var(--pl-bg);color:var(--pl-text);font-weight:600;box-shadow:0 1px 2px #00000014}.banners{display:grid}.banner{display:flex;gap:10px;align-items:flex-start;margin:10px 12px 0;padding:10px 12px;border-radius:8px;font-size:13px;border:1px solid}.banner .bi{width:18px;height:18px;border-radius:50%;display:grid;place-items:center;font-weight:800;font-size:11px;flex:none;margin-top:1px;color:var(--pl-bg)}.banner .bt{flex:1;display:grid;gap:2px;min-width:0}.banner .bt b{font-weight:650}.banner .bt span{color:var(--pl-muted)}.banner details{font-size:12px;color:var(--pl-muted)}.banner summary{cursor:pointer}.banner code{font-size:11.5px;overflow-wrap:anywhere}.banner .btn{flex:none}.b-neg{background:color-mix(in srgb,var(--pl-neg) 7%,var(--pl-bg));border-color:color-mix(in srgb,var(--pl-neg) 30%,transparent)}.b-neg .bi{background:var(--pl-neg)}.b-warn{background:color-mix(in srgb,var(--pl-warn) 8%,var(--pl-bg));border-color:color-mix(in srgb,var(--pl-warn) 30%,transparent)}.b-warn .bi{background:var(--pl-warn)}.b-info{background:var(--pl-surface);border-color:var(--pl-border)}.b-info .bi{background:var(--pl-muted)}.center{display:grid;place-items:center;padding:36px 20px;min-height:100%}.card{width:100%;max-width:400px;display:grid;gap:14px;text-align:center;justify-items:center}.card h2{margin:0;font-size:19px;font-weight:650;text-wrap:balance}.card p{margin:0;color:var(--pl-muted);max-width:36ch}.big-ic{width:52px;height:52px;border-radius:14px;display:grid;place-items:center;background:color-mix(in srgb,var(--pl-accent) 12%,transparent);color:var(--pl-accent)}.big-ic svg{width:28px;height:28px}.big-ic.muted-ic{background:var(--pl-surface);color:var(--pl-muted)}.fine{font-size:12px;color:var(--pl-muted)}.scrim{position:absolute;inset:0;z-index:30;background:#00000047;display:grid;place-items:center;padding:16px}.sheet{width:100%;max-width:560px;max-height:100%;overflow:auto;background:var(--pl-bg);border:1px solid var(--pl-border);border-radius:12px;box-shadow:0 12px 40px var(--pl-shadow);padding:16px;display:grid;gap:14px;align-content:start}.sheet-hd{display:flex;align-items:flex-start;gap:10px}.sheet-hd div{flex:1;display:grid;gap:2px}.sheet-hd h2{margin:0;font-size:17px;font-weight:650}.sheet-hd p{margin:0;font-size:12.5px;color:var(--pl-muted)}.sheet-ft{display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;padding-top:12px;border-top:1px solid var(--pl-border)}.sheet-ft p{margin:0;font-size:12.5px;color:var(--pl-muted)}@media(max-width:719px){.scrim{padding:0;place-items:stretch}.sheet{max-width:none;border-radius:0;border:0}}.kbd{display:grid;grid-template-columns:auto 1fr;gap:6px 14px;margin:0;font-size:13px}.kbd dt{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px}.kbd dt kbd{border:1px solid var(--pl-border);border-bottom-width:2px;border-radius:4px;padding:0 5px;background:var(--pl-surface)}.kbd dd{margin:0;color:var(--pl-muted)}@media(max-width:719px){.pl-app :is(.btn-sm,.chip,.seg,.icon-btn,button.pill,.link){position:relative}.pl-app :is(.btn-sm,.chip,.seg,.icon-btn,button.pill,.link):after{content:"";position:absolute;inset:-9px -4px}}@media(prefers-reduced-motion:reduce){.spin,.cbar.is-busy:after{animation:none}.cbar.is-busy:after{width:100%;left:0;opacity:.4}}';function Nr(e){let t,n=/^#([0-9a-f]{6})$/i.exec(e.trim());if(n)t=[0,2,4].map(s=>parseInt(n[1].slice(s,s+2),16));else{let s=/rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/i.exec(e);s&&(t=[s[1],s[2],s[3]].map(Number))}if(!t)return;let[r,o,i]=t.map(s=>{let l=s/255;return l<=.03928?l/12.92:((l+.055)/1.055)**2.4});return .2126*r+.7152*o+.0722*i}function Bn(e,t,n={}){let r=e.ownerDocument,o=r.defaultView,i=r.getElementById("pl-styles");i||(i=r.createElement("style"),i.id="pl-styles",i.textContent=t,r.head.appendChild(i));let s=d=>{d?r.documentElement.setAttribute("data-pl-theme",d):r.documentElement.removeAttribute("data-pl-theme")};if(n.getTheme&&n.onThemeChange)return s(n.getTheme().colorScheme),n.onThemeChange(d=>s(d.colorScheme));let l=()=>{if(!o)return;let d=o.getComputedStyle(r.documentElement).getPropertyValue("--t-color-bg-body"),w=d?Nr(d.trim()):void 0;s(w===void 0?void 0:w<.3?"dark":"light")};l();let c=r.getElementById("__atomic_theme"),f=c&&typeof MutationObserver<"u"?new MutationObserver(l):void 0;return f?.observe(c,{childList:!0,characterData:!0,subtree:!0}),()=>f?.disconnect()}var he=44,Hr=52,zr=44;function kt(e){let t=Math.floor(e/60);return t>=8&&t<18?8:Math.max(0,t-1)}function Fe(e){return e<560?3:e<720?5:7}function Un(e,t,n,r){let{doc:o}=e,i=t.segment,s=i.event,l=i.startMin/60*he,c=Math.max((i.endMin-i.startMin)/60*he,16),f=i.endMin-i.startMin<30,d=i.endMin-i.startMin<75,w=`calc(${100/n}% - 4px)`,m=[`${R(i.startMin)}\u2013${dt(i,!0)}`,s.location].filter(Boolean).join(" \xB7 ");return a(o,"li",{},a(o,"button",{class:`ev${f?" ev-short":d?" ev-1":""}${s.pending?" ev-edited":""}`,style:`--c:${s.calendar.color};top:${l+1}px;height:${c-2}px;left:calc(${100/n*r}% + 2px);width:${w}`,"data-key":`ev-${s.subject}-${i.date}`,"data-subject":s.subject,"aria-label":Pe(i),onclick:u=>e.open(s,u.currentTarget)},a(o,"span",{class:"ev-t","aria-hidden":"true"},s.title||"(untitled)"),f?null:a(o,"span",{class:"ev-m","aria-hidden":"true"},m),s.conflict?a(o,"span",{class:"ev-badge","aria-hidden":"true"},"!"):s.pending?a(o,"span",{class:"ev-dot","aria-hidden":"true"}):null))}function Gr(e,t,n,r){let{doc:o}=e,i=[],s=new Map;for(let f of n){let d=s.get(f.cluster)??[];d.push(f),s.set(f.cluster,d)}for(let f of s.values()){let d=f[0].lanes;if(d<=r){for(let p of f)i.push(Un(e,p,d,p.lane));continue}let w=Math.max(1,r-1),m=f.filter(p=>p.lanep.lane>=w);for(let p of m)i.push(Un(e,p,w+1,p.lane));let b=Math.min(...u.map(p=>p.segment.startMin));i.push(a(o,"li",{},a(o,"button",{class:"ev-more",style:`top:${b/60*he+1}px;height:22px;left:calc(${100/(w+1)*w}% + 2px);width:calc(${100/(w+1)}% - 4px)`,"data-key":`more-${t}-${b}`,"aria-label":`${u.length} more ${u.length===1?"event":"events"} on ${z(t)}: show in agenda`,onclick:()=>{e.goTo(t),e.setView("agenda")}},`+${u.length}`)))}let l=t===e.today,c=N(e.now,e.zone);return a(o,"section",{class:`wk-col${l?" is-today":""}`,"aria-label":z(t)},a(o,"h3",{class:"sr-only"},z(t)),l&&c.date===t?a(o,"div",{class:"now",style:`top:${c.minutes/60*he}px`,"aria-hidden":"true"}):null,i.length?a(o,"ul",{},...i):null)}function Yn(e,t,n,r,o){let{doc:i}=e,s=fn(t,n,r),l=(o-Hr)/r,c=Math.max(1,Math.floor(l/zr)),f=`--n:${r}`,d=Math.max(s.rows,1),w=a(i,"div",{class:"wk-head","aria-hidden":"true"},a(i,"div",{class:"wk-tz"},Ge(e.now,e.zone)),...s.days.map(p=>a(i,"div",{class:`wk-dh${p===e.today?" is-today":""}`},a(i,"span",{},Te(p)),a(i,"b",{},String(Number(p.slice(8))))))),m=a(i,"div",{class:"wk-all",role:"group","aria-label":"All-day events",style:`grid-template-rows:repeat(${d}, 22px)`},a(i,"div",{class:"wk-tz",style:`grid-row:1 / span ${d}`},"all-day"),...s.bars.map(p=>a(i,"button",{class:`ad${p.event.pending?" ev-edited":""}`,style:`--c:${p.event.calendar.color};grid-column:${p.col+2} / span ${p.span};grid-row:${p.row+1}`,"data-key":`ad-${p.event.subject}`,"data-subject":p.event.subject,"aria-label":Pe({event:p.event,date:s.days[p.col],allDay:!0,startMin:0,endMin:1440}),onclick:x=>e.open(p.event,x.currentTarget)},`${p.event.title||"(untitled)"}${p.event.location?` \xB7 ${p.event.location}`:""}`))),u=a(i,"div",{class:"wk-hours","aria-hidden":"true"},...Array.from({length:24},(p,x)=>a(i,"div",{class:"wk-h"},a(i,"span",{},R(x*60))))),b=a(i,"div",{class:"wk-body"},u,...s.days.map((p,x)=>Gr(e,p,s.columns[x],c)));return a(i,"div",{class:"wk",style:f},a(i,"button",{class:"sr-only sr-only-focusable link","data-key":"to-agenda",onclick:()=>e.setView("agenda")},"Switch to agenda view"),w,m,a(i,"div",{class:"wk-scroll","data-scroll":"week"},b))}var Ir=/^(input|textarea|select)$/i,_r={calendarId:"",total:0,added:0,updated:0,unchanged:0,skipped:{recurring:0,cancelled:0,unreadable:0},unreadable:[],conflicts:[],localOnly:0,invalid:[],review:[]};async function so({root:e,store:t}){kn(e.ownerDocument.defaultView??window);let n=e.ownerDocument,r=n.defaultView;Bn(e,`${Fn} +${vn}`,t),e.classList.add("pl-app");let o=en(),i=()=>N(Date.now(),o).date,s=()=>e.clientWidth||r.innerWidth||1024,l={view:s()<720?"agenda":"week",anchor:i(),month:i(),menu:!1,visible:!0,why:!1,choices:new Map,conflictErrors:new Map,scrollTo:kt(N(Date.now(),o).minutes)},c,f,d,w=!1,m=()=>{w||(w=!0,queueMicrotask(()=>{w=!1,Le()}))},u=$n(t,()=>m()),b=g=>{Object.assign(l,g),m()},p=g=>{setTimeout(()=>e.querySelector(g)?.focus(),0)},x=()=>({doc:n,zone:o,today:i(),now:Date.now(),width:s(),open:(g,E)=>v(g,E.dataset.key),goTo:g=>b({anchor:g,month:g}),setView:g=>b({view:g,...l.view===g?{}:{scrollTo:kt(N(Date.now(),o).minutes)}})});function v(g,E){c=void 0,b({drawer:{subject:g.subject,mode:"view",...E?{from:E}:{}},menu:!1}),p("#drawer-title")}function h(){let g=l.drawer?.from;c=void 0,b({drawer:void 0}),g&&p(`[data-key="${CSS.escape(g)}"]`)}function y(g){let E=gt(n);b({sheet:g,menu:!1,...E?{sheetFrom:E}:{}}),p(`#${g}-title`)}function C(){let g=l.sheetFrom;b({sheet:void 0,confirming:void 0,sheetFrom:void 0}),p(g?`[data-key="${CSS.escape(g)}"]`:'[data-key="primary"]')}async function T(){await u.openInHost().catch(()=>{})}async function D(){y("review"),await u.prepareReview()}function j(g){let E=l.view==="week"?Fe(s()):7,S=M(l.anchor,g*E);b({anchor:S,month:S})}function _(g){clearInterval(f),l.countdown=g,f=setInterval(()=>{if(!l.countdown||l.countdown<=1){clearInterval(f),l.countdown=void 0,u.refresh();return}l.countdown--,m()},1e3)}function Z(g){if(g.meta)return a(n,"button",{class:"chip","aria-pressed":l.visible?"true":"false","aria-label":`Show ${g.meta.summary}`,"data-key":"chip",onclick:()=>b({visible:!l.visible})},a(n,"span",{class:"sw",style:`--c:${g.meta.color}`,"aria-hidden":"true"}),g.meta.summary)}function B(g,E){let S=En(g),k=ht(g),$=g.state.kind==="refreshing"||g.state.kind==="sending",P=S.opens,L=`${k} ${k===1?"change":"changes"}`,A=k>0?a(n,"button",{class:"btn btn-primary","data-key":"primary","aria-label":`Review ${L}`,onclick:()=>{D()}},E?`Review ${k}`:`Review ${L}`):a(n,"button",{class:"btn","data-key":"primary",disabled:$||!g.meta,onclick:()=>{u.refresh()}},"Sync now");return jn(n,{title:"Calendar",chips:Z(g),status:Mn(n,{...S,...P?{onClick:()=>{P==="review"?D():P==="conflicts"?y("conflicts"):e.querySelector(".banner details")?.setAttribute("open","")}}:{}}),action:A})}function K(g){let E=g.state.kind==="refreshing"||g.state.kind==="sending",S=g.state.kind==="refreshing"?"Reading events\u2026":g.state.kind==="sending"?"Sending changes\u2026":g.at?`Last synced ${mt(g.at).replace(/^at /,"")}`:void 0;return yt(n,{provider:"Google Calendar",...g.meta?.account?{account:g.meta.account}:{},...S?{detail:S}:{},busy:E,menuOpen:l.menu,onMenu:k=>b({menu:k}),menu:[{label:"Sync now",disabled:E,onSelect:()=>{u.refresh()}},{label:"Reconnect",onSelect:()=>{u.connect()}},{label:"Choose calendars",hint:"One calendar per app: add another Calendar app for a second one.",disabled:!0,onSelect:()=>{}},{label:"Keyboard shortcuts",onSelect:()=>y("shortcuts")},...g.can.disconnect?[{label:"Disconnect",hint:"This app stops using the connection. Your rows stay here.",onSelect:()=>{u.disconnect()}}]:[]]})}function X(g){let E=g.state;if(E.kind!=="error"){d=void 0;return}let S=E.problem;S!==d&&(d=S,S.kind==="rate-limited"&&S.retryAfter&&_(S.retryAfter));let k=Cn(S,g.meta?.summary??"this calendar",l.countdown),$=k.action?.does==="reconnect"||E.reconnect,P=k.action??($?{label:"Reconnect"}:void 0);return a(n,"div",{class:"banners"},wt(n,{tone:k.tone,role:k.role,title:k.title,body:k.body,details:`${S.status?`HTTP ${S.status}: `:""}${S.message}`,...P?{action:{label:$?"Reconnect":P.label,...$?{name:"Reconnect Google Calendar"}:{},onClick:()=>{clearInterval(f),l.countdown=void 0,$?u.connect():u.refresh()}}}:{}}))}function se(g,E,S,k){let $=a(n,"span",{class:"tb-nav"},a(n,"button",{class:"icon-btn","aria-label":l.view==="week"?"Previous days":"Previous week","data-key":"prev",onclick:()=>j(-1)},G(n,I.prev)),a(n,"button",{class:"icon-btn","aria-label":l.view==="week"?"Next days":"Next week","data-key":"next",onclick:()=>j(1)},G(n,I.next))),P=l.view==="agenda"?De(l.anchor):g?rn(E,M(E,S-1)):nn(E,M(E,S-1));return a(n,"div",{class:"tb"},g?null:a(n,"button",{class:"btn","data-key":"today",onclick:()=>x().goTo(i())},"Today"),$,a(n,"h2",{class:"tb-title","aria-live":"polite"},P),Rn(n,"View",[{value:"agenda",label:"Agenda"},{value:"week",label:"Week"},...k?[{value:"month",label:"Month \u2197"}]:[]],l.view,L=>L==="month"?void T():x().setView(L)))}function Oe(g,E,S){let k=l.visible?g.events:[],$=l.view==="week"?Fe(E.width):7,P=l.view==="week"&&$===7?te(l.anchor):l.anchor;if(g.state.kind==="refreshing"&&!g.summary&&!g.events.length)return a(n,"div",{class:"main"},Nn(n,{calendar:g.meta?.summary??"Calendar",color:g.meta?.color??"#4986e7",...g.state.kind==="refreshing"&&g.state.pages?{pages:g.state.pages}:{}}));let A=l.view==="week"?$:7,ne=l.view==="week"?P:te(l.anchor),Ct=Ie(k,ne,A).size===0,be;if(Ct&&g.summary){let Ae=hn(k,M(ne,A)),Jn=Ae?void 0:bn(k,ne),ye=Ae??Jn;be=Re(n,{muted:!0,title:l.view==="week"&&$<7?"No events these days":"No events this week",...ye?{text:`Your ${Ae?"next":"latest"} event is ${ye.event.title||"(untitled)"} on ${z(ye.date)}.`}:l.visible?{}:{text:`${g.meta?.summary??"The calendar"} is hidden.`},children:ye?[a(n,"button",{class:"btn btn-primary","data-key":"jump",onclick:()=>E.goTo(ye.date)},Ae?"Jump to next event":"Jump to latest event")]:[]})}else l.view==="week"?be=Yn(E,k,P,$,E.width-(E.width>=900?232:0)):be=xn(E,k,l.anchor,g.summary&&xt(g.summary.skipped)?vt(g.summary.skipped,g.summary.unreadable):void 0);return a(n,"div",{class:"main"},se(S,P,$,g.can.openResource),l.view==="agenda"?wn(E,k,l.anchor):null,l.view==="week"&&!(Ct&&g.summary)?be:a(n,"div",{class:"view","data-scroll":"view"},be))}function O(g,E,S){if(!l.drawer)return;let k=l.drawer,$=g.events.find(P=>P.subject===k.subject);if(!$){l.drawer=void 0,c=void 0;return}return k.mode==="edit"&&!$.readOnly?(c?.subject!==$.subject&&(c={subject:$.subject,element:Pn(E,$,{narrow:S,onClose:h,onCancel:()=>{c=void 0,b({drawer:{...k,mode:"view"}}),p("#drawer-title")},onSave:async P=>{await u.saveEvent($.subject,P),c=void 0,b({drawer:{...k,mode:"view"}}),p("#drawer-title")}})}),c.element):Dn(E,$,{narrow:S,onClose:h,onEdit:()=>{b({drawer:{...k,mode:"edit"}}),p('[data-key="f-title"]')},onReview:()=>{D()},onConflicts:()=>y("conflicts"),...$.link&&g.can.openExternal?{onOpenLink:()=>{u.openLink($)}}:{}})}function H(g,E){if(l.sheet==="shortcuts")return _n(E,C);let S=g.meta?.color??"#4986e7";if(l.sheet==="review"){let k=g.state,$=k.kind==="sending"||k.kind==="ready"?k.summary:g.summary??_r;return Gn(E,{summary:$,...k.kind==="sending"?{progress:k.progress}:{},outcomes:k.kind==="ready"||k.kind==="error"?k.outcomes??[]:[],color:S,busy:k.kind==="refreshing"||g.stale,onClose:C,onDiscard:P=>{u.discard(P)},onSend:()=>{u.send()},onReviewAgain:()=>{u.refresh()}})}if(l.sheet==="conflicts")return In(E,{list:g.summary?.conflicts??[],color:S,choices:l.choices,...l.confirming?{confirming:l.confirming}:{},errors:l.conflictErrors,onClose:C,onChoose:(k,$,P)=>{l.choices.set(k,{...l.choices.get(k)??{},[$]:P}),l.conflictErrors.delete(k),m()},onResolve:k=>{u.resolve(k,l.choices.get(k)??{}).catch($=>{l.conflictErrors.set(k,$ instanceof Error?$.message:String($)),m()})},onKeep:k=>{u.keepAsLocal(k)},onRemove:k=>{l.confirming=void 0,u.removeLocal(k)},onConfirm:k=>b({confirming:k}),...g.can.openResource?{onOpenRow:k=>{u.openInHost(k.subject)}}:{}})}function U(g,E){let S=g.state,k=E.width<720;switch(S.kind){case"loading":return[q(a(n,"p",{class:"sr-only",role:"status"},"Loading\u2026"))];case"no-relay":return[q(On(n))];case"disconnected":case"connecting":return[q(Ln(n,{connecting:S.kind==="connecting",onConnect:()=>{u.connect()},onCancel:()=>u.cancelConnect()}))];case"choosing":{let L=S.calendars.find(A=>A.primary)?.id;return[a(n,"header",{},yt(n,{provider:"Google Calendar",...L?{account:L}:{}})),q(An(n,{calendars:S.calendars,onImport:A=>{u.choose(A)}}))]}default:}if(!g.meta)return[a(n,"header",{},B(g,k)),a(n,"main",{class:"pl-main"},X(g),S.kind==="error"?void 0:a(n,"p",{class:"fine",style:"padding:14px",role:"status"},"Reading your calendars\u2026"))];let $=l.view==="week"?Fe(E.width):7,P=l.view==="week"&&$===7?te(l.anchor):l.anchor;return[a(n,"header",{},B(g,k),K(g)),a(n,"main",{class:"pl-main"},X(g),a(n,"div",{class:"shell"},E.width>=900&&l.view==="week"?Vn(E,{month:l.month,selected:l.anchor,weekFrom:P,weekDays:$,meta:g.meta,visible:l.visible,...g.summary?{summary:g.summary}:{},whyOpen:l.why,onMonth:L=>b({month:L}),onVisible:L=>b({visible:L}),onWhy:()=>b({why:!l.why})}):null,Oe(g,E,k),O(g,E,k)??null))]}function q(...g){return a(n,"main",{class:"pl-scroll"},a(n,"h1",{class:"sr-only"},"Calendar"),...g)}function Le(){let g=u.snapshot(),E=x(),S=gt(n),k=new Map;for(let A of e.querySelectorAll("[data-scroll]"))k.set(A.dataset.scroll,A.scrollTop);let $=U(g,E).filter(A=>!!A),P=H(g,E);e.replaceChildren(...$,...P?[P]:[]);for(let A of e.querySelectorAll("[data-scroll]")){let ne=k.get(A.dataset.scroll);ne!==void 0&&(A.scrollTop=ne)}let L=e.querySelector('[data-scroll="week"]');L&&l.scrollTo!==void 0&&(L.scrollTop=Math.max(0,l.scrollTo*he-10),l.scrollTo=void 0),yn(e,S)}n.addEventListener("keydown",g=>{let E=g.target;if(g.altKey||g.ctrlKey||g.metaKey)return;if(g.key==="Escape"){if(l.menu)b({menu:!1});else if(l.sheet)C();else if(l.drawer)h();else return;g.preventDefault();return}if(Ir.test(E.tagName)||E.isContentEditable)return;let S=u.snapshot();if(!S.meta)return;if(l.sheet){g.key==="?"&&y("shortcuts");return}let k=E.closest("[data-subject]"),$=!0;switch(g.key){case"t":x().goTo(i());break;case"ArrowLeft":case"k":j(-1);break;case"ArrowRight":case"j":j(1);break;case"a":x().setView("agenda");break;case"w":x().setView("week");break;case"m":if(!S.can.openResource){$=!1;break}T();break;case"?":y("shortcuts");break;case"e":{let P=l.drawer?.subject??k?.dataset.subject,L=S.events.find(ne=>ne.subject===P);if(!L||L.readOnly){$=!1;break}let A=l.drawer?.from??k?.dataset.key;b({drawer:{subject:L.subject,mode:"edit",...A?{from:A}:{}}}),p('[data-key="f-title"]');break}default:$=!1}$&&g.preventDefault()}),n.addEventListener("click",g=>{l.menu&&!g.target.closest(".cbar-menu")&&b({menu:!1})}),r.addEventListener("resize",()=>{c||m()}),setInterval(()=>{c||m()},6e4),Le(),await u.load().catch(g=>{e.replaceChildren(wt(n,{tone:"neg",role:"alert",title:"The calendar could not load.",body:g instanceof Error?g.message:String(g)}))})}export{so as view}; diff --git a/integrations/calendar/README.md b/integrations/calendar/README.md index 3f22044b..567bf279 100644 --- a/integrations/calendar/README.md +++ b/integrations/calendar/README.md @@ -15,7 +15,7 @@ drive apps. 1. **Install.** From the catalog: entry `calendar` (experimental; published but disabled pending launch: the catalog entry carries the module and its integrity with `enabled: false`, so the Integrations page does not offer it yet; the lanes' dev-server serves it enabled (`DEV_SERVER_ENABLE_APPS`), which is how the e2e installs it). Once enabled it is listed under the Integrations page's **Drive apps**. The host downloads `apps/calendar//ui.js` (`app/build.mjs`'s bundle, minified, - 104,561 bytes for 0.1.1) from GitHub Pages and refuses it unless it + 107,798 bytes for 0.1.2) from GitHub Pages and refuses it unless it matches the entry's integrity hash (see [Publishing a drive app](../README.md#publishing-a-drive-app)). The e2e installs it that way, from the committed module the lane's dev-server @@ -104,8 +104,9 @@ day is exclusive, like Google's all-day end: - timed event within one day: no End day (the property is removed if an edit makes an overnight event fit in one day). -Day and End day are derived on import and on every local edit, and never -read back, so move an event by editing Start and End. Version 0.1.0 wrote +Day and End day are derived on import and on every local edit, and are not +re-derived when Start or End is edited in the host table, so move an event +with the app's Edit (or edit Day and End day too). Version 0.1.0 wrote the shortnames `day` and `all-day`, no End day, and Google's description to the core Description; the host view drew its all-day and multi-day events on their first day only. A table first imported by 0.1.0 keeps those old @@ -113,6 +114,49 @@ Properties (0.1.1 no longer writes them) and gets the new ones on its next sync; installs of 0.1.0 were experimental, so nothing migrates the old values beyond that re-import. +### Which days the Agenda and Week draw an event on + +From 0.1.2 the app's own views draw every row on exactly the days the host +table's Calendar view draws it (Michiel's decision on +atomic-server#1803: the data keeps the host's format, and the app's views +follow the host, not Google's UI). `app/events.ts` imports the host's +`isAllDayOnDate` from `browser/lib/src/calendar-date.ts` and applies +`CalendarView.tsx`'s bucketing to the same columns, at the pin: + +| Row | Host view and app views | +| ---------------------------------------------------------- | ----------------------------------------- | +| All day, End day after Day | Day up to, not including, End day | +| All day, End day equal to or before Day, or no date | nowhere | +| All day, no End day | Day only | +| Not all day (timed), any End day | Day only, End day ignored | +| No Day (or one that doesn't start with `YYYY-MM-DD`) | nowhere | +| Made with the host view's `+` (Day, End day, no Start/End) | Day (the app draws it untimed, read-only) | + +Consequences, which differ from 0.1.1 and from Google's own UI: + +- A timed event that runs past midnight, or over several days, is drawn on + its Day only: the Week's block stops at 24:00, and its label and + accessible name give the real end ("22:00 to 01:30 on Friday 25 + September"). +- The day and the clock times of a timed event are the ones in its stored + offset (the date Day holds), not in the viewer's zone. For a viewer whose + zone has the same offset as the event nothing changes. Otherwise, an event + stored as `23:30-04:00` shows at 23:30 on its own date, as the host does, + while the drawer still gives the time in the viewer's zone and, on a + second line, in the event's own offset. +- Day and End day decide, not Start and End: a row whose Day or End day was + edited in the host table moves in the app too. + +Declared by unit tests (`app/events.test.ts`, one case per row of the table, +each checked against the host's `isAllDayOnDate`); the e2e checks the host +view's days for the fixture, not the app's views in another zone. + +The Google side stays as Google has it: an all-day event whose `end.date` +equals its `start.date` (Google's UI shows one day) is read as that one +day, with End day the day after (#184), both by `project()` and by the +LocalThought lens in `devonian/google-calendar/lens/projection.ts`; a +write-back sends Google the exclusive end. + Each row also carries its binding, outside the table's columns: the Google event id, the ETag last read, and the sync baseline. The baseline is JSON of the five fields as both sides last agreed, keyed by field (`title`, @@ -231,8 +275,10 @@ node --test integrations/localthought/mock-proxy.test.mjs - **Unit, views** (`app/view.test.ts`, jsdom; `app/controller.test.ts`, `app/events.test.ts`, `app/contrast.test.ts`): every screen of the design against the fake store, the banner copy for each provider status, agenda - grouping and week packing (exclusive all-day ends, midnight crossings, - viewer zone), local edits stored as offset-qualified strings, and the + grouping and week packing on the host Calendar view's days (exclusive + End day, End day == Day, missing Day or End day, midnight crossings, + events stored in another offset), local edits stored as offset-qualified + strings, and the event tints' contrast for Google's 24 classic calendar colours. - **Unit** (`app/sync.test.ts`, `adapter.test.ts`): the whole drive-app path against the stateful fixture in diff --git a/integrations/calendar/app/agenda.ts b/integrations/calendar/app/agenda.ts index 568e7260..8dffd6c7 100644 --- a/integrations/calendar/app/agenda.ts +++ b/integrations/calendar/app/agenda.ts @@ -24,7 +24,7 @@ export function dayStrip( ): HTMLElement { const { doc } = ctx; const monday = mondayOf(selected); - const busy = busyDays(events, monday, 7, ctx.zone); + const busy = busyDays(events, monday, 7); return h( doc, @@ -55,10 +55,10 @@ function row(ctx: Ctx, segment: Segment): HTMLElement { const { doc } = ctx; const e = segment.event; const time = segment.allDay - ? [h(doc, 'span', {}, 'All day')] + ? [h(doc, 'span', {}, segment.untimed ? 'No time' : 'All day')] : [ h(doc, 'span', {}, hhmm(segment.startMin)), - h(doc, 'small', {}, hhmm(segment.endMin)), + h(doc, 'small', {}, hhmm(segment.until?.minutes ?? segment.endMin)), ]; const second = segment.dayOf ? `Day ${segment.dayOf.n} of ${segment.dayOf.total}` @@ -109,7 +109,6 @@ export function agenda( events, from, Math.max(1, daysBetween(from, addDays(mondayOf(from), 7))), - ctx.zone, ); return h( diff --git a/integrations/calendar/app/context.ts b/integrations/calendar/app/context.ts index 5c634952..e9c23d27 100644 --- a/integrations/calendar/app/context.ts +++ b/integrations/calendar/app/context.ts @@ -11,6 +11,7 @@ import { addDays, formatOffset, hhmm, + isDate, longDay, offsetAt, shortDay, @@ -40,11 +41,25 @@ export function relativeDay(ctx: Ctx, date: string, long: string): string { return long; } -/** `10:00 to 11:00`, or `All day`, for one segment. */ +/** + * `10:00 to 11:00`, `22:00 to 01:30 on Friday 25 September`, `All day`, or + * `No time set`, for one segment. + */ export function spoken(segment: Segment): string { + if (segment.untimed) return 'No time set'; if (segment.allDay) return 'All day'; - return `${hhmm(segment.startMin)} to ${hhmm(segment.endMin)}`; + return `${hhmm(segment.startMin)} to ${endClock(segment)}`; +} + +/** The end as the views label it: its own clock, and its date when later. */ +export function endClock(segment: Segment, short = false): string { + if (!segment.until) return hhmm(segment.endMin); + const time = hhmm(segment.until.minutes); + + return short + ? `${time} ${shortDay(segment.until.date)}` + : `${time} on ${longDay(segment.until.date)}`; } /** @@ -66,9 +81,19 @@ export function accessibleName(segment: Segment): string { /** When an event is, in the viewer's zone: a day line and a time line. */ export function when( - event: Projection, + event: Projection & { day?: unknown }, zone: string, ): { day: string; time?: string; own?: string } { + // A row made in the host table can have a Day but no Start or End. + const day = + typeof event.day === 'string' && isDate(event.day.slice(0, 10)) + ? longDay(event.day.slice(0, 10)) + : 'No date set'; + if (event.allDay && (!isDate(event.start) || !isDate(event.end))) + return { day, time: 'All day' }; + if (!event.allDay && (!event.start || !event.end)) + return { day, time: 'No time set' }; + if (event.allDay) { const last = addDays(event.end, -1); diff --git a/integrations/calendar/app/events.test.ts b/integrations/calendar/app/events.test.ts index f06509d7..36a8d54b 100644 --- a/integrations/calendar/app/events.test.ts +++ b/integrations/calendar/app/events.test.ts @@ -6,8 +6,15 @@ import { latestEvent, nextEvent, packWeek, + occupies, type CalEvent, } from './events.js'; +import { endDayOf } from './sync.js'; +import { endClock, spoken, when } from './context.js'; +import { + isAllDayOnDate, + nextCalendarDate, +} from '../../../browser/lib/src/calendar-date.js'; import { addDays, hhmm, @@ -28,8 +35,13 @@ const NY = 'America/New_York'; let n = 0; +/** + * A row as `readEvents` reads it. Day and End day default to what the app + * writes for Start and End (`sync.ts` `valuesOf`); a test that passes them + * explicitly models a row whose Day or End day were set some other way. + */ function ev(fields: Partial): CalEvent { - return { + const base = { subject: `did:ad:row-${++n}`, id: `e${n}`, title: `Event ${n}`, @@ -44,6 +56,12 @@ function ev(fields: Partial): CalEvent { calendar: { name: 'Work', color: '#039be5' }, ...fields, }; + + return { + day: base.start.slice(0, 10), + endDay: endDayOf(base), + ...base, + }; } describe('time', () => { @@ -103,7 +121,7 @@ describe('agendaDays', () => { start: '2026-09-23', end: '2026-09-26', }); - const days = agendaDays([offsite], '2026-09-22', 6, AMS); + const days = agendaDays([offsite], '2026-09-22', 6); expect(days.map(d => d.items.length)).toEqual([0, 1, 1, 1, 0, 0]); expect(days[2].items[0]).toMatchObject({ allDay: true, @@ -113,27 +131,27 @@ describe('agendaDays', () => { it('a one-day all-day event has no "Day n of m"', () => { const one = ev({ allDay: true, start: '2026-09-24', end: '2026-09-25' }); - const [day] = agendaDays([one], '2026-09-24', 1, AMS); + const [day] = agendaDays([one], '2026-09-24', 1); expect(day.items[0].dayOf).toBeUndefined(); }); - it('splits a timed event that crosses midnight into one piece per day', () => { + it('shows a timed event that crosses midnight on its Day only, as the host does', () => { const late = ev({ start: '2026-09-24T22:00:00+02:00', end: '2026-09-25T01:30:00+02:00', }); - const days = agendaDays([late], '2026-09-24', 2, AMS); + // The app writes End day for it; the host view ignores End day unless + // All day is true, and so do the views. + expect(late.endDay).toBe('2026-09-25'); + const days = agendaDays([late], '2026-09-24', 2); + expect(days.map(d => d.items.length)).toEqual([1, 0]); expect(days[0].items[0]).toMatchObject({ startMin: 1320, endMin: 1440, allDay: false, - dayOf: { n: 1, total: 2 }, - }); - expect(days[1].items[0]).toMatchObject({ - startMin: 0, - endMin: 90, - dayOf: { n: 2, total: 2 }, + until: { date: '2026-09-25', minutes: 90 }, }); + expect(days[0].items[0].dayOf).toBeUndefined(); }); it('an event ending exactly at midnight stays on its own day', () => { @@ -141,20 +159,36 @@ describe('agendaDays', () => { start: '2026-09-24T20:00:00+02:00', end: '2026-09-25T00:00:00+02:00', }); - const days = agendaDays([evening], '2026-09-24', 2, AMS); + const days = agendaDays([evening], '2026-09-24', 2); expect(days.map(d => d.items.length)).toEqual([1, 0]); - expect(days[0].items[0].dayOf).toBeUndefined(); + expect(days[0].items[0]).toMatchObject({ startMin: 1200, endMin: 1440 }); + expect(days[0].items[0].until).toBeUndefined(); }); - it('places timed events in the viewer zone, not the event zone', () => { - // 23:30 in New York is 05:30 the next day in Amsterdam. + it('places a timed event on its Day, in its own offset, whatever the viewer zone', () => { + // 23:30 in New York is 05:30 the next day in Amsterdam. The host view + // reads Day, the date in the stored offset, and so does the app. const call = ev({ start: '2026-09-24T23:30:00-04:00', end: '2026-09-25T00:30:00-04:00', }); - const days = agendaDays([call], '2026-09-24', 2, AMS); - expect(days[0].items).toEqual([]); - expect(days[1].items[0]).toMatchObject({ startMin: 330, endMin: 390 }); + const days = agendaDays([call], '2026-09-24', 2); + expect(days[1].items).toEqual([]); + expect(days[0].items[0]).toMatchObject({ + startMin: 1410, + endMin: 1440, + until: { date: '2026-09-25', minutes: 30 }, + }); + }); + + it('draws a timed event spanning several days on its Day only', () => { + const conference = ev({ + start: '2026-09-22T09:00:00+02:00', + end: '2026-09-24T17:00:00+02:00', + }); + const days = agendaDays([conference], '2026-09-21', 5); + expect(days.map(d => d.items.length)).toEqual([0, 1, 0, 0, 0]); + expect(days[1].items[0]).toMatchObject({ allDay: false, startMin: 540 }); }); it('orders all-day first, then by start time', () => { @@ -174,13 +208,13 @@ describe('agendaDays', () => { start: '2026-09-24', end: '2026-09-25', }); - const [day] = agendaDays([a, b, c], '2026-09-24', 1, AMS); + const [day] = agendaDays([a, b, c], '2026-09-24', 1); expect(day.items.map(i => i.event.title)).toEqual(['Z', 'A', 'B']); }); it('skips events with an interval it cannot place', () => { const bad = ev({ start: 'garbage', end: 'garbage' }); - expect(agendaDays([bad], '2026-09-24', 1, AMS)[0].items).toEqual([]); + expect(agendaDays([bad], '2026-09-24', 1)[0].items).toEqual([]); }); }); @@ -201,7 +235,7 @@ describe('packWeek', () => { start: '2026-09-24', end: '2026-09-25', }); - const week = packWeek([offsite, deadline, holiday], '2026-09-21', 7, AMS); + const week = packWeek([offsite, deadline, holiday], '2026-09-21', 7); const bar = (e: CalEvent) => week.bars.find(b => b.event === e)!; expect(bar(offsite)).toMatchObject({ col: 2, span: 3, row: 0 }); expect(bar(deadline)).toMatchObject({ col: 0, span: 1, row: 0 }); @@ -211,7 +245,7 @@ describe('packWeek', () => { it('clips bars to the visible days', () => { const long = ev({ allDay: true, start: '2026-09-19', end: '2026-09-23' }); - const week = packWeek([long], '2026-09-21', 3, AMS); + const week = packWeek([long], '2026-09-21', 3); expect(week.bars[0]).toMatchObject({ col: 0, span: 2 }); }); @@ -226,12 +260,7 @@ describe('packWeek', () => { const dentist = t('11:30', '12:30', 'Dentist'); const later = t('12:15', '13:00', 'Later'); const alone = t('16:00', '17:00', 'Sync'); - const week = packWeek( - [alone, later, dentist, oneOnOne], - '2026-09-21', - 7, - AMS, - ); + const week = packWeek([alone, later, dentist, oneOnOne], '2026-09-21', 7); const col = week.columns[1]; const of = (e: CalEvent) => col.find(b => b.segment.event === e)!; expect(of(oneOnOne)).toMatchObject({ lane: 0, lanes: 2 }); @@ -242,21 +271,198 @@ describe('packWeek', () => { expect(week.columns[0]).toEqual([]); }); - it('draws a timed event covering whole days in the all-day row', () => { + it('draws a timed event covering whole days in its Day column only', () => { const trip = ev({ start: '2026-09-21T18:00:00+02:00', end: '2026-09-23T09:00:00+02:00', }); - const week = packWeek([trip], '2026-09-21', 7, AMS); - expect(week.bars).toEqual([expect.objectContaining({ col: 1, span: 1 })]); + const week = packWeek([trip], '2026-09-21', 7); + expect(week.bars).toEqual([]); expect(week.columns[0][0].segment).toMatchObject({ startMin: 1080, endMin: 1440, + until: { date: '2026-09-23', minutes: 540 }, + }); + expect(week.columns.slice(1).flat()).toEqual([]); + }); +}); + +/** + * The host table's Calendar view, case by case (atomic-server + * CalendarView.tsx at the pin): the app's views must draw exactly the days + * it draws. `host()` restates the host's bucketing of one row, with the + * host's own `isAllDayOnDate`, so each case is checked against it as well + * as against the expected days. + */ +describe('the host Calendar view’s days', () => { + const WEEK = '2026-09-21'; + const dates = Array.from({ length: 7 }, (_, i) => addDays(WEEK, i)); + const host = (e: CalEvent) => + dates.filter(d => + e.allDay && e.endDay !== undefined + ? isAllDayOnDate( + typeof e.day === 'string' ? e.day.slice(0, 10) : undefined, + e.endDay, + d, + ) + : typeof e.day === 'string' && e.day.slice(0, 10) === d, + ); + + const drawn = (e: CalEvent) => { + const week = packWeek([e], WEEK, 7); + + return { + agenda: agendaDays([e], WEEK, 7) + .filter(d => d.items.length) + .map(d => d.date), + week: week.days.filter( + (_, i) => + week.columns[i].length || + week.bars.some(b => i >= b.col && i < b.col + b.span), + ), + busy: [...busyDays([e], WEEK, 7)], + }; + }; + + const cases: Array<[string, Partial, string[]]> = [ + [ + 'all-day, one day', + { allDay: true, start: '2026-09-22', end: '2026-09-23' }, + ['2026-09-22'], + ], + [ + 'all-day, three days, End day exclusive', + { allDay: true, start: '2026-09-22', end: '2026-09-25' }, + ['2026-09-22', '2026-09-23', '2026-09-24'], + ], + [ + 'all-day, End day == Day (#184 shape, as stored): nowhere', + { + allDay: true, + start: '2026-09-22', + end: '2026-09-23', + endDay: '2026-09-22', + }, + [], + ], + [ + 'all-day, End day before Day: nowhere', + { + allDay: true, + start: '2026-09-22', + end: '2026-09-23', + endDay: '2026-09-20', + }, + [], + ], + [ + 'all-day, End day not a date: nowhere', + { allDay: true, start: '2026-09-22', end: '2026-09-23', endDay: 'soon' }, + [], + ], + [ + 'all-day, no End day: its Day only', + { + allDay: true, + start: '2026-09-22', + end: '2026-09-25', + endDay: undefined, + }, + ['2026-09-22'], + ], + [ + 'row added in the host view: Day and End day, no Start or End', + { + allDay: true, + start: '', + end: '', + id: undefined, + day: '2026-09-23', + endDay: nextCalendarDate('2026-09-23'), + }, + ['2026-09-23'], + ], + [ + 'Day and End day edited in the host table: they win over Start and End', + { + allDay: true, + start: '2026-09-22', + end: '2026-09-23', + day: '2026-09-25', + endDay: '2026-09-27', + }, + ['2026-09-25', '2026-09-26'], + ], + [ + 'no Day: nowhere', + { allDay: true, start: '2026-09-22', end: '2026-09-23', day: undefined }, + [], + ], + [ + 'Day with a time part: its first ten characters', + { day: '2026-09-24T10:00:00+02:00' }, + ['2026-09-24'], + ], + ['timed, within one day', {}, ['2026-09-24']], + [ + 'timed, crossing midnight: Day only', + { start: '2026-09-24T22:00:00+02:00', end: '2026-09-25T01:30:00+02:00' }, + ['2026-09-24'], + ], + [ + 'timed, three days: Day only, End day ignored', + { start: '2026-09-22T09:00:00+02:00', end: '2026-09-24T17:00:00+02:00' }, + ['2026-09-22'], + ], + [ + 'timed, stored in another offset: the stored date', + { start: '2026-09-24T23:30:00-04:00', end: '2026-09-25T00:30:00-04:00' }, + ['2026-09-24'], + ], + [ + 'timed, Start and End unusable, Day set: its Day, untimed', + { start: '', end: '', day: '2026-09-23' }, + ['2026-09-23'], + ], + ]; + + for (const [name, fields, expected] of cases) + it(name, () => { + const e = ev(fields); + expect(host(e)).toEqual(expected); + expect(dates.filter(d => occupies(e, d))).toEqual(expected); + expect(drawn(e)).toEqual({ + agenda: expected, + week: expected, + busy: expected, + }); }); - expect(week.columns[2][0].segment).toMatchObject({ - startMin: 0, - endMin: 540, + + it('draws a row without clock times in the all-day area, saying so', () => { + const e = ev({ start: '', end: '', day: '2026-09-23' }); + const [segment] = agendaDays([e], '2026-09-23', 1)[0].items; + expect(segment).toMatchObject({ allDay: true, untimed: true }); + expect(packWeek([e], WEEK, 7).bars).toEqual([ + expect.objectContaining({ col: 2, span: 1 }), + ]); + }); + + it('numbers the days of a multi-day all-day event from Day to End day', () => { + const e = ev({ + allDay: true, + start: '2026-09-22', + end: '2026-09-23', + day: '2026-09-22', + endDay: '2026-09-24', }); + expect( + agendaDays([e], WEEK, 7) + .flatMap(d => d.items) + .map(s => s.dayOf), + ).toEqual([ + { n: 1, total: 2 }, + { n: 2, total: 2 }, + ]); }); }); @@ -278,11 +484,11 @@ describe('nextEvent and busyDays', () => { start: '2026-10-20', end: '2026-10-21', }); - expect(nextEvent([later, early, next], '2026-10-05', AMS)).toMatchObject({ + expect(nextEvent([later, early, next], '2026-10-05')).toMatchObject({ event: next, date: '2026-10-13', }); - expect(nextEvent([early], '2026-10-05', AMS)).toBeUndefined(); + expect(nextEvent([early], '2026-10-05')).toBeUndefined(); }); it('finds the last event before a date, for a calendar of past events', () => { @@ -302,18 +508,47 @@ describe('nextEvent and busyDays', () => { start: '2026-10-13T10:00:00+02:00', end: '2026-10-13T11:00:00+02:00', }); - expect(latestEvent([april, next, may], '2026-09-28', AMS)).toMatchObject({ + expect(latestEvent([april, next, may], '2026-09-28')).toMatchObject({ event: may, date: '2026-05-12', }); - expect(latestEvent([next], '2026-09-28', AMS)).toBeUndefined(); + expect(latestEvent([next], '2026-09-28')).toBeUndefined(); }); it('marks the days with events', () => { const e = ev({ allDay: true, start: '2026-09-23', end: '2026-09-25' }); - expect([...busyDays([e], '2026-09-21', 7, AMS)]).toEqual([ + expect([...busyDays([e], '2026-09-21', 7)]).toEqual([ '2026-09-23', '2026-09-24', ]); }); }); + +describe('labels for host-placed segments', () => { + it('names the real end of an event that runs past midnight', () => { + const late = ev({ + title: 'Night shift', + start: '2026-09-24T22:00:00+02:00', + end: '2026-09-25T01:30:00+02:00', + }); + const [segment] = agendaDays([late], '2026-09-24', 1)[0].items; + expect(spoken(segment)).toBe('22:00 to 01:30 on Friday 25 September'); + expect(endClock(segment, true)).toBe('01:30 Fri 25 Sep'); + }); + + it('describes a row with a Day but no Start or End without throwing', () => { + expect( + when(ev({ allDay: true, start: '', end: '', day: '2026-09-23' }), AMS), + ).toEqual({ day: 'Wednesday 23 September', time: 'All day' }); + expect(when(ev({ start: '', end: '', day: '2026-09-23' }), AMS)).toEqual({ + day: 'Wednesday 23 September', + time: 'No time set', + }); + const [segment] = agendaDays( + [ev({ start: '', end: '', day: '2026-09-23' })], + '2026-09-23', + 1, + )[0].items; + expect(spoken(segment)).toBe('No time set'); + }); +}); diff --git a/integrations/calendar/app/events.ts b/integrations/calendar/app/events.ts index 373186d7..3332ac7c 100644 --- a/integrations/calendar/app/events.ts +++ b/integrations/calendar/app/events.ts @@ -1,15 +1,34 @@ // @wc-ignore-file /** * The display model of the Calendar app's views: events as the rows hold - * them, laid out per day (Agenda) and per column (Week) in the viewer's zone. - * Pure functions only; `main.ts` and the view modules draw from these. + * them, laid out per day (Agenda) and per column (Week). Pure functions + * only; `main.ts` and the view modules draw from these. * - * All-day `end` is exclusive (the day after the last day), as Google and the - * lens store it. A timed event that crosses midnight is split into one - * segment per day it covers. + * Which days an event occupies is decided exactly as the host table's own + * Calendar view decides it (atomic-server + * `browser/data-browser/src/chunks/TablePage/Calendar/CalendarView.tsx` at + * the pin in `.atomic-server-ref`), from the same columns, with the host's + * own `isAllDayOnDate` (imported from its `calendar-date.ts`, not copied): + * + * - a row is placed by its Day (`atomic-calendar-day`): the first ten + * characters of the stored string. A row without one is drawn nowhere. + * - a row whose All day is true and that has an End day is drawn on every + * day with Day <= day < End day (End day is exclusive). An End day on or + * before Day, or one that is not a date, draws it nowhere. + * - every other row, timed ones included, is drawn on its Day only. A timed + * event that runs past midnight is not continued on the next day, and its + * End day is ignored. + * + * Start and End only give the clock times within that day, read in the + * event's own stored offset (the offset its Day is the date in), so a viewer + * in another zone sees the event on the same day as the host view does. */ import type { Projection } from '../adapter.js'; -import { addDays, daysBetween, instant, wall } from './time.js'; +import { + isAllDayOnDate, + isCalendarDate, +} from '../../../browser/lib/src/calendar-date.js'; +import { addDays, daysBetween, instant } from './time.js'; export interface CalEvent extends Projection { /** The row's subject. */ @@ -27,6 +46,56 @@ export interface CalEvent extends Projection { /** The calendar is read-only for this person: never offer Edit. */ readOnly: boolean; calendar: { name: string; color: string }; + /** The row's Day (`atomic-calendar-day`) as stored: it places the event. */ + day: unknown; + /** The row's End day (`atomic-calendar-end-day`) as stored, if any. */ + endDay: unknown; +} + +/** The host view's day key: the first ten characters of the stored Day. */ +function hostKey(event: CalEvent): string | undefined { + return typeof event.day === 'string' && /^\d{4}-\d{2}-\d{2}/.test(event.day) + ? event.day.slice(0, 10) + : undefined; +} + +/** + * Whether the host table's Calendar view draws `event` on `date`: its + * bucketing of a row, for a table whose date column is `atomic-calendar-day`. + */ +export function occupies(event: CalEvent, date: string): boolean { + const key = hostKey(event); + if (event.allDay && event.endDay !== undefined) + return isAllDayOnDate(key, event.endDay, date); + + return key !== undefined && key === date; +} + +/** The first day the host view draws `event` on, if it draws it at all. */ +function firstDay(event: CalEvent): string | undefined { + const key = hostKey(event); + + return key !== undefined && occupies(event, key) ? key : undefined; +} + +/** + * A timed event's clock in its own stored offset: minutes since midnight of + * Start as written, and how long it lasts. Undefined when Start and End are + * not a valid timed interval. + */ +function clock( + event: Projection, +): { startMin: number; duration: number } | undefined { + const m = /^\d{4}-\d{2}-\d{2}T(\d{2}):(\d{2})/.exec(event.start); + const start = Date.parse(event.start); + const end = Date.parse(event.end); + if (!m || !Number.isFinite(start) || !Number.isFinite(end) || end <= start) + return undefined; + + return { + startMin: Number(m[1]) * 60 + Number(m[2]), + duration: Math.round((end - start) / 60_000), + }; } /** Start and end as instants, for ordering and timed layout. */ @@ -56,63 +125,72 @@ export function bounds( export interface Segment { event: CalEvent; date: string; - /** All-day, or a timed event covering this whole day (drawn as all-day). */ + /** Drawn in the all-day area: an all-day row, or a row without clock times. */ allDay: boolean; - /** Minutes since midnight in the viewer's zone (0 when it continues from the day before). */ + /** A row that is not all-day but has no valid Start and End to time it. */ + untimed?: boolean; + /** Minutes since midnight, in the event's own stored offset. */ startMin: number; - /** Minutes since midnight (1440 when it continues into the next day). */ + /** Minutes since midnight, capped at 1440 when the event runs past midnight. */ endMin: number; - /** 1-based day of a multi-day event, and how many days it covers. */ + /** When a timed event runs past midnight: End's own date and clock. */ + until?: { date: string; minutes: number }; + /** 1-based day of a multi-day all-day event, and how many days it covers. */ dayOf?: { n: number; total: number }; } -/** The per-day pieces of one event, clipped to [from, from + days). */ +/** + * The days of [from, from + days) the host view draws `event` on (see the + * file comment), one segment each. + */ export function segments( event: CalEvent, from: string, days: number, - zone: string, ): Segment[] { const out: Segment[] = []; - const last = addDays(from, days); - - if (event.allDay) { - if (!bounds(event, zone)) return out; - const total = daysBetween(event.start, event.end); - - for (let d = event.start, n = 1; d < event.end; d = addDays(d, 1), n++) - if (d >= from && d < last) - out.push({ - event, - date: d, - allDay: true, - startMin: 0, - endMin: 1440, - ...(total > 1 ? { dayOf: { n, total } } : {}), - }); - - return out; - } + const key = hostKey(event); + const total = + event.allDay && key !== undefined && isCalendarDate(event.endDay) + ? daysBetween(key, event.endDay) + : 1; + const time = event.allDay ? undefined : clock(event); + + for (let i = 0; i < days; i++) { + const date = addDays(from, i); + if (!occupies(event, date)) continue; + + if (event.allDay || !time) { + out.push({ + event, + date, + allDay: true, + ...(event.allDay ? {} : { untimed: true }), + startMin: 0, + endMin: 1440, + ...(key !== undefined && total > 1 + ? { dayOf: { n: daysBetween(key, date) + 1, total } } + : {}), + }); + continue; + } - const b = bounds(event, zone); - if (!b) return out; - const s = wall(b.start, zone); - const e = wall(b.end, zone); - // An end at exactly midnight belongs to the day before. - const lastDay = e.minutes === 0 ? addDays(e.date, -1) : e.date; - const total = daysBetween(s.date, lastDay) + 1; - - for (let d = s.date, n = 1; d <= lastDay; d = addDays(d, 1), n++) { - if (d < from || d >= last) continue; - const startMin = d === s.date ? s.minutes : 0; - const endMin = d === e.date ? e.minutes : 1440; + const endAt = time.startMin + time.duration; + const end = /^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2})/.exec(event.end); out.push({ event, - date: d, - allDay: total > 1 && startMin === 0 && endMin === 1440, - startMin, - endMin, - ...(total > 1 ? { dayOf: { n, total } } : {}), + date, + allDay: false, + startMin: time.startMin, + endMin: Math.min(endAt, 1440), + ...(endAt > 1440 && end + ? { + until: { + date: end[1], + minutes: Number(end[2]) * 60 + Number(end[3]), + }, + } + : {}), }); } @@ -140,13 +218,12 @@ export function agendaDays( events: CalEvent[], from: string, days: number, - zone: string, ): AgendaDay[] { const byDay = new Map(); for (let i = 0; i < days; i++) byDay.set(addDays(from, i), []); for (const event of events) - for (const segment of segments(event, from, days, zone)) + for (const segment of segments(event, from, days)) byDay.get(segment.date)!.push(segment); return [...byDay].map(([date, items]) => ({ @@ -190,14 +267,13 @@ export function packWeek( events: CalEvent[], from: string, count: number, - zone: string, ): WeekLayout { const days = Array.from({ length: count }, (_, i) => addDays(from, i)); const barSegs = new Map(); const perDay: Segment[][] = days.map(() => []); for (const event of events) - for (const segment of segments(event, from, count, zone)) { + for (const segment of segments(event, from, count)) { if (segment.allDay) { const list = barSegs.get(event.subject) ?? []; list.push(segment); @@ -285,20 +361,33 @@ function packColumn(sorted: Segment[]): Block[] { return out; } +/** + * Where `event` starts as the views draw it: its first day in the host view + * and, for a timed event, the clock time on it. Undefined when the host view + * draws it nowhere. + */ +function placed(event: CalEvent): { date: string; at: number } | undefined { + const date = firstDay(event); + if (date === undefined) return undefined; + const time = event.allDay ? undefined : clock(event); + + return { + date, + at: Date.parse(`${date}T00:00:00Z`) + (time?.startMin ?? 0) * 60_000, + }; +} + /** The first event starting on or after `date`, for "Jump to next event". */ export function nextEvent( events: CalEvent[], after: string, - zone: string, ): { event: CalEvent; date: string } | undefined { let best: { event: CalEvent; date: string; at: number } | undefined; for (const event of events) { - const b = bounds(event, zone); - if (!b) continue; - const date = event.allDay ? event.start : wall(b.start, zone).date; - if (date < after) continue; - if (!best || b.start < best.at) best = { event, date, at: b.start }; + const p = placed(event); + if (!p || p.date < after) continue; + if (!best || p.at < best.at) best = { event, ...p }; } return best && { event: best.event, date: best.date }; @@ -310,16 +399,13 @@ export function nextEvent( export function latestEvent( events: CalEvent[], before: string, - zone: string, ): { event: CalEvent; date: string } | undefined { let best: { event: CalEvent; date: string; at: number } | undefined; for (const event of events) { - const b = bounds(event, zone); - if (!b) continue; - const date = event.allDay ? event.start : wall(b.start, zone).date; - if (date >= before) continue; - if (!best || b.start > best.at) best = { event, date, at: b.start }; + const p = placed(event); + if (!p || p.date >= before) continue; + if (!best || p.at > best.at) best = { event, ...p }; } return best && { event: best.event, date: best.date }; @@ -330,11 +416,10 @@ export function busyDays( events: CalEvent[], from: string, days: number, - zone: string, ): Set { const out = new Set(); for (const event of events) - for (const s of segments(event, from, days, zone)) out.add(s.date); + for (const s of segments(event, from, days)) out.add(s.date); return out; } diff --git a/integrations/calendar/app/main.ts b/integrations/calendar/app/main.ts index 8ecef986..3ded0fbb 100644 --- a/integrations/calendar/app/main.ts +++ b/integrations/calendar/app/main.ts @@ -507,14 +507,14 @@ export async function view({ root, store }: ViewArgs): Promise { const rangeDays = ui.view === 'week' ? days : 7; const rangeFrom = ui.view === 'week' ? from : mondayOf(ui.anchor); - const empty = busyDays(events, rangeFrom, rangeDays, zone).size === 0; + const empty = busyDays(events, rangeFrom, rangeDays).size === 0; let body: HTMLElement; if (empty && snap.summary) { - const next = nextEvent(events, addDays(rangeFrom, rangeDays), zone); + const next = nextEvent(events, addDays(rangeFrom, rangeDays)); // Nothing later: point at the most recent earlier event instead, so an // import of past events never ends on an empty week with no way on. - const latest = next ? undefined : latestEvent(events, rangeFrom, zone); + const latest = next ? undefined : latestEvent(events, rangeFrom); const target = next ?? latest; body = emptyState(doc, { muted: true, diff --git a/integrations/calendar/app/package.json b/integrations/calendar/app/package.json index a1d40ec2..4cf8130b 100644 --- a/integrations/calendar/app/package.json +++ b/integrations/calendar/app/package.json @@ -1,7 +1,7 @@ { "name": "@atomic-integrations/calendar-app", "private": true, - "version": "0.1.1", + "version": "0.1.2", "type": "module", "description": "Google Calendar drive app. Records only the version its catalog entry and apps/calendar//ui.js carry (integrations/tooling/apps.mjs); no npm dependencies. Not a certified sandbox package (no atomicCertification)." } diff --git a/integrations/calendar/app/sync.ts b/integrations/calendar/app/sync.ts index 079bbf11..82f05eee 100644 --- a/integrations/calendar/app/sync.ts +++ b/integrations/calendar/app/sync.ts @@ -20,7 +20,8 @@ * for an all-day event it is Google's exclusive end date, the day after the * last day; for a timed event that ends on a later date it is that date, * and a timed event within one day has none. Day and End day are derived - * on import and never read back: move an event by editing Start and End. + * on import and on local edits, and never sent to Google. The app's own + * views place rows by them, as the host view does (`events.ts`). * - The binding lives on the row, not in a separate store: the Google event * id, the ETag last read, and the baseline — the projection both sides * last agreed on, as JSON text. The baseline is what lets a refresh tell a @@ -901,6 +902,9 @@ export async function readEvents( conflict: inConflict.has(subject), readOnly: readOnly || !card.id, calendar: { name: meta.summary, color: meta.color }, + // What the host's Calendar view places the row by; the views do too. + day: row.get(props[DAY]), + endDay: row.get(props[END_DAY]), }); } diff --git a/integrations/calendar/app/week.ts b/integrations/calendar/app/week.ts index ff7f6ec7..b422b32f 100644 --- a/integrations/calendar/app/week.ts +++ b/integrations/calendar/app/week.ts @@ -6,7 +6,7 @@ * `
` with a list of event buttons; a visually hidden "Switch to * agenda view" link comes first. */ -import { accessibleName, type Ctx } from './context.js'; +import { accessibleName, endClock, type Ctx } from './context.js'; import { packWeek, type Block, type CalEvent } from './events.js'; import { h } from './ui/dom.js'; import { hhmm, longDay, offsetLabel, wall, weekdayShort } from './time.js'; @@ -46,7 +46,7 @@ function block(ctx: Ctx, b: Block, lanes: number, left: number): HTMLElement { // Under 75 minutes there is room for one title line and the time. const oneLine = s.endMin - s.startMin < 75; const width = `calc(${100 / lanes}% - 4px)`; - const meta = [`${hhmm(s.startMin)}–${hhmm(s.endMin)}`, e.location] + const meta = [`${hhmm(s.startMin)}–${endClock(s, true)}`, e.location] .filter(Boolean) .join(' · '); @@ -168,7 +168,7 @@ export function week( gridWidth: number, ): HTMLElement { const { doc } = ctx; - const layout = packWeek(events, from, count, ctx.zone); + const layout = packWeek(events, from, count); const colWidth = (gridWidth - GUTTER) / count; const maxLanes = Math.max(1, Math.floor(colWidth / MIN_LANE)); const template = `--n:${count}`; diff --git a/integrations/calendar/devonian/google-calendar/lens/lens.test.ts b/integrations/calendar/devonian/google-calendar/lens/lens.test.ts index c428a109..aa60f3c5 100644 --- a/integrations/calendar/devonian/google-calendar/lens/lens.test.ts +++ b/integrations/calendar/devonian/google-calendar/lens/lens.test.ts @@ -1,6 +1,9 @@ // @wc-ignore-file import { describe, expect, it } from 'vitest'; +import { Datatype, isAllDayOnDate } from '@tomic/lib'; import { planCalendarValues } from './edit.js'; +import { calendarFields, calendarProjection } from './projection.js'; +import type { FetchedPlatform, JSONValue } from './types.js'; import { applyCalendarEdit, planCalendarEdit } from '../sync.js'; // Moved from devonian/__tests__/unit/platformLenses.test.ts with the lens. @@ -134,3 +137,77 @@ describe('Calendar runtime after lens extraction', () => { expect(checkpoint).toMatchObject({ [name]: 'After', summary: 'After' }); }); }); + +describe('calendarProjection: the host Calendar view’s format', () => { + const fetched = (start: JSONValue, end: JSONValue): FetchedPlatform => ({ + platform: 'google-calendar', + ontology: { + description: 'Google Calendar', + terms: [ + { + path: 'urn:event', + kind: 'class', + shortname: 'event', + description: 'An event', + datatype: Datatype.STRING, + requires: [], + recommends: [], + }, + ], + }, + records: [ + { + resource: 'event', + namespace: 'primary', + id: 'e1', + name: 'Invented event', + values: { start, end }, + }, + ], + }); + const projected = (start: JSONValue, end: JSONValue) => + calendarProjection(fetched(start, end)).records[0].values; + + it('keeps Google’s exclusive all-day end as End day', () => { + expect( + projected({ date: '2026-11-16' }, { date: '2026-11-19' }), + ).toMatchObject({ + [calendarFields.day]: '2026-11-16', + [calendarFields.allDay]: true, + [calendarFields.endDay]: '2026-11-19', + }); + }); + + it('reads end.date == start.date as one day, not as an empty range (#184)', () => { + const values = projected({ date: '2026-12-31' }, { date: '2026-12-31' }); + expect(values).toMatchObject({ + [calendarFields.day]: '2026-12-31', + [calendarFields.endDay]: '2027-01-01', + }); + // Google's own start/end stay as sent, for write-back. + expect(values.end).toEqual({ date: '2026-12-31' }); + expect( + isAllDayOnDate( + values[calendarFields.day], + values[calendarFields.endDay], + '2026-12-31', + ), + ).toBe(true); + }); + + it('still refuses an all-day end before its start', () => { + expect(() => + projected({ date: '2026-12-31' }, { date: '2026-12-30' }), + ).toThrow('invalid all-day interval'); + }); + + it('gives a timed event its Day in the supplied offset and no End day', () => { + const values = projected( + { dateTime: '2026-09-24T23:30:00-04:00' }, + { dateTime: '2026-09-25T00:30:00-04:00' }, + ); + expect(values[calendarFields.day]).toBe('2026-09-24'); + expect(values[calendarFields.allDay]).toBe(false); + expect(values[calendarFields.endDay]).toBeUndefined(); + }); +}); diff --git a/integrations/calendar/devonian/google-calendar/lens/projection.ts b/integrations/calendar/devonian/google-calendar/lens/projection.ts index a6e30fd8..16c18a45 100644 --- a/integrations/calendar/devonian/google-calendar/lens/projection.ts +++ b/integrations/calendar/devonian/google-calendar/lens/projection.ts @@ -3,6 +3,7 @@ import { Datatype, calendarFields, isCalendarDate as validDay, + nextCalendarDate, } from '@tomic/lib'; import type { JSONValue, FetchedPlatform, Term } from './types.js'; @@ -11,7 +12,11 @@ export { calendarFields } from '@tomic/lib'; /** An additional projection, never a replacement for the provider's fields. * One DATE column supports both all-day dates and timed events in a single view. * Timed events use the date in Google's supplied offset; raw start/end retain - * the instant and zone. All-day ends are also projected for range rendering. + * the instant and zone. All-day ends are also projected for range rendering, + * in the host's exclusive form. An all-day event whose end.date equals its + * start.date (Google's UI shows it as one day; its own writes use the day + * after) gets the day after as its End day, as the drive app's `project()` + * does (#184): the host view would draw End day == Day on no day at all. */ export function calendarProjection(fetched: FetchedPlatform): FetchedPlatform { if (fetched.platform !== 'google-calendar') return fetched; @@ -112,7 +117,7 @@ export function calendarProjection(fetched: FetchedPlatform): FetchedPlatform { (start.dateTime !== undefined || end.dateTime !== undefined || !validDay(end.date) || - end.date <= start.date!)) || + end.date < start.date!)) || (!allDay && end.date !== undefined)) ) throw new Error( @@ -149,7 +154,8 @@ export function calendarProjection(fetched: FetchedPlatform): FetchedPlatform { values[calendarFields.day] = date.slice(0, 10); values[calendarFields.allDay] = allDay; if (allDay && validDay(end.date)) - values[calendarFields.endDay] = end.date; + values[calendarFields.endDay] = + end.date === start.date ? nextCalendarDate(end.date) : end.date; } return { ...row, values }; diff --git a/integrations/calendar/e2e/calendar.spec.ts b/integrations/calendar/e2e/calendar.spec.ts index bdd0a622..2b6645f0 100644 --- a/integrations/calendar/e2e/calendar.spec.ts +++ b/integrations/calendar/e2e/calendar.spec.ts @@ -32,7 +32,7 @@ import { before } from '../../../browser/e2e/tests/test-utils'; const APP_FRAME = 'iframe[title="App"]'; /** The catalog's version of this app (integrations/catalog.json). */ -const VERSION = '0.1.1'; +const VERSION = '0.1.2'; const NAME = 'https://atomicdata.dev/properties/name'; /** The host's shared calendar field names (`@tomic/lib` `calendarFields`). */ const DAY = 'atomic-calendar-day'; diff --git a/integrations/catalog.json b/integrations/catalog.json index 82b0b524..c85dd424 100644 --- a/integrations/catalog.json +++ b/integrations/catalog.json @@ -167,11 +167,11 @@ "https://atomicdata.dev/integrations/properties/events": "Install from Drive apps, connect Google Calendar through the host's consent bar and choose a calendar. It syncs when opened and on Sync now; Send sends the reviewed edits. Nothing runs while the app is closed.", "https://atomicdata.dev/integrations/properties/limitation": "One calendar per app, up to 25,000 events per scan. Recurring events (series and instances) and cancelled events are not imported; an imported event cancelled in Google is reported, never deleted. No creating or deleting events in Google. Mock-tested only: not yet verified against a live Google account.", "https://atomicdata.dev/integrations/properties/keywords": "google calendar events agenda week month schedule drive app two-way review", - "https://atomicdata.dev/integrations/properties/version": "0.1.1", + "https://atomicdata.dev/integrations/properties/version": "0.1.2", "https://atomicdata.dev/integrations/properties/app-row-name": "Event", "https://atomicdata.dev/integrations/properties/app-row-name-plural": "Events", - "https://atomicdata.dev/integrations/properties/app-module": "https://ontola.github.io/atomic-plugins/apps/calendar/0.1.1/ui.js", - "https://atomicdata.dev/integrations/properties/app-module-integrity": "sha384-TWIhxFCrYOCtmlHL+faWPcQSbKndLcY1gGtSxMJ3yEhN1B3tUBQp6lHi9w84lZ9i" + "https://atomicdata.dev/integrations/properties/app-module": "https://ontola.github.io/atomic-plugins/apps/calendar/0.1.2/ui.js", + "https://atomicdata.dev/integrations/properties/app-module-integrity": "sha384-vNveURZFLgD4O9jmppoh9EwESAONdgkbw4QWyYKqNTNi0tTn0dtDWg10hUoloQt2" }, { "https://atomicdata.dev/properties/localId": "devonian-todoist",