From a96ef56f7600bc3c4e2c8df84d686fede3913e01 Mon Sep 17 00:00:00 2001 From: Michiel de Jong Date: Tue, 29 Sep 2026 14:22:47 +0200 Subject: [PATCH 1/2] calendar: compare on open reads host-format edits back to Google (#192) Edits made to synced rows outside the app (the host's table, its Calendar view, another device) were already found on open for Name, Notes, Location, Start, End and All day. Edits to Day and End day were not: the next sync rewrote them from Start and End. hostValue() now reads the row's host format back through the lens (exclusive End day for all-day, Day's date with Start's clock and offset for timed), decided against the on-row baseline; what has no Google equivalent is held back and listed. Columns the app doesn't map are listed as kept here only. After a send, the row's Start and End are written from Google's answer. Release 0.1.3 (catalog entry stays enabled: false). Unit tests per case in app/compare.test.ts; the e2e edits End day as the user, reopens the app, reviews and sends it. Co-Authored-By: Claude Opus 5.5 --- apps/calendar/0.1.3/ui.js | 2 + integrations/calendar/README.md | 84 +++- integrations/calendar/app/compare.test.ts | 489 +++++++++++++++++++ integrations/calendar/app/controller.test.ts | 1 + integrations/calendar/app/controller.ts | 4 + integrations/calendar/app/main.ts | 1 + integrations/calendar/app/package.json | 2 +- integrations/calendar/app/sheets.ts | 9 + integrations/calendar/app/sync.ts | 228 ++++++++- integrations/calendar/e2e/calendar.spec.ts | 79 ++- integrations/catalog.json | 6 +- 11 files changed, 873 insertions(+), 32 deletions(-) create mode 100644 apps/calendar/0.1.3/ui.js create mode 100644 integrations/calendar/app/compare.test.ts diff --git a/apps/calendar/0.1.3/ui.js b/apps/calendar/0.1.3/ui.js new file mode 100644 index 00000000..6a105c13 --- /dev/null +++ b/apps/calendar/0.1.3/ui.js @@ -0,0 +1,2 @@ +var Zn=Object.create;var St=Object.defineProperty;var Kn=Object.getOwnPropertyDescriptor;var Xn=Object.getOwnPropertyNames;var Qn=Object.getPrototypeOf,er=Object.prototype.hasOwnProperty;var tr=(e,t)=>()=>{try{return t||e((t={exports:{}}).exports,t),t.exports}catch(n){throw t=0,n}};var nr=(e,t,n,r)=>{if(t&&typeof t=="object"||typeof t=="function")for(let o of Xn(t))!er.call(e,o)&&o!==n&&St(e,o,{get:()=>t[o],enumerable:!(r=Kn(t,o))||r.enumerable});return e};var rr=(e,t,n)=>(n=e!=null?Zn(Qn(e)):{},nr(t||!e||!e.__esModule?St(n,"default",{value:e,enumerable:!0}):n,e));var Dt=tr((qr,Tt)=>{"use strict";Tt.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 g={key:l,value:s[l]},d={key:c,value:s[c]};return i(g,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,Ze.default)(e)===(0,Ze.default)(t);function Pt(e,t,n){let r={local:{},remote:{},createLocal:!1,createRemote:!1,deleteLocal:!1,deleteRemote:!1,conflicts:[]};if(pe(t,n))return t!==void 0&&(r.agreed=structuredClone(t)),r;if(t===null||n===null)return pe(t,e)&&n===null?r.deleteLocal=!0:pe(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];pe(l,c)||(pe(l,s)?r.local[i]=structuredClone(c):pe(c,s)?r.remote[i]=structuredClone(l):r.conflicts.push({property:i,base:s,local:l,remote:c}))}return r}var ue=class extends Error{},ve=class extends Error{constructor(n){super("Google event changed after preview; preview again");this.id=n}id},Mt=250,ar=100,jt={Authorization:"secret:google-calendar","Content-Type":"application/json"};function Ke(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 or(e,t){return t!==e?t:new Date(Date.parse(`${e}T00:00:00Z`)+864e5).toISOString().slice(0,10)}function Xe(e){return/^\d{4}-\d{2}-\d{2}T.*(?:Z|[+-]\d{2}:\d{2})$/.test(e)&&Number.isFinite(Date.parse(e))}function Qe(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 ue(`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||!Ke(e.start.date)||!e.end.date||!Ke(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 et(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 Lt(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 At(e,t,n,r,o,i){return{operation:e,method:t,url:n,id:r,headers:i?{...jt,"If-Match":i}:jt,...o===void 0?{}:{body:JSON.stringify(o)}}}async function Nt(e,t,{maxPages:n=ar}={}){let r=et(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},g=[],d,b=0;do{if(++b>n)throw new Error(`Pilot supports at most ${String(n*Mt).replace(/\B(?=(\d{3})+$)/g,",")} events per scan`);let p=`${r}?singleEvents=false&showDeleted=true&maxResults=${Mt}${d?`&pageToken=${encodeURIComponent(d)}`:""}`,x=Lt(await e.read(At("list","GET",p,`page-${b}`)));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=Qe(v)}catch(w){if(!(w instanceof ue))throw w;c.unreadable++,g.push({id:v.id,title:v.summary??"",reason:w.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(Rt(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 y={calendarId:t,revision:o.revision,changes:[],conflicts:[],skipped:c,unreadable:g};for(let[p,x]of i){let v=o.records[p],h=u.get(p);if(v&&(!h||v.local!==h.subject)){y.conflicts.push({...v.local?{subject:v.local}:{},id:p,fields:["Missing or rebound local card"]});continue}let w=Pt(v?.baseline,h?.value,x);if(w.conflicts.length){y.conflicts.push({subject:h?.subject,id:p,fields:w.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,...w.remote};Rt(C),y.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?y.changes.push({subject:p.subject,local:p.value,desired:p.value}):i.has(p.id)||y.conflicts.push({subject:p.subject,id:p.id,fields:["Event cancelled, recurring or inaccessible; no deletion inferred"]});return y}function Ot(e,t){return t?{date:e}:{dateTime:e}}function Ht(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=Ot(t.start,t.allDay)),(t.end!==n.end||t.allDay!==n.allDay)&&(o.end=Ot(t.end,t.allDay)),!!Object.keys(o).length)return{id:e,subject:r,patch:o}}async function zt(e,t,n,r){let o=await e.read(At("update","PATCH",`${t}/${encodeURIComponent(n.id)}?sendUpdates=none`,"write",n.patch,r));if(o.status===412)throw new ve(n.id);return Lt(o)}function U(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 Gt(e,t,n){return U(e)&&U(t)&&U(n)&&e<=n&&n`${String(t).padStart(4,"0")}-${String(n).padStart(2,"0")}-${String(r).padStart(2,"0")}`;if(r+=1,U(o())||(r=1,n+=1),n>12&&(n=1,t+=1),!U(o()))throw new Error("Calendar date out of range");return o()}function Vt(){return Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC"}var _t=new Map;function ir(e){let t=_t.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"}),_t.set(e,t)),t}function N(e,t){let n={};for(let r of ir(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 ge(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 Ge(e,t,n){let r=Date.parse(`${e}T00:00:00Z`)+t*6e4,o=r-ge(r,n)*6e4;return o=r-ge(o,n)*6e4,o}function tt(e){let t=e<0?"-":"+",n=Math.abs(e);return`${t}${ke(Math.floor(n/60))}:${ke(n%60)}`}var ke=e=>String(e).padStart(2,"0");function R(e){return`${ke(Math.floor(e/60))}:${ke(e%60)}`}function nt(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 rt(e,t,n){let r=Ge(e,t,n),o=N(r,n);return`${o.date}T${R(o.minutes)}:00${tt(ge(r,n))}`}function P(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 Yt(e){return(new Date(`${e}T00:00:00Z`).getUTCDay()+6)%7}function ne(e){return P(e,-Yt(e))}function le(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 sr=["Monday","Tuesday","Wednesday","Thursday","Friday","Saturday","Sunday"],lr=["January","February","March","April","May","June","July","August","September","October","November","December"],oe=e=>Number(e.slice(8,10)),te=e=>lr[Number(e.slice(5,7))-1];function fe(e){return sr[Yt(e)]}function Ce(e){return fe(e).slice(0,3)}function J(e){return`${Ce(e)} ${oe(e)} ${te(e).slice(0,3)}`}function z(e){return`${fe(e)} ${oe(e)} ${te(e)}`}function Ee(e){return`${te(e)} ${e.slice(0,4)}`}function Ft(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]=[oe(e),oe(t)];return n!==r?`${o} ${te(e)} ${n} \u2013 ${i} ${te(t)} ${r}`:e.slice(5,7)!==t.slice(5,7)?`${o} ${te(e)} \u2013 ${i} ${te(t)} ${r}`:`${o} \u2013 ${i} ${te(t)} ${r}`}function Bt(e,t){let n=r=>te(r).slice(0,3);return e.slice(0,7)===t.slice(0,7)?`${oe(e)} \u2013 ${oe(t)} ${n(t)}`:`${oe(e)} ${n(e)} \u2013 ${oe(t)} ${n(t)}`}function Ie(e,t){let n=ge(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?`:${ke(o)}`:""}`}var Se="google-calendar",at="https://www.googleapis.com/calendar/v3",$e=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},Ut=["unknown_connection","not_delegated","capability_scope","platform_mismatch","credential_refresh_failed"],cr=[...Ut,"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 dr(e){let t=e.body,n=typeof t?.error=="string"?t.error:void 0;if(e.status<400||!n||!cr.includes(n))return;let r=typeof t?.message=="string"?`: ${t.message}`:"";return new Error(Ut.includes(n)?`The integration proxy refused this connection (${n}${r}). Connect again.`:`The integration proxy refused the request (${n}${r}).`)}function pr(e){let t=new URL(e),n=new URL(at);if(t.origin!==n.origin||!t.pathname.startsWith(`${n.pathname}/`)||t.username||t.password||t.hash)throw new Error(`Refusing a request outside ${at}: ${t.href}`);return{path:t.pathname,query:Object.fromEntries(t.searchParams)}}function _e(e,t){let n={async read(r){let{path:o,query:i}=pr(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:Se,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 $e(r.id,d)}n.last=c;let g=dr(c);if(g)throw g;return{status:c.status,body:typeof c.body=="string"?c.body:JSON.stringify(c.body??null)}}};return n}async function ot(e,t){let n=_e(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(`${at}/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 g of c.items)typeof g.id=="string"&&g.id&&r.push({id:g.id,summary:typeof g.summary=="string"?g.summary:g.id,primary:g.primary===!0,accessRole:typeof g.accessRole=="string"?g.accessRole:"reader",...typeof g.backgroundColor=="string"&&/^#[0-9a-f]{6}$/i.test(g.backgroundColor)?{backgroundColor:g.backgroundColor}:{}});o=c.nextPageToken}while(o);return r}var W="https://atomicdata.dev",ct=`${W}/properties/parent`,ia=`${W}/properties/isA`,K=`${W}/properties/name`,De=`${W}/properties/shortname`,ur=`${W}/properties/description`,gr=`${W}/properties/datatype`,it=`${W}/properties/recommends`,fr=`${W}/properties/requires`,st=`${W}/properties/properties`,mr=`${W}/classes/Property`,V=`${W}/datatypes`,Ye="atomic-calendar-day",dt="atomic-calendar-all-day",Fe="atomic-calendar-end-day",pt="atomic-calendar-notes",Te={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},[dt]:{name:"All day",datatype:`${V}/boolean`,description:"Whether Start and End are dates rather than date-times.",column:!0},[Ye]:{name:"Day",datatype:`${V}/date`,description:"The date of Start, for calendar views. Derived from Start; editing it moves the event, and the Calendar app offers that move to Google for review.",column:!0},[Fe]:{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 from End; editing it changes the end, and the Calendar app offers that to Google for review.",column:!0},[pt]:{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}},Me="#4986e7",je=e=>e==="reader"||e==="freeBusyReader",Pe=e=>Array.isArray(e)?e.filter(t=>typeof t=="string"):[];async function q(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(ct);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 re(e,t,n){let r=await e.getResource(t.ontology),o=Pe(r.get(st)),i=new Map;for(let u of o){let y=(await e.getResource(u)).get(De);typeof y=="string"&&i.set(y,u)}let s=Object.keys(Te).filter(u=>!i.has(u));if(s.length&&!n)return;let l=[];for(let u of s){let y=Te[u],p=await e.newResource({parent:t.ontology,isA:[mr],propVals:{[De]:u,[K]:y.name,[ur]:y.description,[gr]:y.datatype}});i.set(u,p.subject),l.push(p.subject)}l.length&&await r.set(st,[...o,...l]).save();let c=Object.fromEntries(Object.keys(Te).map(u=>[u,i.get(u)])),g=await e.getResource(t.rowClass),d=Pe(g.get(it)),b=[K,...Object.keys(Te).filter(u=>Te[u].column).map(u=>c[u])],m=[...d,...b.filter(u=>!d.includes(u))];return(m.length!==d.length||g.get(K)!=="Event")&&await g.set(it,m).set(K,"Event").save(),c}async function hr(e,t){let n=await e.getResource(t.ontology),r=new Map;for(let o of Pe(n.get(st))){let i=(await e.getResource(o)).get(De);typeof i=="string"&&r.set(i,o)}return r}function yr(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:Me,accessRole:typeof t.accessRole=="string"?t.accessRole:"reader",...typeof t.account=="string"?{account:t.account}:{}}}catch{return}}async function Jt(e){let t=await q(e),n=await hr(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?yr(o.get(s)):void 0;return{id:i,...l?{meta:l}:{}}}async function Wt(e,t,n){let r=await q(e),o=await re(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??Me,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(K,t.summary).save(),l}var me=e=>typeof e=="string"?e:"";function Be(e,t){let n=e.get(t["google-event-id"]),r=typeof n=="string"&&!!n,o={start:me(e.get(t.start)),end:me(e.get(t.end)),allDay:e.get(t[dt])===!0},i=r?br({...o,day:e.get(t[Ye]),endDay:e.get(t[Fe])},Ve(e,t)):o;return{subject:e.subject,...r?{id:n}:{},value:{title:me(e.get(K)),description:me(e.get(t[pt])),location:me(e.get(t.location)),..."reason"in i?o:i},..."reason"in i?{reason:i.reason}:{}}}function br(e,t){let n={start:e.start,end:e.end,allDay:e.allDay};if(!t)return n;let r=typeof e.day=="string"?e.day:void 0,o=typeof e.endDay=="string"?e.endDay:void 0,i=e.start!==t.start||e.end!==t.end,s=e.allDay!==t.allDay;if(r===e.start.slice(0,10)&&o===lt(n)&&(i||!s)||r===void 0&&!s)return n;let c=lt(t),g=r!==t.start.slice(0,10)||o!==c;if(i)return g?{reason:"Start or End and Day or End day were edited in the table and disagree; change the event with Edit in the app"}:n;if(!U(r))return{reason:"Day must be a date (YYYY-MM-DD)"};if(o!==void 0&&!U(o))return{reason:"End day must be a date (YYYY-MM-DD)"};if(e.allDay)return o===void 0?{start:r,end:It(r),allDay:!0}:o<=r?{reason:"End day must be after Day: for an all-day event it is the day after the last day"}:{start:r,end:o,allDay:!0};if(t.allDay)return{reason:"All day was turned off in the table, with no times to send; set the times with Edit in the app"};let d=e.start.slice(0,10),b=o!==c?o??r:P(e.end.slice(0,10),se(d,r));return{start:`${r}${e.start.slice(10)}`,end:`${b}${e.end.slice(10)}`,allDay:!1}}function Ve(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 qt(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"}var wr=new Set(["day","all-day"]),xr=e=>e!=null&&e!==""&&!(Array.isArray(e)&&!e.length);async function vr(e,t,n){let r=await e.getResource(t.rowClass),o=new Set([K,...Object.values(n)]),i=[];for(let s of new Set([...Pe(r.get(fr)),...Pe(r.get(it))])){if(o.has(s))continue;let l=(await e.getResource(s)).get(De);typeof l=="string"&&wr.has(l)||i.push(s)}return i}async function kr(e,t,n){let r={props:n,bound:new Map,localOnly:0,invalid:new Map,unmapped:[]},o=await vr(e,t,n),i=new Map(o.map(s=>[s,0]));for(let s of await e.query({property:ct,value:t.table})){let l=await e.getResource(s);if(!Be(l,n).id){r.localOnly++;continue}r.bound.set(s,l);for(let g of o)xr(l.get(g))&&i.set(g,i.get(g)+1)}for(let[s,l]of i){if(!l)continue;let c=await e.getResource(s),g=c.get(K)??c.get(De);r.unmapped.push({column:typeof g=="string"&&g?g:s,rows:l})}return r}function Cr(e,t,n){let r=_e(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{reason:l,...c}=Be(s,o),g=l??qt(c.value);if(g){n.invalid.set(s.subject,{title:c.value.title,reason:g});let d=Ve(s,o);d&&i.push({...c,value:d});continue}i.push(c)}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:Ve(s,o)}}return{revision:0,records:i,cursor:null}}}}var Y={title:"Title",description:"Description",location:"Location",start:"Start",end:"End",allDay:"All day"};function Er(e,t){return Object.keys(Y).filter(n=>e[n]!==t[n]).map(n=>({field:Y[n],before:String(e[n]),after:String(t[n])}))}function lt(e){let t=e.start.slice(0,10),n=e.end.slice(0,10);return e.allDay||n>t?n:void 0}function Zt(e,t){return{[K]:t.title,[e[pt]]:t.description,[e.location]:t.location,[e.start]:t.start,[e.end]:t.end,[e[dt]]:t.allDay,[e[Ye]]:t.start.slice(0,10),[e[Fe]]:lt(t)}}function $r(e){return Object.fromEntries(Object.entries(e).filter(([,t])=>t!==void 0))}function Re(e,t,n){let r=!1;for(let[o,i]of Object.entries(Zt(t,n)))e.get(o)!==i&&(i===void 0?e.remove(o):e.set(o,i),r=!0);return r}async function Kt(e,t,n,r={}){let o=await q(e),i=await re(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 kr(e,o,i),g=Cr(t,n,c),d=0,b=g.read;g.read=async p=>{let x=await b(p);return p.operation==="list"&&r.onPage?.(++d),x};let m=await Sr(g.relayed,()=>Nt(g,l,{maxPages:r.maxPages})),u=new Map([...c.bound.values()].map(p=>[p.subject,me(p.get(K))])),y={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()],unmapped:c.unmapped,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:{...$r(Zt(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}:{}}}),y.added++;continue}let v=c.bound.get(p.subject),h=Re(v,i,p.desired),w=p.link??"",C=v.get(i["google-etag"])!==(p.etag??"")||v.get(i["sync-baseline"])!==x||(v.get(i["google-link"])??"")!==w;C&&v.set(i["google-etag"],p.etag??"").set(i["sync-baseline"],x).set(i["google-link"],w),(h||C)&&await v.save(),h?y.updated++:y.unchanged++;let T=Ht(p.id,p.desired,p.remote,p.subject);T&&p.etag&&y.review.push({edit:T,etag:p.etag,title:p.remote.title,fields:Er(p.remote,p.desired),remote:p.remote,desired:p.desired})}return y}async function Xt(e,t,n,r,o,i){let s=await q(e),l=await re(e,s,!1),c=_e(t,n),g=et(r),d=[],b=!1;for(let m of o){let{title:u}=m;if(b){d.push({status:"not-sent",title:u}),i?.(d.length-1,d.at(-1));continue}i?.(d.length);try{let y=await zt({read:c.read,cards:async()=>[],state:async()=>Tr()},g,m.edit,m.etag),p=Qe(y);if(m.edit.subject&&p){let x=await e.getResource(m.edit.subject),v=Be(x,l);!v.reason&&Object.keys(Y).every(h=>v.value[h]===m.desired[h])&&Re(x,l,p),await x.set(l["google-etag"],y.etag).set(l["sync-baseline"],JSON.stringify(p)).save()}d.push({status:"sent",title:u})}catch(y){y instanceof ve?d.push({status:"stale",title:u}):y instanceof $e?(d.push({status:"uncertain",title:u,message:y.message}),b=!0):d.push({status:"failed",title:u,message:y instanceof Error?y.message:String(y)})}i?.(d.length-1,d.at(-1))}return d}async function Sr(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 Qt(e,t,n=[]){let r=await q(e),o=await re(e,r,!0),i=new Set(n.map(c=>c.subject)),s=[],l=je(t.accessRole);for(let c of await e.query({property:ct,value:r.table})){let g=await e.getResource(c),d=Be(g,o),b=Ve(g,o),m=g.get(o["google-link"]);s.push({...d.value,subject:c,...d.id?{id:d.id}:{},...b?{baseline:b}:{},...typeof m=="string"&&/^https:\/\//.test(m)?{link:m}:{},pending:!!d.id&&!!b&&(!!d.reason||Object.keys(Y).some(u=>b[u]!==d.value[u])),conflict:i.has(c),readOnly:l||!d.id,calendar:{name:t.summary,color:t.color},day:g.get(o[Ye]),endDay:g.get(o[Fe])})}return s}async function en(e,t){let n=await q(e),r=await re(e,n,!0);await(await e.getResource(n.table)).set(r["google-calendar-meta"],JSON.stringify(t)).save()}async function tn(e,t,n){let r=await q(e),o=await re(e,r,!0),i=await e.getResource(t);Re(i,o,n)&&await i.save()}async function nn(e,t){if(!t.edit.subject)return;let n=await q(e),r=await re(e,n,!0),o=await e.getResource(t.edit.subject);Re(o,r,t.remote),await o.set(r["google-etag"],t.etag).set(r["sync-baseline"],JSON.stringify(t.remote)).save()}async function rn(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(Y);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 y=n[u];if(y)return[u,y==="mine"?o[u]:i[u]];let p=s?s[u]!==o[u]:!1;return[u,p?o[u]:i[u]]})),g=qt(c);if(g)throw new Error(`That combination can\u2019t be saved: ${g}.`);let d=await q(e),b=await re(e,d,!0),m=await e.getResource(r);Re(m,b,c),m.set(b["sync-baseline"],JSON.stringify(i)).set(b["google-etag"],t.etag??""),await m.save()}async function an(e,t){let n=await q(e),r=await re(e,n,!0);await(await e.getResource(t)).remove(r["google-event-id"]).remove(r["google-etag"]).remove(r["sync-baseline"]).save()}async function on(e){return(await q(e)).table}async function sn(e,t){await(await e.getResource(t)).destroy()}function Tr(){throw new Error("applyEdit does not read connection state")}function ln(e,t,n){return t===e.today?"Today":t===P(e.today,1)?"Tomorrow":t===P(e.today,-1)?"Yesterday":n}function Dr(e){return e.untimed?"No time set":e.allDay?"All day":`${R(e.startMin)} to ${ut(e)}`}function ut(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 Oe(e){let t=e.event,n=[t.title||"(untitled)",Dr(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 cn(e,t){let n=typeof e.day=="string"&&le(e.day.slice(0,10))?z(e.day.slice(0,10)):"No date set";if(e.allDay&&(!le(e.start)||!le(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=P(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],g=tt(ge(r,t)),d=c==="Z"?"+00:00":c,b=m=>m.slice(11,16);return{day:z(i.date),time:l,...d&&d!==g?{own:`${b(e.start)} \u2013 ${b(e.end)} in the event\u2019s time zone (UTC${d.replace("-","\u2212")})`}:{}}}function he(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"?P(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 dn(e,t){let n=e.baseline;if(!n)return;let r=Object.keys(Y).filter(s=>n[s]!==e[s]);if(!r.length)return;if(r.length===1){let[s]=r;return`${Y[s]} changed from ${he(s,n,t)} to ${he(s,e,t)}.`}let o=r.map(s=>Y[s].toLowerCase()),i=`${o.slice(0,-1).join(", ")} and ${o.at(-1)}`;return`${i[0].toUpperCase()}${i.slice(1)} changed.`}function gt(e){return typeof e.day=="string"&&/^\d{4}-\d{2}-\d{2}/.test(e.day)?e.day.slice(0,10):void 0}function pn(e,t){let n=gt(e);return e.allDay&&e.endDay!==void 0?Gt(n,e.endDay,t):n!==void 0&&n===t}function Pr(e){let t=gt(e);return t!==void 0&&pn(e,t)?t:void 0}function un(e){let t=/^\d{4}-\d{2}-\d{2}T(\d{2}):(\d{2})/.exec(e.start),n=Date.parse(e.start),r=Date.parse(e.end);if(!(!t||!Number.isFinite(n)||!Number.isFinite(r)||r<=n))return{startMin:Number(t[1])*60+Number(t[2]),duration:Math.round((r-n)/6e4)}}function gn(e,t){if(e.allDay){let o=Date.parse(`${e.start}T00:00:00Z`),i=Date.parse(`${e.end}T00:00:00Z`);return!Number.isFinite(o)||!Number.isFinite(i)||i<=o?void 0:{start:Ge(e.start,0,t),end:Ge(e.end,0,t)}}let n=Date.parse(e.start),r=Date.parse(e.end);if(!(!Number.isFinite(n)||!Number.isFinite(r)||r<=n))return{start:n,end:r}}function ft(e,t,n){let r=[],o=gt(e),i=e.allDay&&o!==void 0&&U(e.endDay)?se(o,e.endDay):1,s=e.allDay?void 0:un(e);for(let l=0;l1?{dayOf:{n:se(o,c)+1,total:i}}:{}});continue}let g=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(g,1440),...g>1440&&d?{until:{date:d[1],minutes:Number(d[2])*60+Number(d[3])}}:{}})}return r}var fn=(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 mn(e,t,n){let r=new Map;for(let o=0;o({date:o,items:i.sort(fn)}))}function hn(e,t,n){let r=Array.from({length:n},(d,b)=>P(t,b)),o=new Map,i=r.map(()=>[]);for(let d of e)for(let b of ft(d,t,n))if(b.allDay){let m=o.get(d.subject)??[];m.push(b),o.set(d.subject,m)}else i[r.indexOf(b.date)].push(b);let s=[];for(let d of o.values()){let b;for(let m of d){let u=r.indexOf(m.date);b&&b.col+b.span===u?b.span++:(b={event:m.event,col:u,span:1},s.push(b))}}s.sort((d,b)=>d.col-b.col||b.span-d.span||d.event.title.localeCompare(b.event.title));let l=[],c=s.map(d=>{let b=0;for(;;b++){l[b]??=[];let m=l[b],u=!0;for(let y=d.col;yMr(d.sort(fn)));return{days:r,bars:c,rows:l.length,columns:g}}function Mr(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(g=>g<=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 yn(e){let t=Pr(e);if(t===void 0)return;let n=e.allDay?void 0:un(e);return{date:t,at:Date.parse(`${t}T00:00:00Z`)+(n?.startMin??0)*6e4}}function bn(e,t){let n;for(let r of e){let o=yn(r);!o||o.date=t||(!n||o.at>n.at)&&(n={event:r,...o})}return n&&{event:n.event,date:n.date}}function Ue(e,t,n){let r=new Set;for(let o of e)for(let i of ft(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"),jr(o,r),o}function jr(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 mt(e){return e.activeElement?.dataset?.key}function xn(e,t){if(!t)return;let n=e.querySelector(`[data-key="${CSS.escape(t)}"]`);n&&n!==e.ownerDocument.activeElement&&n.focus({preventScroll:!0})}function vn(e,t,n){let{doc:r}=e,o=ne(n),i=Ue(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=P(o,l);return a(r,"button",{class:"st-d","data-key":`strip-${c}`,"aria-current":c===n?"date":void 0,"aria-label":`${fe(c)} ${Number(c.slice(8))}${i.has(c)?", has events":""}${c===e.today?", today":""}`,onclick:()=>e.goTo(c)},a(r,"span",{"aria-hidden":"true"},Ce(c)[0]),a(r,"b",{"aria-hidden":"true"},String(Number(c.slice(8)))),a(r,"i",{class:i.has(c)?"":"off"}))}))}function Rr(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":Oe(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 kn(e,t,n,r){let{doc:o}=e,i=mn(t,n,Math.max(1,se(n,P(ne(n),7))));return a(o,"div",{class:"agenda"},...i.map(s=>a(o,"section",{class:"ag-day","aria-label":`${fe(s.date)} ${J(s.date).slice(4)}`},a(o,"h3",{class:`ag-dh${s.date===e.today?" is-today":""}`},ln(e,s.date,fe(s.date)),a(o,"span",{},J(s.date))),s.items.length?a(o,"ul",{},...s.items.map(l=>Rr(e,l))):a(o,"p",{class:"ag-none"},"No events"))),r?a(o,"p",{class:"ag-note"},r):null)}var Cn='.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 Le(e,t,n={},r=globalThis.__USERTEST_REPORT__){if(typeof r=="function")try{r({source:"calendar",level:e,message:t,...n})}catch{}}var Je=e=>e instanceof Error?e.stack:void 0;function En(e){e.addEventListener("error",t=>Le("error",t.message,{kind:"uncaught",stack:Je(t.error)})),e.addEventListener("unhandledrejection",t=>Le("error",t.reason instanceof Error?t.reason.message:String(t.reason),{kind:"unhandled-rejection",stack:Je(t.reason)}))}var Or=(e,t,n=`${t}s`)=>`${e} ${e===1?t:n}`;var We=e=>e instanceof Error?e.message:String(e),ht=e=>typeof e?.status!="number"&&/connect again|reconnect/i.test(We(e));function Lr(e){let t=We(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||ht(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 ie="Nothing here was changed.";function $n(e,t="this calendar",n=e.retryAfter){switch(e.kind){case"reauth":return{tone:"neg",role:"alert",title:"Google access has expired.",body:ie,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. ${ie}`,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:ie,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:ie,action:{label:"Retry now",does:"retry"}};case"network":return{tone:"info",role:"status",title:"Couldn\u2019t reach Google.",body:ie,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:ie};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. ${ie}`,action:{label:"Retry",does:"retry"}};case"other":return{tone:"neg",role:"status",title:"Something went wrong while syncing.",body:ie,action:{label:"Retry",does:"retry"}}}}var Ar=e=>e.toLocaleTimeString("en-GB",{hour:"2-digit",minute:"2-digit"});function yt(e,t=new Date){let n=Math.floor((t.getTime()-e.getTime())/6e4);return n<1?"just now":n<60?`${n} min ago`:`at ${Ar(e)}`}function bt(e){let t=e.stale?0:e.summary?.review.length??0;return Math.max(t,e.pending)}function Sn(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:Or(i,"conflict"),tone:"warn",opens:"conflicts"};let s=bt(e);return s?{text:`${s} to review`,tone:"accent",opens:"review"}:{text:o?`Synced ${yt(o,t)}`:"Not synced yet",tone:"muted"}}function Tn(e,t,n={}){let r={kind:"loading"},o=[],i,s,l=[],c,g=!1,d=h=>{r=h,t(r)},b=()=>t(r);async function m(h){for(;;){let w=o.at(-1);if(!w)throw Object.assign(new Error("Reconnect Google Calendar."),{reconnect:!0});try{return await h(w.connectionId)}catch(C){if(!ht(C))throw C;o=o.slice(0,-1)}}}let u=(h,w)=>{let C=Lr(h);Le("error",We(h),{problem:C.kind,...C.status?{status:C.status}:{},stack:Je(h)}),d({kind:"error",message:We(h),reconnect:o.length===0||h.reconnect===!0||ht(h),problem:C,...w?{outcomes:w}:{},...c?{summary:c.summary,at:c.at}:{}})},y=async()=>{s&&(l=await Qt(e,s,c?.summary.conflicts??[]))},p=async h=>{try{let w=await m(D=>ot(h,D)),C=w.find(D=>D.id===i);if(!C)return;let T=w.find(D=>D.primary)?.id;s={summary:C.summary,color:C.backgroundColor??Me,accessRole:C.accessRole,...T?{account:T}:{}},await en(e,s),await y(),b()}catch{}},x=async h=>{if(g=!0,c){let w={...c.summary,conflicts:c.summary.conflicts.filter(C=>C!==h)};c={...c,summary:w},(r.kind==="ready"||r.kind==="error")&&(r={...r,summary:w})}await y(),b()},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:g,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:Se}),!o.length)return d({kind:"disconnected"}),{};let w=await Jt(e);if(!w)return await v.listCalendars(),{};i=w.id,s=w.meta??{summary:"Google Calendar",color:Me,accessRole:"owner"},await y();let C=v.refresh();return{refreshing:w.meta?C:C.then(()=>p(h))}},async listCalendars(){let h=e.proxy;if(!h)return d({kind:"no-relay"});d({kind:"refreshing"});try{let w=await m(C=>ot(h,C));d({kind:"choosing",calendars:w})}catch(w){u(w)}},async choose(h){if(r.kind!=="choosing")return;let w=r.calendars.find(T=>T.id===h);if(!w)return;let C=r.calendars.find(T=>T.primary)?.id;try{s=await Wt(e,w,C),i=w.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:Se}))?.status==="connected"?await v.load():d({kind:"disconnected"})}catch(w){u(w)}},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 w=await m(C=>Kt(e,h,C,{...n,onPage:T=>{r.kind==="refreshing"&&d({...r,pages:T})}}));g=!1,c={summary:w,at:new Date},Le(w.unreadable.length?"warn":"info","Sync finished",{total:w.total,added:w.added,updated:w.updated,unchanged:w.unchanged,skipped:w.skipped,conflicts:w.conflicts.length,review:w.review.length,unreadable:w.unreadable.map(({id:C,reason:T})=>({id:C,reason:T}))}),await y(),d({kind:"ready",at:c.at,summary:w,outcomes:[]})}catch(w){await y().catch(()=>{}),u(w)}}},async prepareReview(){(g||!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:w,at:C}=r,T=w.review.map(()=>{});d({kind:"sending",summary:w,progress:[...T]});let D;try{D=await m(_=>Xt(e,h,_,w.calendarId,w.review,(X,F)=>{T[X]=F??"sending",r.kind==="sending"&&d({...r,progress:[...T]})}))}catch(_){return await y().catch(()=>{}),u(_)}c={at:C,summary:{...w,review:[]}},await y();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,w){await tn(e,h,w),g=!0,await y(),b()},async discard(h){if(await nn(e,h),c){let w={...c.summary,review:c.summary.review.filter(C=>C!==h)};c={...c,summary:w},r.kind==="ready"&&(r={...r,summary:w})}await y(),b()},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 on(e)),!0):!1},async disconnect(){let h=e.proxy;if(h?.disconnect){try{await h.disconnect({platform:Se})}catch(w){return u(w)}o=[],d({kind:"disconnected"})}},async resolve(h,w){await rn(e,h,w),await x(h)},async keepAsLocal(h){h.subject&&(await an(e,h.subject),await x(h))},async removeLocal(h){h.subject&&(await sn(e,h.subject),await x(h))}};return v}var wt="Guests, reminders and video links are edited in Google Calendar.";function Pn(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 Mn(e,t,{narrow:n,onClose:r,onEdit:o,onReview:i,onConflicts:s,onOpenLink:l}){let{doc:c}=e,g=cn(t,e.zone),d=t.pending?dn(t,e.zone):void 0,b=[["When",[a(c,"span",{},g.day),g.time?a(c,"b",{},g.time):null,g.own?a(c,"span",{class:"muted"},g.own):null].filter(m=>!!m)]];return t.location&&b.push(["Where",[c.createTextNode(t.location)]]),t.description&&b.push(["Notes",[c.createTextNode(t.description)]]),Pn(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"},...b.map(([m,u])=>a(c,"div",{},a(c,"dt",{},m),a(c,"dd",{},...u)))),a(c,"p",{class:"dr-hint"},wt),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 Nr(e,t){if(e.allDay)return{title:e.title,allDay:!0,startDate:e.start,startTime:"09:00",endDate:P(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 Dn(e,t){let n={};e.title.trim()||(n.title="Add a title"),le(e.startDate)||(n.start="Enter a start date"),le(e.endDate)||(n.end="Enter an end date");let r="",o="";if(!n.start&&!n.end)if(e.allDay)r=e.startDate,o=P(e.endDate,1),o<=r&&(n.end="End must be after start");else{let i=nt(e.startTime),s=nt(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=rt(e.startDate,i,t),o=rt(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 jn(e,t,{narrow:n,onClose:r,onCancel:o,onSave:i}){let{doc:s,zone:l}=e,c=Nr(t,l),g=!1,d=!1,b,m=Pn(e,t,r,n),u=a(s,"div",{class:"form"});m.append(u);let y=(O,H)=>a(s,"input",{...H,value:String(c[O]),"data-key":`f-${O}`,oninput:B=>{c[O]=B.target.value,g=!0,de()}}),p=y("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,g=!0,Ne()}}),v=y("startDate",{type:"date","aria-label":"Start date"}),h=y("startTime",{type:"time",class:"t","aria-label":"Start time"}),w=y("endDate",{type:"date","aria-label":"End date"}),C=y("endTime",{type:"time",class:"t","aria-label":"End time"}),T=y("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"}),X=a(s,"p",{class:"err",id:"err-end",role:"alert"}),F=a(s,"p",{class:"err",role:"alert"}),Q=a(s,"button",{class:"btn btn-primary","data-key":"drawer-save",onclick:async()=>{g=!0;let{value:O}=Dn(c,l);if(de(),!(!O||d)){d=!0,Q.setAttribute("disabled","");try{await i(O)}catch(H){b=H instanceof Error?H.message:String(H),d=!1,Q.removeAttribute("disabled"),de()}}}},"Save here"),ee=(O,H,B)=>{O.textContent=B??"",O.hidden=!B;for(let Z of H)Z.classList.toggle("is-bad",!!B),B?Z.setAttribute("aria-invalid","true"):Z.removeAttribute("aria-invalid")};function de(){let{errors:O}=Dn(c,l),H=g?O:{};ee(j,[p],H.title),ee(_,[v,h],H.start),ee(X,c.allDay?[w]:[w,C],H.end),c.allDay&&(h.classList.remove("is-bad"),C.classList.remove("is-bad")),ee(F,[],b),Object.keys(O).length&&g?Q.setAttribute("disabled",""):d||Q.removeAttribute("disabled")}function Ne(){let O=`${l.split("/").at(-1)?.replace(/_/g," ")}, ${Ie(e.now,l)}`,H=(B,Z,He)=>a(s,"div",{class:`fld-2${c.allDay?" all-day":""}`,role:"group","aria-label":B},a(s,"span",{},B),Z,c.allDay?null:He);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",w,C),X,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?wt:`Times are in your zone (${O}). ${wt}`),F,a(s,"div",{class:"dr-ft"},a(s,"button",{class:"btn btn-ghost","data-key":"drawer-cancel",onclick:o},"Cancel"),Q)),de()}return Ne(),m}function Rn(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 On(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 xt(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((g,d)=>a(e,"button",{role:"menuitem","data-key":`menu-${d}`,disabled:g.disabled,onclick:()=>{l?.(!1),g.onSelect()}},g.label,g.hint?a(e,"small",{},g.hint):null))):null):null)}function vt(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 Ae(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 Ln(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 ye(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 An(e){return Ae(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 Hr=[{name:"Outlook Calendar",mark:"O",color:"#0078d4"},{name:"Apple Calendar",mark:"",color:"#8e8e93"}];function Nn(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 Ae(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,...Hr.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 zr={owner:"Owner",writer:"Can edit",reader:"View only",freeBusyReader:"Free/busy only"};function Hn(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=je(s.accessRole),c=[s.primary?s.id:void 0,zr[s.accessRole]??s.accessRole].filter(Boolean).join(" \xB7 "),g=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",{},g,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 zn(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 ce=(e,t,n=`${t}s`)=>`${e} ${e===1?t:n}`,Gr=Object.fromEntries(Object.entries(Y).map(([e,t])=>[t,e]));function In(e,t){let n=gn(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 Gn(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 Ir={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 _n(e,{summary:t,progress:n,outcomes:r,color:o,busy:i,onClose:s,onDiscard:l,onSend:c,onReviewAgain:g}){let{doc:d,zone:b}=e,m=t.review,u=!!n,y=[];if(t.invalid.length&&y.push(a(d,"p",{class:"fine"},`Not sent: ${t.invalid.map(p=>`${p.title.trim()||"(untitled)"} (${p.reason})`).join("; ")}.`)),t.localOnly&&y.push(a(d,"p",{class:"fine"},`${ce(t.localOnly,"row")} made in this table won\u2019t be sent: creating events isn\u2019t supported.`)),t.unmapped.length&&y.push(a(d,"p",{class:"fine"},`Kept here only, never sent to Google: ${t.unmapped.map(p=>`${p.column} (${ce(p.rows,"event")})`).join(", ")}. Only Title, Notes, Location, Start, End, All day, Day and End day are sent.`)),!m.length&&r.length){let p=r.filter(v=>v.status==="sent").length,x=r.filter(v=>v.status==="stale");return ye(d,{id:"review",title:`${p} of ${ce(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),Gn(d,v),a(d,"span",{class:"sr-only"},`${v.title}: ${Ir[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:g},"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?ye(d,{id:"review",title:`Send ${ce(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]?Gn(d,n[x]):a(d,"span",{class:"when"},In(p.remote,b))),a(d,"dl",{class:"diff"},...p.fields.flatMap(v=>{let h=Gr[v.field],w=h?he(h,p.remote,b):v.before,C=h?he(h,p.desired,b):v.after;return[a(d,"dt",{},v.field),a(d,"dd",{},a(d,"del",{},w),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"))))),...y],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 ${ce(m.length,"change")} to Google`,onclick:c},u?"Sending\u2026":`Send ${ce(m.length,"change")}`)]}):ye(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:y})}function Vn(e,{list:t,color:n,choices:r,confirming:o,errors:i,onClose:s,onChoose:l,onResolve:c,onKeep:g,onRemove:d,onConfirm:b,onOpenRow:m}){let{doc:u,zone:y}=e,p=(x,v)=>{let h=x.local??x.remote,w=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"},In(h,y).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",{},w,a(u,"div",{class:"cf"},...T.map(j=>{let _=`cf-${v}-${j}`,X=(F,Q,ee)=>a(u,"label",{class:"opt"},a(u,"input",{type:"radio",name:_,checked:C[j]===F,"data-key":`${_}-${F}`,onchange:()=>l(x,j,F)}),a(u,"small",{},Q),he(j,ee,y));return a(u,"div",{class:"cf-row",role:"radiogroup","aria-label":Y[j]??j},a(u,"span",{},Y[j]??j),X("mine","Keep mine",x.local),X("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",{},w,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:()=>b(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:()=>b(x)},"Remove local copy"),a(u,"button",{class:"btn btn-sm","data-key":`keep-${v}`,onclick:()=>g(x)},"Keep as local event"))):a(u,"li",{},w,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 ye(u,{id:"conflicts",title:t.length?`${ce(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 _r=[[["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 Yn(e,t){let{doc:n}=e;return ye(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"},..._r.flatMap(([r,o])=>[a(n,"dt",{},...r.flatMap((i,s)=>[s?" ":null,a(n,"kbd",{},i)])),a(n,"dd",{},o)]))]})}function Fn(e,{month:t,selected:n,weekFrom:r,weekDays:o,meta:i,visible:s,summary:l,whyOpen:c,onMonth:g,onVisible:d,onWhy:b}){let{doc:m}=e,u=`${t.slice(0,7)}-01`,y=ne(u),p=P(u,32).slice(0,7),x=P(u,-1).slice(0,7),v=P(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=P(y,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 w=l?.skipped,C=je(i.accessRole);return a(m,"aside",{class:"side","aria-label":"Calendars"},a(m,"div",{},a(m,"div",{class:"mm-hd"},a(m,"b",{},Ee(u)),a(m,"span",{},a(m,"button",{class:"icon-btn",style:"width:24px;height:24px","aria-label":"Previous month","data-key":"mm-prev",onclick:()=>g(`${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:()=>g(`${p}-01`)},G(m,I.next)))),a(m,"div",{class:"mm-g",role:"group","aria-label":Ee(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)))),w&&kt(w)?a(m,"section",{class:"side-sec note"},a(m,"h2",{},"Not shown"),a(m,"p",{},Ct(w,l?.unreadable)," ",a(m,"button",{class:"link","aria-expanded":c?"true":"false","data-key":"why",onclick:b},"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 kt(e){return!!(e.recurring||e.cancelled||e.unreadable)}function Ct(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 Bn=':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 Vr(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 Un(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"),b=d?Vr(d.trim()):void 0;s(b===void 0?void 0:b<.3?"dark":"light")};l();let c=r.getElementById("__atomic_theme"),g=c&&typeof MutationObserver<"u"?new MutationObserver(l):void 0;return g?.observe(c,{childList:!0,characterData:!0,subtree:!0}),()=>g?.disconnect()}var be=44,Yr=52,Fr=44;function Et(e){let t=Math.floor(e/60);return t>=8&&t<18?8:Math.max(0,t-1)}function qe(e){return e<560?3:e<720?5:7}function Jn(e,t,n,r){let{doc:o}=e,i=t.segment,s=i.event,l=i.startMin/60*be,c=Math.max((i.endMin-i.startMin)/60*be,16),g=i.endMin-i.startMin<30,d=i.endMin-i.startMin<75,b=`calc(${100/n}% - 4px)`,m=[`${R(i.startMin)}\u2013${ut(i,!0)}`,s.location].filter(Boolean).join(" \xB7 ");return a(o,"li",{},a(o,"button",{class:`ev${g?" 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:${b}`,"data-key":`ev-${s.subject}-${i.date}`,"data-subject":s.subject,"aria-label":Oe(i),onclick:u=>e.open(s,u.currentTarget)},a(o,"span",{class:"ev-t","aria-hidden":"true"},s.title||"(untitled)"),g?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 Br(e,t,n,r){let{doc:o}=e,i=[],s=new Map;for(let g of n){let d=s.get(g.cluster)??[];d.push(g),s.set(g.cluster,d)}for(let g of s.values()){let d=g[0].lanes;if(d<=r){for(let p of g)i.push(Jn(e,p,d,p.lane));continue}let b=Math.max(1,r-1),m=g.filter(p=>p.lanep.lane>=b);for(let p of m)i.push(Jn(e,p,b+1,p.lane));let y=Math.min(...u.map(p=>p.segment.startMin));i.push(a(o,"li",{},a(o,"button",{class:"ev-more",style:`top:${y/60*be+1}px;height:22px;left:calc(${100/(b+1)*b}% + 2px);width:calc(${100/(b+1)}% - 4px)`,"data-key":`more-${t}-${y}`,"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*be}px`,"aria-hidden":"true"}):null,i.length?a(o,"ul",{},...i):null)}function Wn(e,t,n,r,o){let{doc:i}=e,s=hn(t,n,r),l=(o-Yr)/r,c=Math.max(1,Math.floor(l/Fr)),g=`--n:${r}`,d=Math.max(s.rows,1),b=a(i,"div",{class:"wk-head","aria-hidden":"true"},a(i,"div",{class:"wk-tz"},Ie(e.now,e.zone)),...s.days.map(p=>a(i,"div",{class:`wk-dh${p===e.today?" is-today":""}`},a(i,"span",{},Ce(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":Oe({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))))),y=a(i,"div",{class:"wk-body"},u,...s.days.map((p,x)=>Br(e,p,s.columns[x],c)));return a(i,"div",{class:"wk",style:g},a(i,"button",{class:"sr-only sr-only-focusable link","data-key":"to-agenda",onclick:()=>e.setView("agenda")},"Switch to agenda view"),b,m,a(i,"div",{class:"wk-scroll","data-scroll":"week"},y))}var Ur=/^(input|textarea|select)$/i,Jr={calendarId:"",total:0,added:0,updated:0,unchanged:0,skipped:{recurring:0,cancelled:0,unreadable:0},unreadable:[],conflicts:[],localOnly:0,invalid:[],unmapped:[],review:[]};async function ho({root:e,store:t}){En(e.ownerDocument.defaultView??window);let n=e.ownerDocument,r=n.defaultView;Un(e,`${Bn} +${Cn}`,t),e.classList.add("pl-app");let o=Vt(),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:Et(N(Date.now(),o).minutes)},c,g,d,b=!1,m=()=>{b||(b=!0,queueMicrotask(()=>{b=!1,He()}))},u=Tn(t,()=>m()),y=f=>{Object.assign(l,f),m()},p=f=>{setTimeout(()=>e.querySelector(f)?.focus(),0)},x=()=>({doc:n,zone:o,today:i(),now:Date.now(),width:s(),open:(f,E)=>v(f,E.dataset.key),goTo:f=>y({anchor:f,month:f}),setView:f=>y({view:f,...l.view===f?{}:{scrollTo:Et(N(Date.now(),o).minutes)}})});function v(f,E){c=void 0,y({drawer:{subject:f.subject,mode:"view",...E?{from:E}:{}},menu:!1}),p("#drawer-title")}function h(){let f=l.drawer?.from;c=void 0,y({drawer:void 0}),f&&p(`[data-key="${CSS.escape(f)}"]`)}function w(f){let E=mt(n);y({sheet:f,menu:!1,...E?{sheetFrom:E}:{}}),p(`#${f}-title`)}function C(){let f=l.sheetFrom;y({sheet:void 0,confirming:void 0,sheetFrom:void 0}),p(f?`[data-key="${CSS.escape(f)}"]`:'[data-key="primary"]')}async function T(){await u.openInHost().catch(()=>{})}async function D(){w("review"),await u.prepareReview()}function j(f){let E=l.view==="week"?qe(s()):7,S=P(l.anchor,f*E);y({anchor:S,month:S})}function _(f){clearInterval(g),l.countdown=f,g=setInterval(()=>{if(!l.countdown||l.countdown<=1){clearInterval(g),l.countdown=void 0,u.refresh();return}l.countdown--,m()},1e3)}function X(f){if(f.meta)return a(n,"button",{class:"chip","aria-pressed":l.visible?"true":"false","aria-label":`Show ${f.meta.summary}`,"data-key":"chip",onclick:()=>y({visible:!l.visible})},a(n,"span",{class:"sw",style:`--c:${f.meta.color}`,"aria-hidden":"true"}),f.meta.summary)}function F(f,E){let S=Sn(f),k=bt(f),$=f.state.kind==="refreshing"||f.state.kind==="sending",M=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:$||!f.meta,onclick:()=>{u.refresh()}},"Sync now");return On(n,{title:"Calendar",chips:X(f),status:Rn(n,{...S,...M?{onClick:()=>{M==="review"?D():M==="conflicts"?w("conflicts"):e.querySelector(".banner details")?.setAttribute("open","")}}:{}}),action:A})}function Q(f){let E=f.state.kind==="refreshing"||f.state.kind==="sending",S=f.state.kind==="refreshing"?"Reading events\u2026":f.state.kind==="sending"?"Sending changes\u2026":f.at?`Last synced ${yt(f.at).replace(/^at /,"")}`:void 0;return xt(n,{provider:"Google Calendar",...f.meta?.account?{account:f.meta.account}:{},...S?{detail:S}:{},busy:E,menuOpen:l.menu,onMenu:k=>y({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:()=>w("shortcuts")},...f.can.disconnect?[{label:"Disconnect",hint:"This app stops using the connection. Your rows stay here.",onSelect:()=>{u.disconnect()}}]:[]]})}function ee(f){let E=f.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=$n(S,f.meta?.summary??"this calendar",l.countdown),$=k.action?.does==="reconnect"||E.reconnect,M=k.action??($?{label:"Reconnect"}:void 0);return a(n,"div",{class:"banners"},vt(n,{tone:k.tone,role:k.role,title:k.title,body:k.body,details:`${S.status?`HTTP ${S.status}: `:""}${S.message}`,...M?{action:{label:$?"Reconnect":M.label,...$?{name:"Reconnect Google Calendar"}:{},onClick:()=>{clearInterval(g),l.countdown=void 0,$?u.connect():u.refresh()}}}:{}}))}function de(f,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))),M=l.view==="agenda"?Ee(l.anchor):f?Bt(E,P(E,S-1)):Ft(E,P(E,S-1));return a(n,"div",{class:"tb"},f?null:a(n,"button",{class:"btn","data-key":"today",onclick:()=>x().goTo(i())},"Today"),$,a(n,"h2",{class:"tb-title","aria-live":"polite"},M),Ln(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 Ne(f,E,S){let k=l.visible?f.events:[],$=l.view==="week"?qe(E.width):7,M=l.view==="week"&&$===7?ne(l.anchor):l.anchor;if(f.state.kind==="refreshing"&&!f.summary&&!f.events.length)return a(n,"div",{class:"main"},zn(n,{calendar:f.meta?.summary??"Calendar",color:f.meta?.color??"#4986e7",...f.state.kind==="refreshing"&&f.state.pages?{pages:f.state.pages}:{}}));let A=l.view==="week"?$:7,ae=l.view==="week"?M:ne(l.anchor),$t=Ue(k,ae,A).size===0,we;if($t&&f.summary){let ze=bn(k,P(ae,A)),qn=ze?void 0:wn(k,ae),xe=ze??qn;we=Ae(n,{muted:!0,title:l.view==="week"&&$<7?"No events these days":"No events this week",...xe?{text:`Your ${ze?"next":"latest"} event is ${xe.event.title||"(untitled)"} on ${z(xe.date)}.`}:l.visible?{}:{text:`${f.meta?.summary??"The calendar"} is hidden.`},children:xe?[a(n,"button",{class:"btn btn-primary","data-key":"jump",onclick:()=>E.goTo(xe.date)},ze?"Jump to next event":"Jump to latest event")]:[]})}else l.view==="week"?we=Wn(E,k,M,$,E.width-(E.width>=900?232:0)):we=kn(E,k,l.anchor,f.summary&&kt(f.summary.skipped)?Ct(f.summary.skipped,f.summary.unreadable):void 0);return a(n,"div",{class:"main"},de(S,M,$,f.can.openResource),l.view==="agenda"?vn(E,k,l.anchor):null,l.view==="week"&&!($t&&f.summary)?we:a(n,"div",{class:"view","data-scroll":"view"},we))}function O(f,E,S){if(!l.drawer)return;let k=l.drawer,$=f.events.find(M=>M.subject===k.subject);if(!$){l.drawer=void 0,c=void 0;return}return k.mode==="edit"&&!$.readOnly?(c?.subject!==$.subject&&(c={subject:$.subject,element:jn(E,$,{narrow:S,onClose:h,onCancel:()=>{c=void 0,y({drawer:{...k,mode:"view"}}),p("#drawer-title")},onSave:async M=>{await u.saveEvent($.subject,M),c=void 0,y({drawer:{...k,mode:"view"}}),p("#drawer-title")}})}),c.element):Mn(E,$,{narrow:S,onClose:h,onEdit:()=>{y({drawer:{...k,mode:"edit"}}),p('[data-key="f-title"]')},onReview:()=>{D()},onConflicts:()=>w("conflicts"),...$.link&&f.can.openExternal?{onOpenLink:()=>{u.openLink($)}}:{}})}function H(f,E){if(l.sheet==="shortcuts")return Yn(E,C);let S=f.meta?.color??"#4986e7";if(l.sheet==="review"){let k=f.state,$=k.kind==="sending"||k.kind==="ready"?k.summary:f.summary??Jr;return _n(E,{summary:$,...k.kind==="sending"?{progress:k.progress}:{},outcomes:k.kind==="ready"||k.kind==="error"?k.outcomes??[]:[],color:S,busy:k.kind==="refreshing"||f.stale,onClose:C,onDiscard:M=>{u.discard(M)},onSend:()=>{u.send()},onReviewAgain:()=>{u.refresh()}})}if(l.sheet==="conflicts")return Vn(E,{list:f.summary?.conflicts??[],color:S,choices:l.choices,...l.confirming?{confirming:l.confirming}:{},errors:l.conflictErrors,onClose:C,onChoose:(k,$,M)=>{l.choices.set(k,{...l.choices.get(k)??{},[$]:M}),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=>y({confirming:k}),...f.can.openResource?{onOpenRow:k=>{u.openInHost(k.subject)}}:{}})}function B(f,E){let S=f.state,k=E.width<720;switch(S.kind){case"loading":return[Z(a(n,"p",{class:"sr-only",role:"status"},"Loading\u2026"))];case"no-relay":return[Z(An(n))];case"disconnected":case"connecting":return[Z(Nn(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",{},xt(n,{provider:"Google Calendar",...L?{account:L}:{}})),Z(Hn(n,{calendars:S.calendars,onImport:A=>{u.choose(A)}}))]}default:}if(!f.meta)return[a(n,"header",{},F(f,k)),a(n,"main",{class:"pl-main"},ee(f),S.kind==="error"?void 0:a(n,"p",{class:"fine",style:"padding:14px",role:"status"},"Reading your calendars\u2026"))];let $=l.view==="week"?qe(E.width):7,M=l.view==="week"&&$===7?ne(l.anchor):l.anchor;return[a(n,"header",{},F(f,k),Q(f)),a(n,"main",{class:"pl-main"},ee(f),a(n,"div",{class:"shell"},E.width>=900&&l.view==="week"?Fn(E,{month:l.month,selected:l.anchor,weekFrom:M,weekDays:$,meta:f.meta,visible:l.visible,...f.summary?{summary:f.summary}:{},whyOpen:l.why,onMonth:L=>y({month:L}),onVisible:L=>y({visible:L}),onWhy:()=>y({why:!l.why})}):null,Ne(f,E,k),O(f,E,k)??null))]}function Z(...f){return a(n,"main",{class:"pl-scroll"},a(n,"h1",{class:"sr-only"},"Calendar"),...f)}function He(){let f=u.snapshot(),E=x(),S=mt(n),k=new Map;for(let A of e.querySelectorAll("[data-scroll]"))k.set(A.dataset.scroll,A.scrollTop);let $=B(f,E).filter(A=>!!A),M=H(f,E);e.replaceChildren(...$,...M?[M]:[]);for(let A of e.querySelectorAll("[data-scroll]")){let ae=k.get(A.dataset.scroll);ae!==void 0&&(A.scrollTop=ae)}let L=e.querySelector('[data-scroll="week"]');L&&l.scrollTo!==void 0&&(L.scrollTop=Math.max(0,l.scrollTo*be-10),l.scrollTo=void 0),xn(e,S)}n.addEventListener("keydown",f=>{let E=f.target;if(f.altKey||f.ctrlKey||f.metaKey)return;if(f.key==="Escape"){if(l.menu)y({menu:!1});else if(l.sheet)C();else if(l.drawer)h();else return;f.preventDefault();return}if(Ur.test(E.tagName)||E.isContentEditable)return;let S=u.snapshot();if(!S.meta)return;if(l.sheet){f.key==="?"&&w("shortcuts");return}let k=E.closest("[data-subject]"),$=!0;switch(f.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"?":w("shortcuts");break;case"e":{let M=l.drawer?.subject??k?.dataset.subject,L=S.events.find(ae=>ae.subject===M);if(!L||L.readOnly){$=!1;break}let A=l.drawer?.from??k?.dataset.key;y({drawer:{subject:L.subject,mode:"edit",...A?{from:A}:{}}}),p('[data-key="f-title"]');break}default:$=!1}$&&f.preventDefault()}),n.addEventListener("click",f=>{l.menu&&!f.target.closest(".cbar-menu")&&y({menu:!1})}),r.addEventListener("resize",()=>{c||m()}),setInterval(()=>{c||m()},6e4),He(),await u.load().catch(f=>{e.replaceChildren(vt(n,{tone:"neg",role:"alert",title:"The calendar could not load.",body:f instanceof Error?f.message:String(f)}))})}export{ho as view}; diff --git a/integrations/calendar/README.md b/integrations/calendar/README.md index 567bf279..237251d0 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, - 107,798 bytes for 0.1.2) from GitHub Pages and refuses it unless it + 110,466 bytes for 0.1.3) 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 @@ -37,8 +37,10 @@ drive apps. days by width, with a sidebar from 900px). An event opens in a drawer; Edit changes exactly the five mapped fields and saves to the row only ("Saved here · not sent to Google yet"). "Open in Google Calendar" asks - the host to open the event's Google page. Rows edited in the host's table - show up the same way after the next sync. + the host to open the event's Google page. Rows edited outside the app + (the host's table, its Calendar view, another device) show up the same + way when the app next opens or syncs; see + [Edits made outside the app](#edits-made-outside-the-app-compare-on-open). 6. **Review and send.** "Review N changes" lists each changed field (before → after), with Discard per event. Nothing is sent until you press "Send N changes"; each row then reports Sent, Changed in Google (a `412`, @@ -104,9 +106,10 @@ 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 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 +Day and End day are derived on import and on every edit made in the app. +Edited in the host, they are read back into Start and End (see +[Edits made outside the app](#edits-made-outside-the-app-compare-on-open)). +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 @@ -163,6 +166,61 @@ the five fields as both sides last agreed, keyed by field (`title`, `description`, …), not by column, so the renamed columns leave it as it was. It is what lets a refresh tell a local edit from a Google edit. +## Edits made outside the app (compare on open) + +Per #177 (Q4–Q7) and #192. The bookkeeping lives on each row, as provider +extras: `google-event-id`, `google-etag` and `sync-baseline`. The host +gives the app no change events yet (#177 H6), so the app compares instead: +every time it opens, and on "Sync now", it reads Google and compares each +synced row with its baseline. Any difference is a local change, however it +was made: the host's table, the host's Calendar view, another view, another +device. It goes into the same "Review N changes" list as an edit made in the +app, and nothing is sent until you press Send. A send is a `PATCH` with +`If-Match`, and the baseline advances only once Google confirms it. + +The rows keep the host's format, so the comparison reads them back through +the lens (`hostValue` in `app/sync.ts`): + +| Edited in the host | Sent to Google (after review) | +| ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| Name, Notes, Location | `summary`, `description`, `location` | +| Start, End (Day and End day left as they were) | `start`, `end` as typed; Day and End day are derived again | +| Day or End day, all-day event | `start.date` = Day, `end.date` = End day (exclusive), so Google shows the days the host view shows | +| End day cleared, all-day event | one day: `end.date` = the day after Day (the host view shows Day only) | +| Day, timed event | same clock time and offset on the new Day; End moves by the same number of days | +| End day, timed event | End on that date at its own time (Day's date when cleared) | +| All day turned on | all-day from Day to End day, or Day alone when there is no End day | +| All day turned off, Start and End still dates | **not sent**, listed: there are no times to send | +| End day on or before Day, all-day event | **not sent**, listed: the host view draws it nowhere (end == start is not read as one day here, unlike Google's) | +| Day or End day not a date | **not sent**, listed | +| Start or End and Day or End day, disagreeing | **not sent**, listed: the app can't tell which one you meant | +| A column the app doesn't map (one you added) | **never sent**; listed as "Kept here only" with how many synced events fill it | + +Only Day moved, with End day left as it was, changes the length of an +all-day event, since that is what the host view then shows. A row that is +listed as not sent is held back entirely: it is neither sent nor rewritten +by the sync (a Google edit to it waits too), until it is fixed in the table +or with the app's Edit. The core Description (0.1.0's column) counts as a +column the app doesn't map: Notes is what is sent. + +- **Conflicts.** A field changed both here (in any view) and in Google since + the baseline is a conflict, handled as any other (_Conflicts_ above): + neither side is overwritten until you choose per field. A Day edit counts + as a Start edit. A Google change to a different field lands in the row, + and the local change is still reviewed. +- **Deleted rows are not noticed.** A synced row deleted in the host takes + its baseline with it, so there is nothing left to compare: the next sync + imports the Google event again as a new row, and the event stays in + Google. Noticing a local deletion needs the host's per-table change list + with tombstones (#177 H6), which doesn't exist at the pin. Declared + limitation. +- **Only while the app is open.** Edits made while it is closed are found + the next time it opens; nothing is sent in the background. + +Declared by unit tests (`app/compare.test.ts`: one case per row of the +table, a both-sides conflict, a deletion, a hand-added row and an unmapped +column) and one e2e step (below). Not live-verified. + ## Scope and policies Declared, not live-verified (see _Verification_): @@ -189,7 +247,9 @@ Declared, not live-verified (see _Verification_): - A local value that can't be sent (an empty title, an interval that isn't valid) is held back and listed. Neither side changes. - Rows made in the table (no Google event id) are counted, never sent: - creating events is not supported. + creating events is not supported. This is the app's answer to #177 Q6 + (hand-added rows are local only unless published explicitly); there is + no "Publish to Google Calendar" yet, so for now they stay local. - **Conditional writes.** Each approved edit is one `PATCH` of only the changed fields, with `If-Match` set to the ETag that same preview read. A `412` marks only that event "Changed in Google since this preview; not @@ -291,7 +351,8 @@ node --test integrations/localthought/mock-proxy.test.mjs and removed again for a timed event across midnight); recurring and cancelled skips; refresh; review; `If-Match` on send; `412`; both-changed conflicts; a lost response followed by a reconnect; cancellation after - import; local-only and invalid rows. `app/build.test.ts` checks that the + import; local-only and invalid rows. `app/compare.test.ts`: edits made + outside the app, found on open (see the table above). `app/build.test.ts` checks that the bundle is one ES module with no storage, `fetch` or credential of its own. - **Host e2e** (`e2e/calendar.spec.ts`, lane `calendar`, tier `e2e`): the same path in the real plugin frame on the pinned host, with the mock @@ -303,7 +364,12 @@ node --test integrations/localthought/mock-proxy.test.mjs (`POST /fixture/google-calendar/…`). It then sends a reviewed edit (checking the fixture received that `If-Match`), sends into a `412`, and loses a `PATCH` response (Playwright lets the request reach the mock, then - aborts the response), syncs again, and checks that the preview agrees. A + aborts the response), syncs again, and checks that the preview agrees. + Then (#192) it sets the one-day all-day event's End day a day later as + the signed-in user (a commit, as a table edit is; not through the table's + cells), reloads the page so the app opens again, checks that the review + lists End, sends it, and checks that the fixture received a `PATCH` of + only `end.date`, with `If-Match`. A later test follows Month into the host table, adds its Calendar view, and checks that the fixture's three-day all-day event is drawn on the 10th, 11th and 12th (not the 9th or 13th), the one-day all-day event on its day diff --git a/integrations/calendar/app/compare.test.ts b/integrations/calendar/app/compare.test.ts new file mode 100644 index 00000000..da17142b --- /dev/null +++ b/integrations/calendar/app/compare.test.ts @@ -0,0 +1,489 @@ +// @wc-ignore-file +/** + * Compare on open (#192, #177 Q4–Q7): edits made to synced rows outside the + * app (the host's table, its built-in Calendar view, another device) are + * found when the app opens, by comparing each row with the baseline stored + * on it, and become the app's reviewed "Changes to send". The rows keep the + * host's format (Day, exclusive End day), so an edit to Day, End day or All + * day is translated back into Google's start and end (`hostValue`). + */ +import { describe as suite, expect, it } from 'vitest'; +import { PRIMARY } from '../fixtures/google-calendar/scenario.mjs'; +import { createController, describe, type ViewState } from './controller.js'; +import { DAY, fakeStore, ONTOLOGY, ROW_CLASS, TABLE } from './fakeStore.js'; +import { + ALL_DAY, + DAY as DAY_FIELD, + DESCRIPTION, + END_DAY, + hostValue, + NAME, + NOTES, + PARENT, + PROPERTIES, + readEvents, + RECOMMENDS, + SHORTNAME, +} from './sync.js'; + +type Store = ReturnType; + +function prop(store: Store, shortname: string): string { + const listed = store.resources.get(ONTOLOGY)![PROPERTIES] as string[]; + const subject = listed.find( + s => store.resources.get(s)![SHORTNAME] === shortname, + ); + if (!subject) throw new Error(`no ${shortname} property`); + + return subject; +} + +function row(store: Store, eventId: string) { + const id = prop(store, 'google-event-id'); + + for (const [subject, props] of store.resources) + if (props[PARENT] === TABLE && props[id] === eventId) + return { + subject, + title: props[NAME], + start: props[prop(store, 'start')], + end: props[prop(store, 'end')], + allDay: props[prop(store, ALL_DAY)], + day: props[prop(store, DAY_FIELD)], + endDay: props[prop(store, END_DAY)], + }; + + return undefined; +} + +/** A commit made in the host's table: the named columns, by shortname. */ +function hostEdit( + store: Store, + eventId: string, + fields: Record, +) { + const subject = row(store, eventId)!.subject; + const stored = { ...store.resources.get(subject)! }; + + for (const [shortname, value] of Object.entries(fields)) { + const property = shortname === 'name' ? NAME : prop(store, shortname); + if (value === undefined) delete stored[property]; + else stored[property] = value as never; + } + + store.resources.set(subject, stored); +} + +async function imported() { + const store = fakeStore(); + const controller = createController(store, () => {}); + await controller.load(); + await controller.choose(PRIMARY); + + return store; +} + +/** Opens the app again on the same rows: `load()` compares on open. */ +async function reopen(store: Store) { + const controller = createController(store, () => {}); + await ( + await controller.load() + ).refreshing; + + return { controller, state: ready(controller.state()) }; +} + +const ready = (state: ViewState) => { + if (state.kind !== 'ready') throw new Error(describe(state)); + + return state; +}; + +const fields = (state: ReturnType, id: string) => + state.summary.review.find(r => r.edit.id === id)?.fields; + +suite('compare on open: edits made in the host table', () => { + it('title, notes and location become one reviewed PATCH of those fields', async () => { + const store = await imported(); + hostEdit(store, 'timed', { + name: 'Retitled in the table', + [NOTES]: 'New agenda', + location: 'Room 12', + }); + const { controller, state } = await reopen(store); + expect(fields(state, 'timed')).toEqual([ + { + field: 'Title', + before: 'Calendar timed fixture', + after: 'Retitled in the table', + }, + { field: 'Description', before: 'Synthetic agenda', after: 'New agenda' }, + { field: 'Location', before: 'Room 4', after: 'Room 12' }, + ]); + // Opening sends nothing. + expect(store.google.writes).toEqual([]); + await controller.send(); + expect(store.google.writes).toEqual([ + { + id: 'timed', + patch: { + summary: 'Retitled in the table', + description: 'New agenda', + location: 'Room 12', + }, + ifMatch: expect.stringMatching(/^"v\d+"$/), + }, + ]); + expect((await reopen(store)).state.summary.review).toEqual([]); + }); + + it('Start and End edited in the table are sent; the stale Day is derived again', async () => { + const store = await imported(); + hostEdit(store, 'timed', { + start: '2026-09-26T11:00:00+02:00', + end: '2026-09-26T12:00:00+02:00', + }); + const { controller, state } = await reopen(store); + expect(fields(state, 'timed')?.map(f => f.field)).toEqual(['Start', 'End']); + // Day followed Start when the preview wrote the row. + expect(row(store, 'timed')!.day).toBe('2026-09-26'); + await controller.send(); + expect(store.google.writes[0].patch).toEqual({ + start: { dateTime: '2026-09-26T11:00:00+02:00' }, + end: { dateTime: '2026-09-26T12:00:00+02:00' }, + }); + }); + + it('Day and End day moved on an all-day event: Google gets the days the host view shows', async () => { + const store = await imported(); + // The host view draws Day up to, not including, End day: 26 and 27. + hostEdit(store, 'all-day', { + [DAY_FIELD]: '2026-09-26', + [END_DAY]: '2026-09-28', + }); + // The app's views mark it before any preview. + const events = await readEvents(store, { + summary: 'Synthetic', + color: '#9fe1e7', + accessRole: 'owner', + }); + expect(events.find(e => e.id === 'all-day')).toMatchObject({ + pending: true, + start: '2026-09-26', + end: '2026-09-28', + }); + const { controller, state } = await reopen(store); + expect(fields(state, 'all-day')).toEqual([ + { field: 'Start', before: DAY, after: '2026-09-26' }, + { field: 'End', before: '2026-09-25', after: '2026-09-28' }, + ]); + await controller.send(); + expect(store.google.writes[0].patch).toEqual({ + start: { date: '2026-09-26' }, + end: { date: '2026-09-28' }, + }); + // Sent: the row's Start and End now say what Day and End day said. + expect(row(store, 'all-day')).toMatchObject({ + start: '2026-09-26', + end: '2026-09-28', + day: '2026-09-26', + endDay: '2026-09-28', + }); + const again = (await reopen(store)).state.summary; + expect(again).toMatchObject({ review: [], conflicts: [], invalid: [] }); + }); + + it('only Day moved: End day stays, so the range the host shows changes with it', async () => { + const store = await imported(); + hostEdit(store, 'trip', { [DAY_FIELD]: '2026-09-11' }); + const { state } = await reopen(store); + expect(fields(state, 'trip')).toEqual([ + { field: 'Start', before: '2026-09-10', after: '2026-09-11' }, + ]); + }); + + it('only End day moved on the three-day event: the end moves', async () => { + const store = await imported(); + hostEdit(store, 'trip', { [END_DAY]: '2026-09-15' }); + const { state } = await reopen(store); + expect(fields(state, 'trip')).toEqual([ + { field: 'End', before: '2026-09-13', after: '2026-09-15' }, + ]); + }); + + it('End day cleared on an all-day event: the host shows Day only, so one day', async () => { + const store = await imported(); + hostEdit(store, 'trip', { [END_DAY]: undefined }); + const { state } = await reopen(store); + expect(fields(state, 'trip')).toEqual([ + { field: 'End', before: '2026-09-13', after: '2026-09-11' }, + ]); + }); + + it('End day not after Day on an all-day event is held back, not rewritten or sent', async () => { + const store = await imported(); + hostEdit(store, 'trip', { [END_DAY]: '2026-09-10' }); + const { state } = await reopen(store); + expect(state.summary.invalid).toEqual([ + { + title: 'Calendar three-day fixture', + reason: + 'End day must be after Day: for an all-day event it is the day after the last day', + }, + ]); + expect(state.summary.review).toEqual([]); + // The person's value stays as they typed it. + expect(row(store, 'trip')!.endDay).toBe('2026-09-10'); + expect(store.google.writes).toEqual([]); + }); + + it('Day moved on a timed event: same clock time and offset, end moves with it', async () => { + const store = await imported(); + hostEdit(store, 'timed', { [DAY_FIELD]: '2026-09-27' }); + const { controller, state } = await reopen(store); + expect(fields(state, 'timed')?.map(f => [f.field, f.after])).toEqual([ + ['Start', '2026-09-27T09:30:00+02:00'], + ['End', '2026-09-27T10:30:00+02:00'], + ]); + await controller.send(); + expect(store.google.writes[0].patch).toEqual({ + start: { dateTime: '2026-09-27T09:30:00+02:00' }, + end: { dateTime: '2026-09-27T10:30:00+02:00' }, + }); + }); + + it('End day set on a timed event: it ends on that date, at its own time', async () => { + const store = await imported(); + hostEdit(store, 'timed', { [END_DAY]: '2026-09-25' }); + const { state } = await reopen(store); + expect(fields(state, 'timed')).toEqual([ + { + field: 'End', + before: `${DAY}T10:30:00+02:00`, + after: '2026-09-25T10:30:00+02:00', + }, + ]); + }); + + it('All day turned on for a timed event: an all-day event on its Day', async () => { + const store = await imported(); + hostEdit(store, 'timed', { [ALL_DAY]: true }); + const { controller, state } = await reopen(store); + expect(fields(state, 'timed')?.map(f => [f.field, f.after])).toEqual([ + ['Start', DAY], + ['End', '2026-09-25'], + ['All day', 'true'], + ]); + await controller.send(); + expect(store.google.writes[0].patch).toEqual({ + start: { date: DAY }, + end: { date: '2026-09-25' }, + }); + }); + + it('All day turned off with no times to send is held back with the reason', async () => { + const store = await imported(); + hostEdit(store, 'all-day', { [ALL_DAY]: false }); + const { state } = await reopen(store); + expect(state.summary.invalid).toEqual([ + { + title: 'Calendar all-day fixture', + reason: + 'All day was turned off in the table, with no times to send; set the times with Edit in the app', + }, + ]); + expect(state.summary.review).toEqual([]); + expect(describe(state)).toContain('1 row can’t be sent as edited.'); + }); + + it('Start and Day both edited, disagreeing, is held back', async () => { + const store = await imported(); + hostEdit(store, 'trip', { + start: '2026-09-09', + [DAY_FIELD]: '2026-09-11', + }); + const { state } = await reopen(store); + expect(state.summary.invalid.map(i => i.title)).toEqual([ + 'Calendar three-day fixture', + ]); + expect(state.summary.review).toEqual([]); + }); + + it('Day edited here and the start changed in Google: a conflict, neither side overwritten', async () => { + const store = await imported(); + hostEdit(store, 'trip', { [DAY_FIELD]: '2026-09-09' }); + store.google.editRemote('trip', { start: { date: '2026-09-11' } }); + const { state } = await reopen(store); + expect(state.summary.conflicts).toEqual([ + expect.objectContaining({ + id: 'trip', + kind: 'both', + fields: ['start'], + local: expect.objectContaining({ start: '2026-09-09' }), + remote: expect.objectContaining({ start: '2026-09-11' }), + }), + ]); + expect(state.summary.review).toEqual([]); + expect(row(store, 'trip')!.day).toBe('2026-09-09'); + expect(store.google.writes).toEqual([]); + }); + + it('Day edited here and another field changed in Google: Google’s lands, the Day edit is reviewed', async () => { + const store = await imported(); + hostEdit(store, 'trip', { [DAY_FIELD]: '2026-09-09' }); + store.google.editRemote('trip', { location: 'Hut' }); + const { state } = await reopen(store); + expect(state.summary.conflicts).toEqual([]); + expect(fields(state, 'trip')?.map(f => f.field)).toEqual(['Start']); + expect(row(store, 'trip')).toMatchObject({ + start: '2026-09-09', + day: '2026-09-09', + }); + expect( + store.resources.get(row(store, 'trip')!.subject)![ + prop(store, 'location') + ], + ).toBe('Hut'); + }); + + it('a synced row deleted here is not noticed: it is imported again, and Google keeps the event', async () => { + const store = await imported(); + store.resources.delete(row(store, 'timed')!.subject); + const { state } = await reopen(store); + // Declared limitation (#177 §4.1, H6): with no change list with + // tombstones, a deleted row leaves nothing to compare. + expect(state.summary).toMatchObject({ added: 1, review: [] }); + expect(row(store, 'timed')).toBeDefined(); + expect(store.google.writes).toEqual([]); + }); + + it('a row added by hand (the host view’s "+") stays local: shown, never uploaded', async () => { + const store = await imported(); + await store.newResource({ + parent: TABLE, + isA: [ROW_CLASS], + propVals: { + [NAME]: 'Added in the host', + [prop(store, DAY_FIELD)]: '2026-09-20', + [prop(store, ALL_DAY)]: true, + [prop(store, END_DAY)]: '2026-09-21', + }, + }); + const { state } = await reopen(store); + expect(state.summary).toMatchObject({ localOnly: 1, review: [] }); + expect(describe(state)).toContain('creating events isn’t supported'); + const events = await readEvents(store, { + summary: 'Synthetic', + color: '#9fe1e7', + accessRole: 'owner', + }); + expect(events.find(e => e.title === 'Added in the host')).toMatchObject({ + readOnly: true, + pending: false, + day: '2026-09-20', + }); + expect(store.google.writes).toEqual([]); + }); + + it('a column the app doesn’t map is listed as kept here, never silently dropped', async () => { + const store = await imported(); + const attendees = 'did:ad:prop-attendees'; + store.resources.set(attendees, { + [PARENT]: ONTOLOGY, + [SHORTNAME]: 'attendees', + [NAME]: 'Attendees', + }); + const klass = store.resources.get(ROW_CLASS)!; + store.resources.set(ROW_CLASS, { + ...klass, + [RECOMMENDS]: [...(klass[RECOMMENDS] as string[]), attendees], + }); + const subject = row(store, 'timed')!.subject; + store.resources.set(subject, { + ...store.resources.get(subject)!, + [attendees]: 'A. Example', + // The core Description is not Notes: not sent either. + [DESCRIPTION]: 'Typed in the wrong column', + }); + hostEdit(store, 'timed', { location: 'Room 5' }); + const { controller, state } = await reopen(store); + expect(state.summary.unmapped).toEqual([{ column: 'Attendees', rows: 1 }]); + expect(describe(state)).toContain( + '1 column the app doesn’t send (Attendees) is kept here only.', + ); + await controller.send(); + expect(store.google.writes[0].patch).toEqual({ location: 'Room 5' }); + expect(store.resources.get(subject)![attendees]).toBe('A. Example'); + }); +}); + +suite('hostValue, the lens read backwards', () => { + const base = { + title: 'x', + description: '', + location: '', + start: '2026-09-10', + end: '2026-09-13', + allDay: true, + }; + + it('rows the app wrote (Day and End day agree with Start and End) read as Start and End', () => { + expect( + hostValue( + { + start: '2026-09-11', + end: '2026-09-12', + allDay: true, + day: '2026-09-11', + endDay: '2026-09-12', + }, + base, + ), + ).toEqual({ start: '2026-09-11', end: '2026-09-12', allDay: true }); + }); + + it('without a baseline, Start and End', () => { + expect( + hostValue( + { + start: '2026-09-10', + end: '2026-09-13', + allDay: true, + day: '2026-09-01', + endDay: undefined, + }, + null, + ), + ).toEqual({ start: '2026-09-10', end: '2026-09-13', allDay: true }); + }); + + it('a Day that isn’t a date is a reason, not a guess', () => { + expect( + hostValue( + { + start: '2026-09-10', + end: '2026-09-13', + allDay: true, + day: '10 September', + endDay: '2026-09-13', + }, + base, + ), + ).toEqual({ reason: 'Day must be a date (YYYY-MM-DD)' }); + }); + + it('a row with no Day (an older version’s) reads as Start and End', () => { + expect( + hostValue( + { + start: '2026-09-10', + end: '2026-09-13', + allDay: true, + day: undefined, + endDay: undefined, + }, + base, + ), + ).toEqual({ start: '2026-09-10', end: '2026-09-13', allDay: true }); + }); +}); diff --git a/integrations/calendar/app/controller.test.ts b/integrations/calendar/app/controller.test.ts index 84d6d294..969d4576 100644 --- a/integrations/calendar/app/controller.test.ts +++ b/integrations/calendar/app/controller.test.ts @@ -154,6 +154,7 @@ suite('pill', () => { conflicts: [], localOnly: 0, invalid: [], + unmapped: [], review: [], }; diff --git a/integrations/calendar/app/controller.ts b/integrations/calendar/app/controller.ts index d2077566..92e6cd94 100644 --- a/integrations/calendar/app/controller.ts +++ b/integrations/calendar/app/controller.ts @@ -129,6 +129,10 @@ export function describe(state: ViewState): string { parts.push( `${plural(s.localOnly, 'row')} made here won’t be sent: creating events isn’t supported.`, ); + if (s.unmapped.length) + parts.push( + `${plural(s.unmapped.length, 'column')} the app doesn’t send (${s.unmapped.map(u => u.column).join(', ')}) ${s.unmapped.length === 1 ? 'is' : 'are'} kept here only.`, + ); return parts.join(' '); } diff --git a/integrations/calendar/app/main.ts b/integrations/calendar/app/main.ts index 3ded0fbb..08bbfa33 100644 --- a/integrations/calendar/app/main.ts +++ b/integrations/calendar/app/main.ts @@ -93,6 +93,7 @@ const EMPTY_SUMMARY: ImportSummary = { conflicts: [], localOnly: 0, invalid: [], + unmapped: [], review: [], }; diff --git a/integrations/calendar/app/package.json b/integrations/calendar/app/package.json index 4cf8130b..50644e3b 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.2", + "version": "0.1.3", "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/sheets.ts b/integrations/calendar/app/sheets.ts index 48865a5f..1d191937 100644 --- a/integrations/calendar/app/sheets.ts +++ b/integrations/calendar/app/sheets.ts @@ -123,6 +123,15 @@ export function review( `${plural(summary.localOnly, 'row')} made in this table won’t be sent: creating events isn’t supported.`, ), ); + if (summary.unmapped.length) + notes.push( + h( + doc, + 'p', + { class: 'fine' }, + `Kept here only, never sent to Google: ${summary.unmapped.map(u => `${u.column} (${plural(u.rows, 'event')})`).join(', ')}. Only Title, Notes, Location, Start, End, All day, Day and End day are sent.`, + ), + ); // After a send: one row per outcome. if (!items.length && outcomes.length) { diff --git a/integrations/calendar/app/sync.ts b/integrations/calendar/app/sync.ts index 82f05eee..52ce57bd 100644 --- a/integrations/calendar/app/sync.ts +++ b/integrations/calendar/app/sync.ts @@ -20,8 +20,10 @@ * 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 on local edits, and never sent to Google. The app's own - * views place rows by them, as the host view does (`events.ts`). + * on import and on local edits. Edited in the host (its table or its + * Calendar view), they are translated back into Start and End for review + * (`hostValue`); they are never sent as such. 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 @@ -44,7 +46,12 @@ import { type Preview, type Projection, } from '../adapter.js'; +import { + isCalendarDate, + nextCalendarDate, +} from '../../../browser/lib/src/calendar-date.js'; import type { CalEvent } from './events.js'; +import { addDays, daysBetween } from './time.js'; import { relay, UncertainWriteError, type Relayed } from './relay.js'; import type { HostProxy, @@ -62,6 +69,7 @@ export const SHORTNAME = `${A}/properties/shortname`; export const DESCRIPTION = `${A}/properties/description`; export const DATATYPE = `${A}/properties/datatype`; export const RECOMMENDS = `${A}/properties/recommends`; +export const REQUIRES = `${A}/properties/requires`; export const PROPERTIES = `${A}/properties/properties`; export const PROPERTY_CLASS = `${A}/classes/Property`; const DT = `${A}/datatypes`; @@ -116,14 +124,14 @@ export const SPECS: Record = { name: 'Day', datatype: `${DT}/date`, description: - 'The date of Start, for calendar views. Derived on import; edit Start to move the event.', + 'The date of Start, for calendar views. Derived from Start; editing it moves the event, and the Calendar app offers that move to Google for review.', column: true, }, [END_DAY]: { name: 'End day', datatype: `${DT}/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.', + '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 from End; editing it changes the end, and the Calendar app offers that to Google for review.', column: true, }, [NOTES]: { @@ -362,20 +370,124 @@ export async function chooseCalendar( const text = (value: JSONValue): string => typeof value === 'string' ? value : ''; -function cardOf(row: PluginResource, props: Props): Card { +/** + * The row as the adapter's projection. For a synced row, Start, End and All + * day come through `hostValue`, so an edit to Day or End day made in the + * host is read, not overwritten. `reason` says why the row's columns can't + * be turned into something Google can take; the value is then the row's own + * Start and End. + */ +function cardOf(row: PluginResource, props: Props): Card & { reason?: string } { const id = row.get(props['google-event-id']); + const bound = typeof id === 'string' && !!id; + const own = { + start: text(row.get(props.start)), + end: text(row.get(props.end)), + allDay: row.get(props[ALL_DAY]) === true, + }; + const when = bound + ? hostValue( + { ...own, day: row.get(props[DAY]), endDay: row.get(props[END_DAY]) }, + baselineOf(row, props), + ) + : own; return { subject: row.subject, - ...(typeof id === 'string' && id ? { id } : {}), + ...(bound ? { id } : {}), value: { title: text(row.get(NAME)), description: text(row.get(props[NOTES])), location: text(row.get(props.location)), - start: text(row.get(props.start)), - end: text(row.get(props.end)), - allDay: row.get(props[ALL_DAY]) === true, + ...('reason' in when ? own : when), }, + ...('reason' in when ? { reason: when.reason } : {}), + }; +} + +type When = Pick; + +/** + * The lens read backwards: what a synced row's Start, End, All day, Day and + * End day columns ask Google for. The rows keep the host's format (Day, and + * an exclusive End day for all-day events; `endDayOf`), and the host table + * and its built-in Calendar view edit Day, End day and All day without + * touching Start and End. + * + * - Day and End day agree with Start and End (as the app writes them): Start + * and End. + * - Only Day, End day or All day differ from the baseline: Start and End + * are made from them, so Google shows the days the host view shows. All + * day: Day to End day (exclusive), or Day alone when End day is unset. + * Timed: Day with Start's clock time and offset; End moves by the same + * number of days, or to End day (Day when cleared) if End day was edited. + * - Only Start or End differ from the baseline (Day and End day are then + * stale): Start and End, and the next write re-derives Day and End day. + * - Both differ and disagree, or the host format has no Google equivalent + * (End day not after Day on an all-day row, All day turned off with no + * times to send): a reason, and the row is held back, not rewritten. + * + * Without a baseline, Start and End. + */ +export function hostValue( + row: When & { day: JSONValue; endDay: JSONValue }, + baseline: Projection | null, +): When | { reason: string } { + const own: When = { start: row.start, end: row.end, allDay: row.allDay }; + if (!baseline) return own; + const day = typeof row.day === 'string' ? row.day : undefined; + const endDay = typeof row.endDay === 'string' ? row.endDay : undefined; + const startEdited = row.start !== baseline.start || row.end !== baseline.end; + const allDayEdited = row.allDay !== baseline.allDay; + const agrees = day === row.start.slice(0, 10) && endDay === endDayOf(own); + if (agrees && (startEdited || !allDayEdited)) return own; + // No Day (cleared, or a row an older version wrote without one): nothing + // for Google in it; the next write of the row derives it again. + if (day === undefined && !allDayEdited) return own; + const baseEndDay = endDayOf(baseline); + const dayEdited = + day !== baseline.start.slice(0, 10) || endDay !== baseEndDay; + + if (startEdited) + return dayEdited + ? { + reason: + 'Start or End and Day or End day were edited in the table and disagree; change the event with Edit in the app', + } + : own; + + if (!isCalendarDate(day)) + return { reason: 'Day must be a date (YYYY-MM-DD)' }; + if (endDay !== undefined && !isCalendarDate(endDay)) + return { reason: 'End day must be a date (YYYY-MM-DD)' }; + + if (row.allDay) { + if (endDay === undefined) + return { start: day, end: nextCalendarDate(day), allDay: true }; + if (endDay <= day) + return { + reason: + 'End day must be after Day: for an all-day event it is the day after the last day', + }; + + return { start: day, end: endDay, allDay: true }; + } + + if (baseline.allDay) + return { + reason: + 'All day was turned off in the table, with no times to send; set the times with Edit in the app', + }; + const startDate = row.start.slice(0, 10); + const endDate = + endDay !== baseEndDay + ? (endDay ?? day) + : addDays(row.end.slice(0, 10), daysBetween(startDate, day)); + + return { + start: `${day}${row.start.slice(10)}`, + end: `${endDate}${row.end.slice(10)}`, + allDay: false, }; } @@ -417,6 +529,52 @@ export interface Rows { localOnly: number; /** Bound rows whose local value can't be sent, with the reason. */ invalid: Map; + /** Columns the app doesn't map, with how many bound rows fill them. */ + unmapped: Array<{ column: string; rows: number }>; +} + +/** + * Version 0.1.0's Day and All day, derived from Start: not columns a person + * fills. (Its other old column, the core Description, is listed: a value + * there is not sent; Notes is.) + */ +const LEGACY = new Set(['day', 'all-day']); + +const filled = (value: JSONValue) => + value !== undefined && + value !== null && + value !== '' && + !(Array.isArray(value) && !value.length); + +/** + * The table's columns (the row class's `requires` and `recommends`) that + * the app neither writes nor sends: ones a person added in the host. Their + * values stay on the rows; this only finds them, so the app can say they + * aren't sent. + */ +async function otherColumns( + store: PluginStore, + where: Layout, + props: Props, +): Promise { + const klass = await store.getResource(where.rowClass); + const own = new Set([ + NAME, + ...Object.values(props as Record), + ]); + const out: string[] = []; + + for (const subject of new Set([ + ...asList(klass.get(REQUIRES)), + ...asList(klass.get(RECOMMENDS)), + ])) { + if (own.has(subject)) continue; + const shortname = (await store.getResource(subject)).get(SHORTNAME); + if (typeof shortname === 'string' && LEGACY.has(shortname)) continue; + out.push(subject); + } + + return out; } async function readRows( @@ -429,7 +587,10 @@ async function readRows( bound: new Map(), localOnly: 0, invalid: new Map(), + unmapped: [], }; + const others = await otherColumns(store, where, props); + const counts = new Map(others.map(p => [p, 0])); for (const subject of await store.query({ property: PARENT, @@ -437,8 +598,25 @@ async function readRows( })) { const row = await store.getResource(subject); const card = cardOf(row, props); - if (!card.id) out.localOnly++; - else out.bound.set(subject, row); + + if (!card.id) { + out.localOnly++; + continue; + } + + out.bound.set(subject, row); + for (const p of others) + if (filled(row.get(p))) counts.set(p, counts.get(p)! + 1); + } + + for (const [subject, rows] of counts) { + if (!rows) continue; + const column = await store.getResource(subject); + const name = column.get(NAME) ?? column.get(SHORTNAME); + out.unmapped.push({ + column: typeof name === 'string' && name ? name : subject, + rows, + }); } return out; @@ -461,8 +639,8 @@ function host( const cards: Card[] = []; for (const row of rows.bound.values()) { - const card = cardOf(row, props); - const reason = invalid(card.value); + const { reason: hostReason, ...card } = cardOf(row, props); + const reason = hostReason ?? invalid(card.value); if (reason) { // Held back, not dropped: the baseline stands in for it, so the @@ -541,6 +719,8 @@ export interface ImportSummary { conflicts: Conflict[]; localOnly: number; invalid: Array<{ title: string; reason: string }>; + /** Columns a person added that the app doesn't send, with how many synced rows fill them. */ + unmapped: Array<{ column: string; rows: number }>; review: PendingEdit[]; } @@ -568,7 +748,7 @@ function fieldsOf(before: Projection, after: Projection) { * undefined when it has none. Exact strings: the date part of what Google * sent, never re-derived through a `Date`. */ -export function endDayOf(value: Projection): string | undefined { +export function endDayOf(value: When): string | undefined { const day = value.start.slice(0, 10); const end = value.end.slice(0, 10); if (value.allDay) return end; @@ -688,6 +868,7 @@ export async function refresh( }), localOnly: rows.localOnly, invalid: [...rows.invalid.values()], + unmapped: rows.unmapped, review: [], }; @@ -802,6 +983,18 @@ export async function send( if (pending.edit.subject && projection) { const row = await store.getResource(pending.edit.subject); + // An edit made in the host's format (Day, End day, All day) was + // sent as Start and End: write those back, so the row agrees with + // its new baseline. Unless the row changed again since the review; + // then the next preview lists that change. + const now = cardOf(row, props); + if ( + !now.reason && + (Object.keys(LABELS) as Array).every( + k => now.value[k] === pending.desired[k], + ) + ) + writeRow(row, props, projection); await row .set(props['google-etag'], event.etag) .set(props['sync-baseline'], JSON.stringify(projection)) @@ -896,9 +1089,10 @@ export async function readEvents( pending: !!card.id && !!baseline && - (Object.keys(LABELS) as Array).some( - k => baseline[k] !== card.value[k], - ), + (!!card.reason || + (Object.keys(LABELS) as Array).some( + k => baseline[k] !== card.value[k], + )), conflict: inConflict.has(subject), readOnly: readOnly || !card.id, calendar: { name: meta.summary, color: meta.color }, diff --git a/integrations/calendar/e2e/calendar.spec.ts b/integrations/calendar/e2e/calendar.spec.ts index 2b6645f0..3517141d 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.2'; +const VERSION = '0.1.3'; const NAME = 'https://atomicdata.dev/properties/name'; /** The host's shared calendar field names (`@tomic/lib` `calendarFields`). */ const DAY = 'atomic-calendar-day'; @@ -207,6 +207,41 @@ test.describe('calendar drive app', () => { ); expect((await driver('state', [])).writes).toHaveLength(2); + // Compare on open (#192): an edit made in the host's table, in the + // host's format (End day, exclusive), found when the app opens again and + // sent, after review, as Google's end date. + const oneDay = (await rowsOf(page)).find( + r => r.name === 'Calendar all-day fixture', + )!; + const later = new Date(Date.parse(`${oneDay[END_DAY]}T00:00:00Z`) + 864e5) + .toISOString() + .slice(0, 10); + await setRowField(page, 'Calendar all-day fixture', END_DAY, later); + await page.reload(); + await expect(pill).toContainText(/Synced|to review/, { timeout: 30_000 }); + await app.getByRole('button', { name: 'Review 1 change' }).click(); + await expect(sheet).toContainText( + new RegExp(`End\\s*${oneDay[END_DAY]}\\s*→\\s*becomes\\s*${later}`), + ); + expect((await driver('state', [])).writes).toHaveLength(2); + await sendOne.click(); + await expect(sheet).toContainText('1 of 1 change sent'); + expect((await driver('state', [])).writes.at(-1)).toEqual( + expect.objectContaining({ + id: 'all-day', + patch: { end: { date: later } }, + ifMatch: expect.stringMatching(/^"v\d+"$/), + }), + ); + await sheet.getByRole('button', { name: 'Done' }).click(); + await expect + .poll( + async () => + (await rowsOf(page)).find(r => r.name === 'Calendar all-day fixture') + ?.end, + ) + .toBe(later); + // The connection lives at the proxy, owned by the signed-in user and // delegated to this app; the page keeps nothing credential-like. const connections = await proxyConnections('google-calendar'); @@ -411,7 +446,8 @@ test.describe('calendar drive app: responsive and theme (#89 C13)', () => { // Exclusive end: not on the 13th, and not the day before. await expect(chip(`${month}13`, trip.name)).toHaveCount(0); await expect(chip(`${month}09`, trip.name)).toHaveCount(0); - // A one-day all-day event covers its day only. + // The other all-day event is not drawn on its End day. (The fixture is + // shared: after the first test's End day edit it is two days long.) await expect(chip(allDay[DAY], allDay.name)).toBeVisible(); await expect(chip(allDay[END_DAY], allDay.name)).toHaveCount(0); // A timed event shows on its day. @@ -534,6 +570,45 @@ async function setRowTitle(page: Page, from: string, to: string) { ); } +/** Sets one column (by shortname) of the row named `title`: a user's commit, as a table edit is. */ +async function setRowField( + page: Page, + title: string, + shortname: string, + value: string, +) { + const table = await tableOf(page); + await page.evaluate( + async ([subject, rowTitle, short, newValue, name]) => { + const store = window.store!; + const collection = await ( + await store.getResource(subject) + ).getChildrenCollection(500); + + for (const member of await collection.getAllMembers()) { + const row = await store.getResource(member); + if (row.get(name) !== rowTitle) continue; + + for (const property of Object.keys(row.getPropVals())) { + const found = (await store.getResource(property)).get( + 'https://atomicdata.dev/properties/shortname', + ); + if (found !== short) continue; + await row.set(property, newValue); + await row.save(); + + return; + } + + throw new Error(`row ${rowTitle} has no ${short}`); + } + + throw new Error(`no row named ${rowTitle}`); + }, + [table, title, shortname, value, NAME] as const, + ); +} + /** The app's table: the value on the app that is a Table (`app-data`). */ async function tableOf(page: Page): Promise { return page.evaluate(async () => { diff --git a/integrations/catalog.json b/integrations/catalog.json index c4b3e31f..e10358ad 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.2", + "https://atomicdata.dev/integrations/properties/version": "0.1.3", "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.2/ui.js", - "https://atomicdata.dev/integrations/properties/app-module-integrity": "sha384-vNveURZFLgD4O9jmppoh9EwESAONdgkbw4QWyYKqNTNi0tTn0dtDWg10hUoloQt2" + "https://atomicdata.dev/integrations/properties/app-module": "https://ontola.github.io/atomic-plugins/apps/calendar/0.1.3/ui.js", + "https://atomicdata.dev/integrations/properties/app-module-integrity": "sha384-+5KpEA6f84tnxKYnsVP9k+3aqfO6R3n+tYrj1YLLeaWHKE9lT9jqrb/H380/TxiT" }, { "https://atomicdata.dev/properties/localId": "devonian-todoist", From 2bbce9856d92e7703bcd915db1078adc56fb9a72 Mon Sep 17 00:00:00 2001 From: Michiel de Jong Date: Tue, 29 Sep 2026 14:52:15 +0200 Subject: [PATCH 2/2] calendar: view test for compare on open; e2e matches the sheet's last-day labels (#192) Co-Authored-By: Claude Opus 5.5 --- integrations/calendar/app/view.test.ts | 36 ++++++++++++++++++++++ integrations/calendar/e2e/calendar.spec.ts | 12 +++++--- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/integrations/calendar/app/view.test.ts b/integrations/calendar/app/view.test.ts index 7bdc01df..77f7a345 100644 --- a/integrations/calendar/app/view.test.ts +++ b/integrations/calendar/app/view.test.ts @@ -453,6 +453,42 @@ describe('Calendar views: host operations of pin 007869464', () => { expect(opened).toEqual(['did:ad:row']); }); + it('#192: opening the app again lists an End day edited in the host, and a column it doesn’t send', async () => { + const { store } = await chosen(1120); + const SHORTNAME = 'https://atomicdata.dev/properties/shortname'; + const RECOMMENDS = 'https://atomicdata.dev/properties/recommends'; + const endDay = [...store.resources.entries()].find( + ([, p]) => p[SHORTNAME] === 'atomic-calendar-end-day', + )![0]; + const [trip, props] = rowWith(store, 'Calendar three-day fixture'); + const attendees = 'did:ad:prop-attendees'; + store.resources.set(attendees, { + [SHORTNAME]: 'attendees', + [NAME]: 'Attendees', + }); + const klass = store.resources.get('did:ad:class-item')!; + store.resources.set('did:ad:class-item', { + ...klass, + [RECOMMENDS]: [...(klass[RECOMMENDS] as string[]), attendees], + }); + store.resources.set(trip, { + ...props, + [endDay]: '2026-09-15', + [attendees]: 'A. Example', + }); + + const root = await mount(store, 1120); + await settle(10); + await click(one(root, 'button', 'Review 1 change'), 10); + const sheet = one(root, 'dialog'); + // Shown as the last day, not the exclusive end: 12th → 14th. + expect(sheet.textContent).toContain('EndSat 12 Sep→ becomes Mon 14 Sep'); + expect(sheet.textContent).toContain( + 'Kept here only, never sent to Google: Attendees (1 event).', + ); + expect(store.google.writes).toEqual([]); + }); + it('an older host shows none of these controls', async () => { const { root } = await chosen(1120, fakeStore({ hostOps: false })); expect(byRole(root, 'button', 'Month ↗')).toEqual([]); diff --git a/integrations/calendar/e2e/calendar.spec.ts b/integrations/calendar/e2e/calendar.spec.ts index 3517141d..b2dc039f 100644 --- a/integrations/calendar/e2e/calendar.spec.ts +++ b/integrations/calendar/e2e/calendar.spec.ts @@ -218,11 +218,13 @@ test.describe('calendar drive app', () => { .slice(0, 10); await setRowField(page, 'Calendar all-day fixture', END_DAY, later); await page.reload(); - await expect(pill).toContainText(/Synced|to review/, { timeout: 30_000 }); - await app.getByRole('button', { name: 'Review 1 change' }).click(); - await expect(sheet).toContainText( - new RegExp(`End\\s*${oneDay[END_DAY]}\\s*→\\s*becomes\\s*${later}`), - ); + // No Sync now: opening the app compares the rows with their baselines. + const review = app.getByRole('button', { name: 'Review 1 change' }); + await expect(review).toBeVisible({ timeout: 30_000 }); + await review.click(); + // The sheet shows the last day (End day minus one), not the raw date. + await expect(sheet).toContainText('Calendar all-day fixture'); + await expect(sheet).toContainText(/End[^→]*→\s*becomes/); expect((await driver('state', [])).writes).toHaveLength(2); await sendOne.click(); await expect(sheet).toContainText('1 of 1 change sent');