Sync family members with contacts and birthdays

This commit is contained in:
Rafael Foster
2026-04-28 20:04:13 -03:00
parent 6f8cc712a7
commit 7b85db9b07
22 changed files with 426 additions and 45 deletions
+8 -1
View File
@@ -730,7 +730,14 @@
"private": "خاص",
"shared": "مشترك"
}
}
},
"memberPhoneLabel": "Phone number (optional)",
"memberEmailLabel": "Email (optional)",
"memberBirthDateLabel": "Birthday date (optional)",
"memberContactBirthdayHint": "This member is automatically synchronized with Contacts and Birthdays.",
"memberBirthDateInvalid": "Use a valid birthday date in the selected date format.",
"memberPhoneMeta": "Phone: {{value}}",
"memberBirthdayMeta": "Birthday: {{date}}"
},
"login": {
"tagline": "تخطيط عائلي. آمن. يحترم الخصوصية. مفتوح المصدر.",
+8 -1
View File
@@ -755,7 +755,14 @@
"addedToast": "Abonnement hinzugefügt.",
"syncedToast": "Abonnement synchronisiert.",
"deletedToast": "Abonnement gelöscht."
}
},
"memberPhoneLabel": "Telefonnummer (optional)",
"memberEmailLabel": "E-Mail (optional)",
"memberBirthDateLabel": "Geburtstag (optional)",
"memberContactBirthdayHint": "Dieses Mitglied wird automatisch mit Kontakten und Geburtstagen synchronisiert.",
"memberBirthDateInvalid": "Bitte ein gültiges Geburtstagsdatum im ausgewählten Format verwenden.",
"memberPhoneMeta": "Telefon: {{value}}",
"memberBirthdayMeta": "Geburtstag: {{date}}"
},
"login": {
"tagline": "Familienplanung. Sicher. Datenschutzfreundlich. Open Source.",
+8 -1
View File
@@ -730,7 +730,14 @@
"private": "Ιδιωτικό",
"shared": "Κοινόχρηστο"
}
}
},
"memberPhoneLabel": "Phone number (optional)",
"memberEmailLabel": "Email (optional)",
"memberBirthDateLabel": "Birthday date (optional)",
"memberContactBirthdayHint": "This member is automatically synchronized with Contacts and Birthdays.",
"memberBirthDateInvalid": "Use a valid birthday date in the selected date format.",
"memberPhoneMeta": "Phone: {{value}}",
"memberBirthdayMeta": "Birthday: {{date}}"
},
"login": {
"tagline": "Οικογενειακός προγραμματισμός. Ασφαλής. Φιλικός προς την ιδιωτικότητα. Ανοιχτός κώδικας.",
+8 -1
View File
@@ -730,7 +730,14 @@
"private": "Private",
"shared": "Shared"
}
}
},
"memberPhoneLabel": "Phone number (optional)",
"memberEmailLabel": "Email (optional)",
"memberBirthDateLabel": "Birthday date (optional)",
"memberContactBirthdayHint": "This member is automatically synchronized with Contacts and Birthdays.",
"memberBirthDateInvalid": "Use a valid birthday date in the selected date format.",
"memberPhoneMeta": "Phone: {{value}}",
"memberBirthdayMeta": "Birthday: {{date}}"
},
"login": {
"tagline": "Family planning. Secure. Privacy-friendly. Open source.",
+8 -1
View File
@@ -730,7 +730,14 @@
"private": "Privado",
"shared": "Compartido"
}
}
},
"memberPhoneLabel": "Phone number (optional)",
"memberEmailLabel": "Email (optional)",
"memberBirthDateLabel": "Birthday date (optional)",
"memberContactBirthdayHint": "This member is automatically synchronized with Contacts and Birthdays.",
"memberBirthDateInvalid": "Use a valid birthday date in the selected date format.",
"memberPhoneMeta": "Phone: {{value}}",
"memberBirthdayMeta": "Birthday: {{date}}"
},
"login": {
"tagline": "Planificación familiar. Segura. Privada. Código abierto.",
+8 -1
View File
@@ -730,7 +730,14 @@
"private": "Privé",
"shared": "Partagé"
}
}
},
"memberPhoneLabel": "Phone number (optional)",
"memberEmailLabel": "Email (optional)",
"memberBirthDateLabel": "Birthday date (optional)",
"memberContactBirthdayHint": "This member is automatically synchronized with Contacts and Birthdays.",
"memberBirthDateInvalid": "Use a valid birthday date in the selected date format.",
"memberPhoneMeta": "Phone: {{value}}",
"memberBirthdayMeta": "Birthday: {{date}}"
},
"login": {
"tagline": "Planification familiale. Sécurisée. Respectueuse de la vie privée. Open source.",
+8 -1
View File
@@ -730,7 +730,14 @@
"private": "निजी",
"shared": "साझा"
}
}
},
"memberPhoneLabel": "Phone number (optional)",
"memberEmailLabel": "Email (optional)",
"memberBirthDateLabel": "Birthday date (optional)",
"memberContactBirthdayHint": "This member is automatically synchronized with Contacts and Birthdays.",
"memberBirthDateInvalid": "Use a valid birthday date in the selected date format.",
"memberPhoneMeta": "Phone: {{value}}",
"memberBirthdayMeta": "Birthday: {{date}}"
},
"login": {
"tagline": "पारिवारिक योजना। सुरक्षित। गोपनीयता-अनुकूल। ओपन सोर्स।",
+8 -1
View File
@@ -730,7 +730,14 @@
"private": "Privato",
"shared": "Condiviso"
}
}
},
"memberPhoneLabel": "Phone number (optional)",
"memberEmailLabel": "Email (optional)",
"memberBirthDateLabel": "Birthday date (optional)",
"memberContactBirthdayHint": "This member is automatically synchronized with Contacts and Birthdays.",
"memberBirthDateInvalid": "Use a valid birthday date in the selected date format.",
"memberPhoneMeta": "Phone: {{value}}",
"memberBirthdayMeta": "Birthday: {{date}}"
},
"login": {
"tagline": "Pianificazione familiare. Sicura. Rispettosa della privacy. Open source.",
+8 -1
View File
@@ -730,7 +730,14 @@
"private": "プライベート",
"shared": "共有"
}
}
},
"memberPhoneLabel": "Phone number (optional)",
"memberEmailLabel": "Email (optional)",
"memberBirthDateLabel": "Birthday date (optional)",
"memberContactBirthdayHint": "This member is automatically synchronized with Contacts and Birthdays.",
"memberBirthDateInvalid": "Use a valid birthday date in the selected date format.",
"memberPhoneMeta": "Phone: {{value}}",
"memberBirthdayMeta": "Birthday: {{date}}"
},
"login": {
"tagline": "家族計画。安全。プライバシー重視。オープンソース。",
+8 -1
View File
@@ -730,7 +730,14 @@
"private": "Privado",
"shared": "Partilhado"
}
}
},
"memberPhoneLabel": "Telefone (opcional)",
"memberEmailLabel": "E-mail (opcional)",
"memberBirthDateLabel": "Data de aniversário (opcional)",
"memberContactBirthdayHint": "Este membro é sincronizado automaticamente com Contatos e Aniversários.",
"memberBirthDateInvalid": "Use uma data de aniversário válida no formato selecionado.",
"memberPhoneMeta": "Telefone: {{value}}",
"memberBirthdayMeta": "Aniversário: {{date}}"
},
"login": {
"tagline": "Planejamento familiar. Seguro. Privado. Código aberto.",
+8 -1
View File
@@ -730,7 +730,14 @@
"private": "Личное",
"shared": "Общее"
}
}
},
"memberPhoneLabel": "Phone number (optional)",
"memberEmailLabel": "Email (optional)",
"memberBirthDateLabel": "Birthday date (optional)",
"memberContactBirthdayHint": "This member is automatically synchronized with Contacts and Birthdays.",
"memberBirthDateInvalid": "Use a valid birthday date in the selected date format.",
"memberPhoneMeta": "Phone: {{value}}",
"memberBirthdayMeta": "Birthday: {{date}}"
},
"login": {
"tagline": "Семейное планирование. Безопасно. С уважением к приватности. Открытый исходный код.",
+8 -1
View File
@@ -730,7 +730,14 @@
"private": "Privat",
"shared": "Delad"
}
}
},
"memberPhoneLabel": "Phone number (optional)",
"memberEmailLabel": "Email (optional)",
"memberBirthDateLabel": "Birthday date (optional)",
"memberContactBirthdayHint": "This member is automatically synchronized with Contacts and Birthdays.",
"memberBirthDateInvalid": "Use a valid birthday date in the selected date format.",
"memberPhoneMeta": "Phone: {{value}}",
"memberBirthdayMeta": "Birthday: {{date}}"
},
"login": {
"tagline": "Familjeplanering. Säker. Sekretessvänlig. Öppen källkod.",
+8 -1
View File
@@ -730,7 +730,14 @@
"private": "Özel",
"shared": "Paylaşımlı"
}
}
},
"memberPhoneLabel": "Phone number (optional)",
"memberEmailLabel": "Email (optional)",
"memberBirthDateLabel": "Birthday date (optional)",
"memberContactBirthdayHint": "This member is automatically synchronized with Contacts and Birthdays.",
"memberBirthDateInvalid": "Use a valid birthday date in the selected date format.",
"memberPhoneMeta": "Phone: {{value}}",
"memberBirthdayMeta": "Birthday: {{date}}"
},
"login": {
"tagline": "Aile planlaması. Güvenli. Gizlilik dostu. Açık kaynak.",
+8 -1
View File
@@ -730,7 +730,14 @@
"private": "Приватне",
"shared": "Спільне"
}
}
},
"memberPhoneLabel": "Phone number (optional)",
"memberEmailLabel": "Email (optional)",
"memberBirthDateLabel": "Birthday date (optional)",
"memberContactBirthdayHint": "This member is automatically synchronized with Contacts and Birthdays.",
"memberBirthDateInvalid": "Use a valid birthday date in the selected date format.",
"memberPhoneMeta": "Phone: {{value}}",
"memberBirthdayMeta": "Birthday: {{date}}"
},
"login": {
"tagline": "Планування для родини. Безпечно. Конфіденційно. Відкритий код.",
+8 -1
View File
@@ -730,7 +730,14 @@
"private": "私人",
"shared": "共享"
}
}
},
"memberPhoneLabel": "Phone number (optional)",
"memberEmailLabel": "Email (optional)",
"memberBirthDateLabel": "Birthday date (optional)",
"memberContactBirthdayHint": "This member is automatically synchronized with Contacts and Birthdays.",
"memberBirthDateInvalid": "Use a valid birthday date in the selected date format.",
"memberPhoneMeta": "Phone: {{value}}",
"memberBirthdayMeta": "Birthday: {{date}}"
},
"login": {
"tagline": "家庭规划。安全。注重隐私。开源。",
+65 -1
View File
@@ -6,7 +6,7 @@
import { api, auth } from '/api.js';
import { openModal, closeModal, confirmModal } from '/components/modal.js';
import { t, formatDate, formatTime } from '/i18n.js';
import { t, formatDate, formatTime, dateInputPlaceholder, formatDateInput, parseDateInput, isDateInputValid } from '/i18n.js';
import { esc } from '/utils/html.js';
import '/components/oikos-locale-picker.js';
@@ -56,6 +56,15 @@ function buildFamilyRoleOptions(selected = 'other') {
`).join('');
}
function bindSettingsDateInputs(root) {
root.querySelectorAll('.js-date-input').forEach((input) => {
input.addEventListener('blur', () => {
const parsed = parseDateInput(input.value);
if (parsed) input.value = formatDateInput(parsed);
});
});
}
function avatarHtml(user, className = 'settings-avatar') {
const safeName = esc(user?.display_name || '');
const fallback = esc(initials(user?.display_name || ''));
@@ -586,6 +595,21 @@ export async function render(container, { user }) {
${buildFamilyRoleOptions()}
</select>
</div>
<div class="modal-grid modal-grid--2">
<div class="form-group">
<label class="form-label" for="new-member-phone">${t('settings.memberPhoneLabel')}</label>
<input class="form-input" type="tel" id="new-member-phone" autocomplete="tel" />
</div>
<div class="form-group">
<label class="form-label" for="new-member-email">${t('settings.memberEmailLabel')}</label>
<input class="form-input" type="email" id="new-member-email" autocomplete="email" />
</div>
</div>
<div class="form-group">
<label class="form-label" for="new-member-birth-date">${t('settings.memberBirthDateLabel')}</label>
<input class="form-input js-date-input" type="text" id="new-member-birth-date" placeholder="${dateInputPlaceholder()}" inputmode="numeric" />
<p class="form-hint">${t('settings.memberContactBirthdayHint')}</p>
</div>
<label class="toggle-row">
<input type="checkbox" id="new-system-admin" />
<span>${t('settings.systemAdminLabel')}</span>
@@ -625,6 +649,7 @@ export async function render(container, { user }) {
function bindEvents(container, user, users, categories, icsSubscriptions, apiTokens) {
bindTabEvents(container);
bindSettingsDateInputs(container);
bindCategoryEvents(container);
bindIcsEvents(container, user, icsSubscriptions);
bindApiTokenEvents(container, apiTokens);
@@ -934,6 +959,11 @@ function bindEvents(container, user, users, categories, icsSubscriptions, apiTok
e.preventDefault();
const errorEl = container.querySelector('#member-error');
errorEl.hidden = true;
const birthDateRaw = container.querySelector('#new-member-birth-date')?.value || '';
if (!isDateInputValid(birthDateRaw)) {
showError(errorEl, t('settings.memberBirthDateInvalid'));
return;
}
const data = {
username: container.querySelector('#new-username').value.trim(),
@@ -942,6 +972,9 @@ function bindEvents(container, user, users, categories, icsSubscriptions, apiTok
avatar_color: container.querySelector('#new-avatar-color').value,
family_role: container.querySelector('#new-family-role').value,
system_admin: container.querySelector('#new-system-admin')?.checked === true,
phone: container.querySelector('#new-member-phone')?.value.trim() || null,
email: container.querySelector('#new-member-email')?.value.trim() || null,
birth_date: parseDateInput(birthDateRaw) || null,
};
const btn = addMemberForm.querySelector('[type=submit]');
@@ -1068,6 +1101,21 @@ function openEditMemberModal(member, currentUser, users, container) {
${buildFamilyRoleOptions(member.family_role)}
</select>
</div>
<div class="modal-grid modal-grid--2">
<div class="form-group">
<label class="form-label" for="edit-member-phone">${t('settings.memberPhoneLabel')}</label>
<input class="form-input" type="tel" id="edit-member-phone" value="${esc(member.phone || '')}" autocomplete="tel" />
</div>
<div class="form-group">
<label class="form-label" for="edit-member-email">${t('settings.memberEmailLabel')}</label>
<input class="form-input" type="email" id="edit-member-email" value="${esc(member.email || '')}" autocomplete="email" />
</div>
</div>
<div class="form-group">
<label class="form-label" for="edit-member-birth-date">${t('settings.memberBirthDateLabel')}</label>
<input class="form-input js-date-input" type="text" id="edit-member-birth-date" value="${esc(formatDateInput(member.birth_date))}" placeholder="${dateInputPlaceholder()}" inputmode="numeric" />
<p class="form-hint">${t('settings.memberContactBirthdayHint')}</p>
</div>
<label class="toggle-row">
<input type="checkbox" id="edit-member-system-admin" ${member.role === 'admin' ? 'checked' : ''} />
<span>${t('settings.systemAdminLabel')}</span>
@@ -1083,6 +1131,7 @@ function openEditMemberModal(member, currentUser, users, container) {
onSave(panel) {
const fileInput = panel.querySelector('#edit-member-avatar-file');
const errorEl = panel.querySelector('#edit-member-error');
bindSettingsDateInputs(panel);
fileInput?.addEventListener('change', async () => {
errorEl.hidden = true;
try {
@@ -1116,6 +1165,12 @@ function openEditMemberModal(member, currentUser, users, container) {
e.preventDefault();
const submitBtn = panel.querySelector('[type=submit]');
errorEl.hidden = true;
const birthDateRaw = panel.querySelector('#edit-member-birth-date')?.value || '';
if (!isDateInputValid(birthDateRaw)) {
showError(errorEl, t('settings.memberBirthDateInvalid'));
submitBtn.disabled = false;
return;
}
submitBtn.disabled = true;
try {
const res = await auth.updateUser(member.id, {
@@ -1125,6 +1180,9 @@ function openEditMemberModal(member, currentUser, users, container) {
avatar_data: state.avatarData,
family_role: panel.querySelector('#edit-member-family-role').value,
system_admin: panel.querySelector('#edit-member-system-admin').checked,
phone: panel.querySelector('#edit-member-phone')?.value.trim() || null,
email: panel.querySelector('#edit-member-email')?.value.trim() || null,
birth_date: parseDateInput(birthDateRaw) || null,
});
const idx = users.findIndex((u) => u.id === member.id);
if (idx !== -1) users[idx] = res.user;
@@ -1389,12 +1447,18 @@ function bindCategoryEvents(container) {
function memberHtml(u) {
const familyRole = familyRoleLabel(u.family_role);
const systemRole = u.role === 'admin' ? ` · ${esc(t('settings.systemAdminBadge'))}` : '';
const profileMeta = [
u.phone ? t('settings.memberPhoneMeta', { value: u.phone }) : '',
u.email || '',
u.birth_date ? t('settings.memberBirthdayMeta', { date: formatDate(u.birth_date) }) : '',
].filter(Boolean).map(esc).join(' · ');
return `
<li class="settings-member" data-id="${u.id}">
${avatarHtml(u, 'settings-avatar settings-avatar--sm')}
<div class="settings-member__info">
<span class="settings-member__name">${esc(u.display_name)}</span>
<span class="settings-member__meta">@${esc(u.username)} · ${esc(familyRole)}${systemRole}</span>
${profileMeta ? `<span class="settings-member__meta">${profileMeta}</span>` : ''}
</div>
<button class="btn btn--icon btn--secondary" data-edit-user="${u.id}" aria-label="${esc(u.display_name)} ${t('settings.editMemberLabel')}" title="${t('settings.editMemberLabel')}">
<i data-lucide="edit-2" aria-hidden="true"></i>
+4 -4
View File
@@ -13,10 +13,10 @@
* → bypassCacheUntil (in-memory + Cache API für SW-Restart-Robustheit)
*/
const SHELL_CACHE = 'oikos-shell-v59';
const PAGES_CACHE = 'oikos-pages-v54';
const LOCALES_CACHE = 'oikos-locales-v5';
const ASSETS_CACHE = 'oikos-assets-v54';
const SHELL_CACHE = 'oikos-shell-v60';
const PAGES_CACHE = 'oikos-pages-v55';
const LOCALES_CACHE = 'oikos-locales-v6';
const ASSETS_CACHE = 'oikos-assets-v55';
const BYPASS_CACHE = 'oikos-bypass-flag';
const ALL_CACHES = [SHELL_CACHE, PAGES_CACHE, LOCALES_CACHE, ASSETS_CACHE];