diff --git a/contacts.js b/contacts.js index 8f9d255..f4e89f0 100644 --- a/contacts.js +++ b/contacts.js @@ -28,7 +28,7 @@ function clean(b, old = {}) { const c = { id: old.id || crypto.randomBytes(6).toString('hex'), name: str(b.name, 120), org: str(b.org, 120), emails: list(b.emails, 10, v => EMAIL.test(v)), phones: list(b.phones, 10), address: str(b.address, 300), - birthday: cleanBirthday(b.birthday), notes: str(b.notes, 2000), updated: Date.now(), + birthday: cleanBirthday(b.birthday), anniversary: cleanBirthday(b.anniversary), gifts: str(b.gifts, 2000), notes: str(b.notes, 2000), updated: Date.now(), }; c.emails.forEach(e => { e.value = e.value.toLowerCase(); }); if (!c.name) c.name = c.emails[0] ? c.emails[0].value.split('@')[0] : c.org; @@ -57,6 +57,7 @@ function parseVcf(text) { else if (name === 'TEL') cur.phones.push({ label: label.toLowerCase(), value: unesc(val).replace(/^tel:/i, '') }); else if (name === 'ADR') cur.address = val.split(';').map(unesc).filter(Boolean).join(', '); else if (name === 'BDAY') cur.birthday = val.replace(/T.*$/, ''); + else if (name === 'ANNIVERSARY' || name === 'X-ANNIVERSARY') cur.anniversary = val.replace(/T.*$/, ''); else if (name === 'NOTE') cur.notes = unesc(val); } return out.map(c => ({ ...c, name: c.name || c.n || '' })); @@ -66,19 +67,22 @@ function toVcf(l) { ...c.emails.map(e => `EMAIL;TYPE=${/^(home|work)$/.test(e.label) ? e.label.toUpperCase() : 'INTERNET'}:${esc(e.value)}`), ...c.phones.map(p => `TEL;TYPE=${/^(home|work|cell|fax)$/.test(p.label) ? p.label.toUpperCase() : 'VOICE'}:${esc(p.value)}`), c.address && 'ADR:;;' + esc(c.address) + ';;;;', c.birthday && 'BDAY:' + (c.birthday.startsWith('--') ? c.birthday.replace(/-/g, '').replace(/^/, '--') : c.birthday.replace(/-/g, '')), + c.anniversary && 'X-ANNIVERSARY:' + c.anniversary.replace(/-/g, '').replace(/^/, c.anniversary.startsWith('--') ? '--' : ''), c.notes && 'NOTE:' + esc(c.notes), 'END:VCARD'].filter(Boolean).join('\r\n')).join('\r\n') + (l.length ? '\r\n' : ''); } -/** Birthdays as all-day calendar events inside `win` ({ fd, td }: local dates, inclusive) for the calendar view. Repeats every year. */ +/** Birthdays and anniversaries as all-day calendar events inside `win` ({ fd, td }: local dates, inclusive) for the calendar view. Each repeats every year. */ function birthdayEvents(win, uid) { const out = [], y0 = +win.fd.slice(0, 4), y1 = +win.td.slice(0, 4); for (const c of load(uid)) { - if (!c.birthday) continue; - const [mo, d] = c.birthday.startsWith('--') ? [c.birthday.slice(2, 4), c.birthday.slice(5, 7)] : [c.birthday.slice(5, 7), c.birthday.slice(8, 10)], born = c.birthday.startsWith('--') ? 0 : +c.birthday.slice(0, 4); - for (let y = y0; y <= y1; y++) { - const day = `${y}-${mo}-${d}`; if (!real(y, +mo, +d) || day < win.fd || day > win.td) continue; - const end = new Date(Date.UTC(y, +mo - 1, +d + 1)).toISOString().slice(0, 10); - out.push({ title: `${c.name}'s birthday${born && y > born ? ` (${y - born})` : ''}`, location: '', allDay: true, start: day, end, uid: 'birthday:' + c.id + ':' + y, series: false, cal: 'Birthdays', calId: 'contacts', color: '#d9578a', editable: false, contactId: c.id }); + for (const [field, label, color] of [['birthday', "'s birthday", '#d9578a'], ['anniversary', "'s anniversary", '#c9a227']]) { + const v = c[field]; if (!v) continue; + const [mo, d] = v.startsWith('--') ? [v.slice(2, 4), v.slice(5, 7)] : [v.slice(5, 7), v.slice(8, 10)], born = v.startsWith('--') ? 0 : +v.slice(0, 4); + for (let y = y0; y <= y1; y++) { + const day = `${y}-${mo}-${d}`; if (!real(y, +mo, +d) || day < win.fd || day > win.td) continue; + const end = new Date(Date.UTC(y, +mo - 1, +d + 1)).toISOString().slice(0, 10); + out.push({ title: `${c.name}${label}${born && y > born ? ` (${y - born})` : ''}`, location: '', allDay: true, start: day, end, uid: field + ':' + c.id + ':' + y, series: false, cal: 'Birthdays', calId: 'contacts', color, editable: false, contactId: c.id }); + } } } return out; diff --git a/public/contacts-view.js b/public/contacts-view.js index 828320b..6d11057 100644 --- a/public/contacts-view.js +++ b/public/contacts-view.js @@ -51,7 +51,7 @@ function ctShow(c) { ${c.emails.map(e => line(e.label, mailOn ? `${esc(e.value)}` : esc(e.value))).join('')} ${c.phones.map(p => line(p.label, `${esc(p.value)}`)).join('')} ${line('address', c.address ? `${esc(c.address)}` : '')} - ${line('birthday', esc(ctBday(c.birthday)))}${line('notes', esc(c.notes).replace(/\n/g, '
'))} + ${line('birthday', esc(ctBday(c.birthday)))}${line('anniversary', esc(ctBday(c.anniversary)))}${line('gift ideas', esc(c.gifts || '').replace(/\n/g, '
'))}${line('notes', esc(c.notes).replace(/\n/g, '
'))}
${mailOn && c.emails[0] ? `` : ''}
`); el.querySelectorAll('[data-mail]').forEach(a => a.onclick = e => { e.preventDefault(); closeSheet(); ctWrite(a.dataset.mail); }); if (el.querySelector('#ctw')) el.querySelector('#ctw').onclick = () => { closeSheet(); ctWrite(c.emails[0].value); }; @@ -63,12 +63,12 @@ function ctShow(c) { function ctWrite(address) { if (typeof mailCompose === 'function') mailCompose({ to: address }); } function ctEdit(c, seed) { - const x = c || seed || { name: '', org: '', emails: [], phones: [], address: '', birthday: '', notes: '' }; + const x = c || seed || { name: '', org: '', emails: [], phones: [], address: '', birthday: '', anniversary: '', gifts: '', notes: '' }; const rows = (kind, items, ph) => `
${(items.length ? items : [{ label: '', value: '' }]).map(i => `
`).join('')}
Add another`; const el = openSheet(c ? 'Edit contact' : 'New contact', '', `
${rows('emails', x.emails, 'name@example.com')}${rows('phones', x.phones, '555 123 4567')} -
+
`); el.querySelector('#ctx').onclick = closeSheet; el.querySelectorAll('[data-more]').forEach(a => a.onclick = e => { e.preventDefault(); const box = el.querySelector(`.ctrows[data-k="${a.dataset.more}"]`), first = box.firstElementChild; const n = first.cloneNode(true); n.querySelectorAll('input').forEach(i => { i.value = ''; }); box.appendChild(n); n.querySelector('[data-v]').focus(); }); @@ -76,7 +76,8 @@ function ctEdit(c, seed) { el.querySelector('#ctf').onsubmit = async e => { e.preventDefault(); const err = el.querySelector('#cter'); err.textContent = ''; const keepYearless = x.birthday && x.birthday.startsWith('--') && !el.querySelector('#ctb').value; - const body = { id: c && c.id, name: el.querySelector('#ctn').value, org: el.querySelector('#cto').value, emails: grab('emails'), phones: grab('phones'), address: el.querySelector('#cta').value, birthday: keepYearless ? x.birthday : el.querySelector('#ctb').value, notes: el.querySelector('#ctt').value }; + const keepAnn = x.anniversary && x.anniversary.startsWith('--') && !el.querySelector('#ctan').value; + const body = { id: c && c.id, name: el.querySelector('#ctn').value, org: el.querySelector('#cto').value, emails: grab('emails'), phones: grab('phones'), address: el.querySelector('#cta').value, birthday: keepYearless ? x.birthday : el.querySelector('#ctb').value, anniversary: keepAnn ? x.anniversary : el.querySelector('#ctan').value, gifts: el.querySelector('#ctg').value, notes: el.querySelector('#ctt').value }; try { const saved = await ctApi('/', ctOpt('POST', body)); ctList = null; closeSheet(); toast('Saved'); if (location.hash.startsWith('#/contacts')) route(); return saved; } catch (er) { err.textContent = er.message; } }; el.querySelector('#ctn').focus(); diff --git a/tools/contacts-test.js b/tools/contacts-test.js index f9060c5..8e7e25a 100644 --- a/tools/contacts-test.js +++ b/tools/contacts-test.js @@ -22,6 +22,9 @@ const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] == const back = contacts.parseVcf(out); assert.equal(back.length, all.length, 'what is exported can be read back'); // calendar birthdays const ev = contacts.birthdayEvents({ fd: '2026-04-01', td: '2026-04-30' }, people.a.id); assert.equal(ev.length, 1); assert.equal(ev[0].start, '2026-04-15'); assert.equal(ev[0].end, '2026-04-16'); assert.equal(ev[0].title, "Zoe Zed's birthday"); + // anniversaries and gift ideas + { const r = await post('/', { name: 'Ann Iversary', anniversary: '2010-08-12', gifts: 'Scarf\nBook' }); const a = await r.json(); assert.equal(a.anniversary, '2010-08-12'); assert.equal(a.gifts, 'Scarf\nBook'); + const ev = contacts.birthdayEvents({ fd: '2026-08-01', td: '2026-08-31' }, people.a.id).filter(e => e.contactId === a.id); assert.equal(ev.length, 1); assert.equal(ev[0].title, "Ann Iversary's anniversary (16)"); assert.equal(ev[0].start, '2026-08-12'); } const dec = contacts.birthdayEvents({ fd: '2026-12-01', td: '2027-01-10' }, people.a.id); assert.equal(dec[0].title, "Jo Smith's birthday (46)", 'the age is shown when the year is known'); assert.equal(contacts.birthdayEvents({ fd: '2026-06-01', td: '2026-06-30' }, people.a.id).length, 0); // each person has their own book