diff --git a/client/src/components/Planner/PlaceFormModal.tsx b/client/src/components/Planner/PlaceFormModal.tsx index 14f2ba86..271e6dd7 100644 --- a/client/src/components/Planner/PlaceFormModal.tsx +++ b/client/src/components/Planner/PlaceFormModal.tsx @@ -39,6 +39,31 @@ interface PlaceFormModalProps { /** Place create/edit form state: maps search + Google-URL resolve + autocomplete, * category creation, file attachments and submit. Keeps PlaceFormModal a thin * render over the form fields. */ + +// #1152: a manually-added place is treated as a likely duplicate of an existing +// trip place if it shares the Google Place ID, the (case-insensitive) name, or +// near-identical coordinates (~11 m). Mirrors the server-side import dedup. +const DUP_COORD_TOLERANCE = 0.0001 +function findDuplicatePlace( + form: PlaceFormData, + places: { name?: string | null; lat?: number | null; lng?: number | null; google_place_id?: string | null }[], +): { name?: string | null } | null { + const name = (form.name || '').trim().toLowerCase() + const gid = (form.google_place_id || '').trim() + const lat = form.lat ? parseFloat(form.lat) : null + const lng = form.lng ? parseFloat(form.lng) : null + for (const p of places || []) { + if (gid && p.google_place_id && p.google_place_id === gid) return p + if (name && p.name && p.name.trim().toLowerCase() === name) return p + if ( + lat != null && lng != null && p.lat != null && p.lng != null && + Math.abs(Number(p.lat) - lat) <= DUP_COORD_TOLERANCE && + Math.abs(Number(p.lng) - lng) <= DUP_COORD_TOLERANCE + ) return p + } + return null +} + function usePlaceFormModal(props: PlaceFormModalProps) { const { isOpen, onClose, onSave, place, prefillCoords, tripId, categories, @@ -51,6 +76,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) { const [newCategoryName, setNewCategoryName] = useState('') const [showNewCategory, setShowNewCategory] = useState(false) const [isSaving, setIsSaving] = useState(false) + const [duplicateWarning, setDuplicateWarning] = useState(null) const [pendingFiles, setPendingFiles] = useState([]) const fileRef = useRef(null) const [acSuggestions, setAcSuggestions] = useState<{ placeId: string; mainText: string; secondaryText: string }[]>([]) @@ -94,6 +120,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) { setForm(DEFAULT_FORM) } setPendingFiles([]) + setDuplicateWarning(null) }, [place, prefillCoords, isOpen]) // Derive location bias bounding box from the trip's existing places @@ -309,6 +336,17 @@ function usePlaceFormModal(props: PlaceFormModalProps) { toast.error(t('places.nameRequired')) return } + // #1152: only for new places, and only on the first attempt — a second click + // (with the warning already showing) is the explicit "add anyway" confirmation. + if (!place && !duplicateWarning) { + const dup = findDuplicatePlace(form, places) + if (dup) { + const dupName = dup.name || form.name + setDuplicateWarning(dupName) + toast.warning(t('places.duplicateExists', { name: dupName })) + return + } + } setIsSaving(true) try { await onSave({ @@ -381,6 +419,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) { handlePaste, hasTimeError, handleSubmit, + duplicateWarning, } } @@ -441,6 +480,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) { handlePaste, hasTimeError, handleSubmit, + duplicateWarning, } = S return ( - {isSaving ? t('common.saving') : place ? t('common.update') : t('common.add')} + {isSaving ? t('common.saving') : place ? t('common.update') : duplicateWarning ? t('places.addAnyway') : t('common.add')} } diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx index 6359024d..74e5bd1b 100644 --- a/client/src/pages/LoginPage.tsx +++ b/client/src/pages/LoginPage.tsx @@ -491,6 +491,7 @@ export default function LoginPage(): React.ReactElement { onChange={(e: React.ChangeEvent) => setMfaCode(e.target.value.toUpperCase().slice(0, 24))} placeholder="000000 or XXXX-XXXX" required + autoFocus style={inputBase} onFocus={(e: React.FocusEvent) => e.target.style.borderColor = '#111827'} onBlur={(e: React.FocusEvent) => e.target.style.borderColor = '#e5e7eb'} diff --git a/client/src/pages/admin/AdminUsersTab.tsx b/client/src/pages/admin/AdminUsersTab.tsx index 6829073e..d5f8f23e 100644 --- a/client/src/pages/admin/AdminUsersTab.tsx +++ b/client/src/pages/admin/AdminUsersTab.tsx @@ -15,7 +15,7 @@ interface AdminUsersTabProps { // create-invite modal. Pure layout around the useAdmin hook — no logic of its own. export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps): React.ReactElement { const { - serverTimezone, hour12, currentUser, + hour12, currentUser, users, isLoading, setShowCreateUser, invites, showCreateInvite, setShowCreateInvite, inviteForm, setInviteForm, @@ -92,10 +92,10 @@ export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps): - {new Date(u.created_at).toLocaleDateString(locale, { timeZone: serverTimezone })} + {new Date(u.created_at).toLocaleDateString(locale)} - {u.last_login ? new Date(u.last_login).toLocaleDateString(locale, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12, timeZone: serverTimezone }) : '—'} + {u.last_login ? new Date(u.last_login).toLocaleDateString(locale, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12 }) : '—'}
@@ -162,7 +162,7 @@ export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps):
{inv.used_count}/{inv.max_uses === 0 ? '∞' : inv.max_uses} {t('admin.invite.uses')} - {inv.expires_at && ` · ${t('admin.invite.expiresAt')} ${new Date(inv.expires_at).toLocaleDateString(locale, { timeZone: serverTimezone })}`} + {inv.expires_at && ` · ${t('admin.invite.expiresAt')} ${new Date(inv.expires_at).toLocaleDateString(locale)}`} {` · ${t('admin.invite.createdBy')} ${inv.created_by_name}`}
diff --git a/server/src/services/inAppNotifications.ts b/server/src/services/inAppNotifications.ts index 1b09ed83..4dca62b9 100644 --- a/server/src/services/inAppNotifications.ts +++ b/server/src/services/inAppNotifications.ts @@ -3,6 +3,13 @@ import { broadcastToUser } from '../websocket'; import { getAction } from './inAppNotificationActions'; import { isEnabledForEvent, type NotifEventType } from './notificationPreferencesService'; +// SQLite's CURRENT_TIMESTAMP is UTC but the string ('YYYY-MM-DD HH:MM:SS') has +// no 'T'/'Z', so `new Date(...)` parses it as LOCAL time. Normalize to ISO-UTC +// so the client renders notification times in the viewer's own timezone (#1149). +function toUtcIso(ts: string): string { + return ts.endsWith('Z') ? ts : ts.replace(' ', 'T') + 'Z'; +} + type NotificationType = 'simple' | 'boolean' | 'navigate'; type NotificationScope = 'trip' | 'user' | 'admin'; type NotificationResponse = 'positive' | 'negative'; @@ -218,6 +225,7 @@ export function createNotificationForRecipient( type: 'notification:new', notification: { ...row, + created_at: toUtcIso(row.created_at), sender_username: sender?.username ?? null, sender_avatar: sender?.avatar ? `/uploads/avatars/${sender.avatar}` : null, }, @@ -251,6 +259,7 @@ function getNotifications( const mapped = rows.map(r => ({ ...r, + created_at: toUtcIso(r.created_at), sender_avatar: r.sender_avatar ? `/uploads/avatars/${r.sender_avatar}` : null, })); diff --git a/shared/src/i18n/ar/places.ts b/shared/src/i18n/ar/places.ts index 2f7a6820..8cf4b75c 100644 --- a/shared/src/i18n/ar/places.ts +++ b/shared/src/i18n/ar/places.ts @@ -86,5 +86,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': 'فشل إنشاء الفئة', 'places.nameRequired': 'يرجى إدخال اسم', 'places.saveError': 'فشل الحفظ', + 'places.duplicateExists': "'{name}' موجود بالفعل في هذه الرحلة.", + 'places.addAnyway': 'الإضافة على أي حال', }; export default places; diff --git a/shared/src/i18n/br/places.ts b/shared/src/i18n/br/places.ts index 6519b72c..171934ba 100644 --- a/shared/src/i18n/br/places.ts +++ b/shared/src/i18n/br/places.ts @@ -88,5 +88,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': 'Falha ao criar categoria', 'places.nameRequired': 'Digite um nome', 'places.saveError': 'Falha ao salvar', + 'places.duplicateExists': "'{name}' já está nesta viagem.", + 'places.addAnyway': 'Adicionar mesmo assim', }; export default places; diff --git a/shared/src/i18n/cs/places.ts b/shared/src/i18n/cs/places.ts index 93c227d3..ecb1fda7 100644 --- a/shared/src/i18n/cs/places.ts +++ b/shared/src/i18n/cs/places.ts @@ -87,5 +87,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': 'Nepodařilo se vytvořit kategorii', 'places.nameRequired': 'Prosím zadejte název', 'places.saveError': 'Uložení se nezdařilo', + 'places.duplicateExists': "'{name}' už v tomto výletu existuje.", + 'places.addAnyway': 'Přesto přidat', }; export default places; diff --git a/shared/src/i18n/de/places.ts b/shared/src/i18n/de/places.ts index d67f9d3a..a678c86e 100644 --- a/shared/src/i18n/de/places.ts +++ b/shared/src/i18n/de/places.ts @@ -88,5 +88,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': 'Fehler beim Erstellen der Kategorie', 'places.nameRequired': 'Bitte einen Namen eingeben', 'places.saveError': 'Fehler beim Speichern', + 'places.duplicateExists': "'{name}' ist bereits in dieser Reise.", + 'places.addAnyway': 'Trotzdem hinzufügen', }; export default places; diff --git a/shared/src/i18n/en/places.ts b/shared/src/i18n/en/places.ts index 9454fdb1..9739c46c 100644 --- a/shared/src/i18n/en/places.ts +++ b/shared/src/i18n/en/places.ts @@ -87,5 +87,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': 'Failed to create category', 'places.nameRequired': 'Please enter a name', 'places.saveError': 'Failed to save', + 'places.duplicateExists': "'{name}' is already in this trip.", + 'places.addAnyway': 'Add anyway', }; export default places; diff --git a/shared/src/i18n/es/places.ts b/shared/src/i18n/es/places.ts index 30477512..c5225a06 100644 --- a/shared/src/i18n/es/places.ts +++ b/shared/src/i18n/es/places.ts @@ -88,5 +88,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': 'No se pudo crear la categoría', 'places.nameRequired': 'Introduce un nombre', 'places.saveError': 'No se pudo guardar', + 'places.duplicateExists': "'{name}' ya está en este viaje.", + 'places.addAnyway': 'Añadir de todos modos', }; export default places; diff --git a/shared/src/i18n/fr/places.ts b/shared/src/i18n/fr/places.ts index 8be11565..3c45225d 100644 --- a/shared/src/i18n/fr/places.ts +++ b/shared/src/i18n/fr/places.ts @@ -89,5 +89,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': 'Impossible de créer la catégorie', 'places.nameRequired': 'Veuillez saisir un nom', 'places.saveError': "Échec de l'enregistrement", + 'places.duplicateExists': "'{name}' est déjà dans ce voyage.", + 'places.addAnyway': 'Ajouter quand même', }; export default places; diff --git a/shared/src/i18n/gr/places.ts b/shared/src/i18n/gr/places.ts index a87e851c..6aa701fe 100644 --- a/shared/src/i18n/gr/places.ts +++ b/shared/src/i18n/gr/places.ts @@ -90,5 +90,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': 'Αποτυχία δημιουργίας κατηγορίας', 'places.nameRequired': 'Παρακαλώ εισαγάγετε ένα όνομα', 'places.saveError': 'Αποτυχία αποθήκευσης', + 'places.duplicateExists': "Το '{name}' υπάρχει ήδη σε αυτό το ταξίδι.", + 'places.addAnyway': 'Προσθήκη ούτως ή άλλως', }; export default places; diff --git a/shared/src/i18n/hu/places.ts b/shared/src/i18n/hu/places.ts index 85470c2a..628bcec2 100644 --- a/shared/src/i18n/hu/places.ts +++ b/shared/src/i18n/hu/places.ts @@ -89,5 +89,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': 'Nem sikerült létrehozni a kategóriát', 'places.nameRequired': 'Kérjük, adj meg egy nevet', 'places.saveError': 'Nem sikerült menteni', + 'places.duplicateExists': "A(z) '{name}' már szerepel ebben az utazásban.", + 'places.addAnyway': 'Hozzáadás mindenképp', }; export default places; diff --git a/shared/src/i18n/id/places.ts b/shared/src/i18n/id/places.ts index 431f0a38..3c3b8f68 100644 --- a/shared/src/i18n/id/places.ts +++ b/shared/src/i18n/id/places.ts @@ -88,5 +88,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': 'Gagal membuat kategori', 'places.nameRequired': 'Harap masukkan nama', 'places.saveError': 'Gagal menyimpan', + 'places.duplicateExists': "'{name}' sudah ada di perjalanan ini.", + 'places.addAnyway': 'Tetap tambahkan', }; export default places; diff --git a/shared/src/i18n/it/places.ts b/shared/src/i18n/it/places.ts index cf9728a9..591a2248 100644 --- a/shared/src/i18n/it/places.ts +++ b/shared/src/i18n/it/places.ts @@ -88,5 +88,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': 'Impossibile creare la categoria', 'places.nameRequired': 'Inserisci un nome', 'places.saveError': 'Impossibile salvare', + 'places.duplicateExists': "'{name}' è già in questo viaggio.", + 'places.addAnyway': 'Aggiungi comunque', }; export default places; diff --git a/shared/src/i18n/ja/places.ts b/shared/src/i18n/ja/places.ts index da196afb..dee4ba1b 100644 --- a/shared/src/i18n/ja/places.ts +++ b/shared/src/i18n/ja/places.ts @@ -89,5 +89,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': 'カテゴリの作成に失敗しました', 'places.nameRequired': '名前を入力してください', 'places.saveError': '保存に失敗しました', + 'places.duplicateExists': '「{name}」はすでにこの旅程に含まれています。', + 'places.addAnyway': 'それでも追加', }; export default places; diff --git a/shared/src/i18n/ko/places.ts b/shared/src/i18n/ko/places.ts index a14719da..2275c385 100644 --- a/shared/src/i18n/ko/places.ts +++ b/shared/src/i18n/ko/places.ts @@ -86,5 +86,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': '카테고리 생성 실패', 'places.nameRequired': '이름을 입력하세요', 'places.saveError': '저장 실패', + 'places.duplicateExists': "'{name}'은(는) 이미 이 여행에 있습니다.", + 'places.addAnyway': '그래도 추가', }; export default places; diff --git a/shared/src/i18n/nl/places.ts b/shared/src/i18n/nl/places.ts index 43c37d00..2353936f 100644 --- a/shared/src/i18n/nl/places.ts +++ b/shared/src/i18n/nl/places.ts @@ -89,5 +89,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': 'Categorie aanmaken mislukt', 'places.nameRequired': 'Voer een naam in', 'places.saveError': 'Opslaan mislukt', + 'places.duplicateExists': "'{name}' staat al in deze reis.", + 'places.addAnyway': 'Toch toevoegen', }; export default places; diff --git a/shared/src/i18n/pl/places.ts b/shared/src/i18n/pl/places.ts index 04a5979e..c0b5d09d 100644 --- a/shared/src/i18n/pl/places.ts +++ b/shared/src/i18n/pl/places.ts @@ -78,6 +78,8 @@ const places: TranslationStrings = { 'places.categoryCreateError': 'Nie udało się utworzyć kategorii', 'places.nameRequired': 'Proszę podać nazwę', 'places.saveError': 'Nie udało się zapisać', + 'places.duplicateExists': "'{name}' jest już w tej podróży.", + 'places.addAnyway': 'Dodaj mimo to', 'places.importNaverList': 'Lista Naver', 'places.importList': 'Import listy', 'places.googleListHint': 'Wklej link do listy Google Maps.', diff --git a/shared/src/i18n/ru/places.ts b/shared/src/i18n/ru/places.ts index 34b8b4d9..7d6fd2bb 100644 --- a/shared/src/i18n/ru/places.ts +++ b/shared/src/i18n/ru/places.ts @@ -88,5 +88,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': 'Не удалось создать категорию', 'places.nameRequired': 'Введите название', 'places.saveError': 'Ошибка сохранения', + 'places.duplicateExists': "'{name}' уже есть в этой поездке.", + 'places.addAnyway': 'Всё равно добавить', }; export default places; diff --git a/shared/src/i18n/tr/places.ts b/shared/src/i18n/tr/places.ts index a1de391d..11e1eb03 100644 --- a/shared/src/i18n/tr/places.ts +++ b/shared/src/i18n/tr/places.ts @@ -87,5 +87,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': 'Kategori oluşturulamadı', 'places.nameRequired': 'Lütfen bir ad girin', 'places.saveError': 'Kaydedilemedi', + 'places.duplicateExists': "'{name}' zaten bu gezide var.", + 'places.addAnyway': 'Yine de ekle', }; export default places; diff --git a/shared/src/i18n/uk/places.ts b/shared/src/i18n/uk/places.ts index e00619db..d7487115 100644 --- a/shared/src/i18n/uk/places.ts +++ b/shared/src/i18n/uk/places.ts @@ -88,5 +88,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': 'Не вдалося створити категорію', 'places.nameRequired': 'Введіть назву', 'places.saveError': 'Помилка збереження', + 'places.duplicateExists': "'{name}' вже є в цій подорожі.", + 'places.addAnyway': 'Все одно додати', }; export default places; diff --git a/shared/src/i18n/zh-TW/places.ts b/shared/src/i18n/zh-TW/places.ts index f52c7aea..046bf95b 100644 --- a/shared/src/i18n/zh-TW/places.ts +++ b/shared/src/i18n/zh-TW/places.ts @@ -83,5 +83,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': '建立分類失敗', 'places.nameRequired': '請輸入名稱', 'places.saveError': '儲存失敗', + 'places.duplicateExists': "'{name}' 已在此行程中。", + 'places.addAnyway': '仍要新增', }; export default places; diff --git a/shared/src/i18n/zh/places.ts b/shared/src/i18n/zh/places.ts index 5bbb8cb7..ec93c06a 100644 --- a/shared/src/i18n/zh/places.ts +++ b/shared/src/i18n/zh/places.ts @@ -83,5 +83,7 @@ const places: TranslationStrings = { 'places.categoryCreateError': '创建分类失败', 'places.nameRequired': '请输入名称', 'places.saveError': '保存失败', + 'places.duplicateExists': "'{name}' 已在此行程中。", + 'places.addAnyway': '仍然添加', }; export default places;