Contacts: anniversaries (shown in the calendar) and gift ideas
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
388dd5b0e7
commit
a20858a6e1
3 files changed
+20
-12
No files matched your search
+12
-8
@@ -28,7 +28,7 @@ function clean(b, old = {}) {
|
|||||||
const c = {
|
const c = {
|
||||||
id: old.id || crypto.randomBytes(6).toString('hex'), name: str(b.name, 120), org: str(b.org, 120),
|
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),
|
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(); });
|
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;
|
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 === '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 === 'ADR') cur.address = val.split(';').map(unesc).filter(Boolean).join(', ');
|
||||||
else if (name === 'BDAY') cur.birthday = val.replace(/T.*$/, '');
|
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);
|
else if (name === 'NOTE') cur.notes = unesc(val);
|
||||||
}
|
}
|
||||||
return out.map(c => ({ ...c, name: c.name || c.n || '' }));
|
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.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.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.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' : '');
|
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) {
|
function birthdayEvents(win, uid) {
|
||||||
const out = [], y0 = +win.fd.slice(0, 4), y1 = +win.td.slice(0, 4);
|
const out = [], y0 = +win.fd.slice(0, 4), y1 = +win.td.slice(0, 4);
|
||||||
for (const c of load(uid)) {
|
for (const c of load(uid)) {
|
||||||
if (!c.birthday) continue;
|
for (const [field, label, color] of [['birthday', "'s birthday", '#d9578a'], ['anniversary', "'s anniversary", '#c9a227']]) {
|
||||||
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);
|
const v = c[field]; if (!v) continue;
|
||||||
for (let y = y0; y <= y1; y++) {
|
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);
|
||||||
const day = `${y}-${mo}-${d}`; if (!real(y, +mo, +d) || day < win.fd || day > win.td) continue;
|
for (let y = y0; y <= y1; y++) {
|
||||||
const end = new Date(Date.UTC(y, +mo - 1, +d + 1)).toISOString().slice(0, 10);
|
const day = `${y}-${mo}-${d}`; if (!real(y, +mo, +d) || day < win.fd || day > win.td) continue;
|
||||||
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 });
|
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;
|
return out;
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ function ctShow(c) {
|
|||||||
${c.emails.map(e => line(e.label, mailOn ? `<a href="#" data-mail="${esc(e.value)}" style="color:var(--accent)">${esc(e.value)}</a>` : esc(e.value))).join('')}
|
${c.emails.map(e => line(e.label, mailOn ? `<a href="#" data-mail="${esc(e.value)}" style="color:var(--accent)">${esc(e.value)}</a>` : esc(e.value))).join('')}
|
||||||
${c.phones.map(p => line(p.label, `<a href="tel:${esc(p.value.replace(/[^\d+]/g, ''))}" style="color:var(--accent)">${esc(p.value)}</a>`)).join('')}
|
${c.phones.map(p => line(p.label, `<a href="tel:${esc(p.value.replace(/[^\d+]/g, ''))}" style="color:var(--accent)">${esc(p.value)}</a>`)).join('')}
|
||||||
${line('address', c.address ? `<a href="https://maps.apple.com/?q=${encodeURIComponent(c.address)}" target="_blank" rel="noopener noreferrer" style="color:var(--accent)">${esc(c.address)}</a>` : '')}
|
${line('address', c.address ? `<a href="https://maps.apple.com/?q=${encodeURIComponent(c.address)}" target="_blank" rel="noopener noreferrer" style="color:var(--accent)">${esc(c.address)}</a>` : '')}
|
||||||
${line('birthday', esc(ctBday(c.birthday)))}${line('notes', esc(c.notes).replace(/\n/g, '<br>'))}</div>
|
${line('birthday', esc(ctBday(c.birthday)))}${line('anniversary', esc(ctBday(c.anniversary)))}${line('gift ideas', esc(c.gifts || '').replace(/\n/g, '<br>'))}${line('notes', esc(c.notes).replace(/\n/g, '<br>'))}</div>
|
||||||
<div class="bar">${mailOn && c.emails[0] ? `<button class="btn" id="ctw">${icon('mail')}Email</button><button class="btn ghost" id="ctm">Mail from them</button>` : ''}<button class="btn ghost" id="cte">Edit</button><button class="btn danger" id="ctd">Delete</button></div>`);
|
<div class="bar">${mailOn && c.emails[0] ? `<button class="btn" id="ctw">${icon('mail')}Email</button><button class="btn ghost" id="ctm">Mail from them</button>` : ''}<button class="btn ghost" id="cte">Edit</button><button class="btn danger" id="ctd">Delete</button></div>`);
|
||||||
el.querySelectorAll('[data-mail]').forEach(a => a.onclick = e => { e.preventDefault(); closeSheet(); ctWrite(a.dataset.mail); });
|
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); };
|
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 ctWrite(address) { if (typeof mailCompose === 'function') mailCompose({ to: address }); }
|
||||||
|
|
||||||
function ctEdit(c, seed) {
|
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) => `<div class="ctrows" data-k="${kind}">${(items.length ? items : [{ label: '', value: '' }]).map(i => `<div class="bar" style="margin:4px 0;gap:6px;flex-wrap:nowrap"><input type="text" data-l placeholder="label" value="${esc(i.label === 'other' ? '' : i.label)}" style="width:80px;flex:none"><input type="text" data-v placeholder="${ph}" value="${esc(i.value)}" style="flex:1" autocapitalize="off"></div>`).join('')}</div><a href="#" data-more="${kind}" style="color:var(--accent);font-size:14px">Add another</a>`;
|
const rows = (kind, items, ph) => `<div class="ctrows" data-k="${kind}">${(items.length ? items : [{ label: '', value: '' }]).map(i => `<div class="bar" style="margin:4px 0;gap:6px;flex-wrap:nowrap"><input type="text" data-l placeholder="label" value="${esc(i.label === 'other' ? '' : i.label)}" style="width:80px;flex:none"><input type="text" data-v placeholder="${ph}" value="${esc(i.value)}" style="flex:1" autocapitalize="off"></div>`).join('')}</div><a href="#" data-more="${kind}" style="color:var(--accent);font-size:14px">Add another</a>`;
|
||||||
const el = openSheet(c ? 'Edit contact' : 'New contact', '', `<form id="ctf"><label for="ctn">Name</label><input type="text" id="ctn" value="${esc(x.name)}" autocomplete="off"><label for="cto">Company</label><input type="text" id="cto" value="${esc(x.org)}" autocomplete="off">
|
const el = openSheet(c ? 'Edit contact' : 'New contact', '', `<form id="ctf"><label for="ctn">Name</label><input type="text" id="ctn" value="${esc(x.name)}" autocomplete="off"><label for="cto">Company</label><input type="text" id="cto" value="${esc(x.org)}" autocomplete="off">
|
||||||
<label>Email</label>${rows('emails', x.emails, 'name@example.com')}<label>Phone</label>${rows('phones', x.phones, '555 123 4567')}
|
<label>Email</label>${rows('emails', x.emails, 'name@example.com')}<label>Phone</label>${rows('phones', x.phones, '555 123 4567')}
|
||||||
<label for="cta">Address</label><input type="text" id="cta" value="${esc(x.address)}" autocomplete="off"><label for="ctb">Birthday</label><input type="date" id="ctb" value="${esc(x.birthday && !x.birthday.startsWith('--') ? x.birthday : '')}">
|
<label for="cta">Address</label><input type="text" id="cta" value="${esc(x.address)}" autocomplete="off"><label for="ctb">Birthday</label><input type="date" id="ctb" value="${esc(x.birthday && !x.birthday.startsWith('--') ? x.birthday : '')}">
|
||||||
<label for="ctt">Notes</label><textarea id="ctt" rows="3">${esc(x.notes)}</textarea><div class="err" id="cter"></div>
|
<label for="ctan">Anniversary</label><input type="date" id="ctan" value="${esc(x.anniversary && !x.anniversary.startsWith('--') ? x.anniversary : '')}"><label for="ctg">Gift ideas</label><textarea id="ctg" rows="3" placeholder="One idea per line">${esc(x.gifts || '')}</textarea><label for="ctt">Notes</label><textarea id="ctt" rows="3">${esc(x.notes)}</textarea><div class="err" id="cter"></div>
|
||||||
<div class="bar"><button class="btn ghost" type="button" id="ctx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Save</button></div></form>`);
|
<div class="bar"><button class="btn ghost" type="button" id="ctx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Save</button></div></form>`);
|
||||||
el.querySelector('#ctx').onclick = closeSheet;
|
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(); });
|
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 => {
|
el.querySelector('#ctf').onsubmit = async e => {
|
||||||
e.preventDefault(); const err = el.querySelector('#cter'); err.textContent = '';
|
e.preventDefault(); const err = el.querySelector('#cter'); err.textContent = '';
|
||||||
const keepYearless = x.birthday && x.birthday.startsWith('--') && !el.querySelector('#ctb').value;
|
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; }
|
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();
|
el.querySelector('#ctn').focus();
|
||||||
|
|||||||
@@ -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');
|
const back = contacts.parseVcf(out); assert.equal(back.length, all.length, 'what is exported can be read back');
|
||||||
// calendar birthdays
|
// 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");
|
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');
|
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);
|
assert.equal(contacts.birthdayEvents({ fd: '2026-06-01', td: '2026-06-30' }, people.a.id).length, 0);
|
||||||
// each person has their own book
|
// each person has their own book
|
||||||
|
|||||||
Reference in new issue
Block a user